diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4ab7b88c95d..f0ee1890dd2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,18 +9,17 @@ /webpack-config @Opentrons/js # subprojects - those with clear team ownership should have appropriate notifications -/protocol-designer @Opentrons/spddrs -/labware-designer @Opentrons/spddrs -/labware-library @Opentrons/spddrs -/protocol-library-kludge @Opentrons/spddrs -/update-server @Opentrons/cpx -/api/src/opentrons @Opentrons/hmg -/discovery-client @Opentrons/cpx -/shared-data/pipette @Opentrons/hmg -/shared-data/protocol @Opentrons/spddrs -/shared-data/module @Opentrons/hmg -/shared-data/deck @Opentrons/hmg -/shared-data/labware @Opentrons/hmg +/protocol-designer @Opentrons/app-and-uis +/labware-designer @Opentrons/app-and-uis +/labware-library @Opentrons/app-and-uis +/protocol-library-kludge @Opentrons/app-and-uis +/update-server @Opentrons/robot-svcs +/discovery-client @Opentrons/robot-svcs @Opentrons/app-and-uis +/shared-data/pipette @Opentrons/embedded-sw +/shared-data/protocol @Opentrons/robot-svcs @Opentrons/app-and-uis +/shared-data/module @Opentrons/embedded-sw +/shared-data/deck @Opentrons/embedded-sw +/shared-data/labware @Opentrons/embedded-sw # subprojects by language - some subprojects are shared by teams but united by a # language community (including makefiles and config) so mark them appropriately diff --git a/.gitignore b/.gitignore index 054df941b82..c387a3c75a4 100755 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,6 @@ opentrons-robot-app.tar.gz # local VERSION.json file when pushing to Flex *new_version_file.json + +# ignore linux swap files +*.swp diff --git a/.storybook/preview.js b/.storybook/preview.js index 8586896bc12..d8537e57827 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -17,6 +17,12 @@ export const customViewports = { export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, viewport: { viewports: customViewports }, + options: { + storySort: { + method: 'alphabetical', + order: ['Design Tokens', 'Library', 'App', 'ODD'], + }, + }, } // Global decorator to apply the styles to all stories diff --git a/api-client/src/deck_configuration/__stubs__/index.ts b/api-client/src/deck_configuration/__stubs__/index.ts new file mode 100644 index 00000000000..658f31fc81e --- /dev/null +++ b/api-client/src/deck_configuration/__stubs__/index.ts @@ -0,0 +1,9 @@ +import { v4 as uuidv4 } from 'uuid' + +import type { Fixture } from '../types' + +export const DECK_CONFIG_STUB: { [fixtureLocation: string]: Fixture } = { + B3: { fixtureLocation: 'B3', loadName: 'standardSlot', fixtureId: uuidv4() }, + C3: { fixtureLocation: 'C3', loadName: 'extensionSlot', fixtureId: uuidv4() }, + D3: { fixtureLocation: 'D3', loadName: 'wasteChute', fixtureId: uuidv4() }, +} diff --git a/api-client/src/deck_configuration/createDeckConfiguration.ts b/api-client/src/deck_configuration/createDeckConfiguration.ts new file mode 100644 index 00000000000..dac04f6d340 --- /dev/null +++ b/api-client/src/deck_configuration/createDeckConfiguration.ts @@ -0,0 +1,29 @@ +// import { POST, request } from '../request' +import { DECK_CONFIG_STUB } from './__stubs__' + +// import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { DeckConfiguration } from './types' + +// TODO(bh, 2023-09-26): uncomment and remove deck config stub when backend api is ready +// export function createDeckConfiguration( +// config: HostConfig, +// data: DeckConfiguration +// ): ResponsePromise { +// return request( +// POST, +// `/deck_configuration`, +// { data }, +// config +// ) +// } + +export function createDeckConfiguration( + config: HostConfig, + data: DeckConfiguration +): Promise<{ data: DeckConfiguration }> { + data.forEach(fixture => { + DECK_CONFIG_STUB[fixture.fixtureLocation] = fixture + }) + return Promise.resolve({ data: Object.values(DECK_CONFIG_STUB) }) +} diff --git a/api-client/src/deck_configuration/deleteDeckConfiguration.ts b/api-client/src/deck_configuration/deleteDeckConfiguration.ts new file mode 100644 index 00000000000..f91613ebf2f --- /dev/null +++ b/api-client/src/deck_configuration/deleteDeckConfiguration.ts @@ -0,0 +1,30 @@ +// import { DELETE, request } from '../request' +import { DECK_CONFIG_STUB } from './__stubs__' + +// import type { ResponsePromise } from '../request' +import type { EmptyResponse, HostConfig } from '../types' +import type { Fixture } from './types' + +// TODO(bh, 2023-09-26): uncomment and remove deck config stub when backend api is ready +// export function deleteDeckConfiguration( +// config: HostConfig, +// data: Fixture +// ): ResponsePromise { +// const { fixtureLocation, ...rest } = data +// return request }>( +// DELETE, +// `/deck_configuration/${fixtureLocation}`, +// { data: rest }, +// config +// ) +// } + +export function deleteDeckConfiguration( + config: HostConfig, + data: Fixture +): Promise { + const { fixtureLocation } = data + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete DECK_CONFIG_STUB[fixtureLocation] + return Promise.resolve({ data: null }) +} diff --git a/api-client/src/deck_configuration/getDeckConfiguration.ts b/api-client/src/deck_configuration/getDeckConfiguration.ts new file mode 100644 index 00000000000..bca636522d2 --- /dev/null +++ b/api-client/src/deck_configuration/getDeckConfiguration.ts @@ -0,0 +1,19 @@ +// import { GET, request } from '../request' +import { DECK_CONFIG_STUB } from './__stubs__' + +// import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { DeckConfiguration } from './types' + +// TODO(bh, 2023-09-26): uncomment and remove deck config stub when backend api is ready +// export function getDeckConfiguration( +// config: HostConfig +// ): ResponsePromise { +// return request(GET, `/deck_configuration`, null, config) +// } + +export function getDeckConfiguration( + config: HostConfig +): Promise<{ data: DeckConfiguration }> { + return Promise.resolve({ data: Object.values(DECK_CONFIG_STUB) }) +} diff --git a/api-client/src/deck_configuration/index.ts b/api-client/src/deck_configuration/index.ts new file mode 100644 index 00000000000..dbd362b3598 --- /dev/null +++ b/api-client/src/deck_configuration/index.ts @@ -0,0 +1,6 @@ +export { createDeckConfiguration } from './createDeckConfiguration' +export { deleteDeckConfiguration } from './deleteDeckConfiguration' +export { getDeckConfiguration } from './getDeckConfiguration' +export { updateDeckConfiguration } from './updateDeckConfiguration' + +export * from './types' diff --git a/api-client/src/deck_configuration/types.ts b/api-client/src/deck_configuration/types.ts new file mode 100644 index 00000000000..a496f476968 --- /dev/null +++ b/api-client/src/deck_configuration/types.ts @@ -0,0 +1,11 @@ +// TODO(bh, 2023-09-26): refine types and move to shared data when settled +export type FixtureName = 'extensionSlot' | 'standardSlot' | 'wasteChute' +export type FixtureLocation = 'B3' | 'C3' | 'D3' + +export interface Fixture { + fixtureId: string + fixtureLocation: FixtureLocation + loadName: FixtureName +} + +export type DeckConfiguration = Fixture[] diff --git a/api-client/src/deck_configuration/updateDeckConfiguration.ts b/api-client/src/deck_configuration/updateDeckConfiguration.ts new file mode 100644 index 00000000000..8e0c0afe7b4 --- /dev/null +++ b/api-client/src/deck_configuration/updateDeckConfiguration.ts @@ -0,0 +1,32 @@ +import { v4 as uuidv4 } from 'uuid' + +// import { PATCH, request } from '../request' +import { DECK_CONFIG_STUB } from './__stubs__' + +// import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { Fixture } from './types' + +// TODO(bh, 2023-09-26): uncomment and remove deck config stub when backend api is ready +// export function updateDeckConfiguration( +// config: HostConfig, +// data: Omit +// ): ResponsePromise { +// const { fixtureLocation, ...rest } = data +// return request }>( +// PATCH, +// `/deck_configuration/${fixtureLocation}`, +// { data: rest }, +// config +// ) +// } + +export function updateDeckConfiguration( + config: HostConfig, + data: Omit +): Promise<{ data: Fixture }> { + const { fixtureLocation } = data + const fixtureId = uuidv4() + DECK_CONFIG_STUB[fixtureLocation] = { ...data, fixtureId } + return Promise.resolve({ data: DECK_CONFIG_STUB[fixtureLocation] }) +} diff --git a/api-client/src/index.ts b/api-client/src/index.ts index fff01e6e91c..f6957a49e8f 100644 --- a/api-client/src/index.ts +++ b/api-client/src/index.ts @@ -1,5 +1,6 @@ // api client entry point export * from './calibration' +export * from './deck_configuration' export * from './health' export * from './instruments' export * from './maintenance_runs' diff --git a/api-client/src/protocols/__tests__/utils.test.ts b/api-client/src/protocols/__tests__/utils.test.ts index 82019ec9cc3..f86532d7359 100644 --- a/api-client/src/protocols/__tests__/utils.test.ts +++ b/api-client/src/protocols/__tests__/utils.test.ts @@ -10,10 +10,17 @@ import { parseLiquidsInLoadOrder, parseLabwareInfoByLiquidId, parseInitialLoadedLabwareByAdapter, + parseInitialLoadedFixturesByCutout, } from '../utils' import { simpleAnalysisFileFixture } from '../__fixtures__' -import type { RunTimeCommand } from '@opentrons/shared-data' +import { + LoadFixtureRunTimeCommand, + RunTimeCommand, + STAGING_AREA_LOAD_NAME, + STANDARD_SLOT_LOAD_NAME, + WASTE_CHUTE_LOAD_NAME, +} from '@opentrons/shared-data' const mockRunTimeCommands: RunTimeCommand[] = simpleAnalysisFileFixture.commands as any const mockLoadLiquidRunTimeCommands = [ @@ -359,6 +366,53 @@ describe('parseInitialLoadedModulesBySlot', () => { ) }) }) +describe('parseInitialLoadedFixturesByCutout', () => { + it('returns fixtures loaded in cutouts', () => { + const loadFixtureCommands: LoadFixtureRunTimeCommand[] = [ + { + id: 'fakeId1', + commandType: 'loadFixture', + params: { + loadName: STAGING_AREA_LOAD_NAME, + location: { cutout: 'B3' }, + }, + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + status: 'succeeded', + }, + { + id: 'fakeId2', + commandType: 'loadFixture', + params: { loadName: WASTE_CHUTE_LOAD_NAME, location: { cutout: 'D3' } }, + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + status: 'succeeded', + }, + { + id: 'fakeId3', + commandType: 'loadFixture', + params: { + loadName: STANDARD_SLOT_LOAD_NAME, + location: { cutout: 'C3' }, + }, + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + status: 'succeeded', + }, + ] + const expected = { + B3: loadFixtureCommands[0], + D3: loadFixtureCommands[1], + C3: loadFixtureCommands[2], + } + expect(parseInitialLoadedFixturesByCutout(loadFixtureCommands)).toEqual( + expected + ) + }) +}) describe('parseLiquidsInLoadOrder', () => { it('returns liquids in loaded order', () => { const expected = [ diff --git a/api-client/src/protocols/utils.ts b/api-client/src/protocols/utils.ts index 6e682d8757a..dabc7f7a5c9 100644 --- a/api-client/src/protocols/utils.ts +++ b/api-client/src/protocols/utils.ts @@ -17,6 +17,7 @@ import type { LoadModuleRunTimeCommand, LoadPipetteRunTimeCommand, LoadLiquidRunTimeCommand, + LoadFixtureRunTimeCommand, } from '@opentrons/shared-data/protocol/types/schemaV7/command/setup' interface PipetteNamesByMount { @@ -210,14 +211,14 @@ interface LoadedModulesBySlot { export function parseInitialLoadedModulesBySlot( commands: RunTimeCommand[] ): LoadedModulesBySlot { - const loadLabwareCommandsReversed = commands + const loadModuleCommandsReversed = commands .filter( (command): command is LoadModuleRunTimeCommand => command.commandType === 'loadModule' ) .reverse() return reduce( - loadLabwareCommandsReversed, + loadModuleCommandsReversed, (acc, command) => 'slotName' in command.params.location ? { ...acc, [command.params.location.slotName]: command } @@ -226,6 +227,25 @@ export function parseInitialLoadedModulesBySlot( ) } +interface LoadedFixturesBySlot { + [slotName: string]: LoadFixtureRunTimeCommand +} +export function parseInitialLoadedFixturesByCutout( + commands: RunTimeCommand[] +): LoadedFixturesBySlot { + const loadFixtureCommandsReversed = commands + .filter( + (command): command is LoadFixtureRunTimeCommand => + command.commandType === 'loadFixture' + ) + .reverse() + return reduce( + loadFixtureCommandsReversed, + (acc, command) => ({ ...acc, [command.params.location.cutout]: command }), + {} + ) +} + export interface LiquidsById { [liquidId: string]: { displayName: string diff --git a/api/docs/img/Flex-and-OT-2-decks.svg b/api/docs/img/Flex-and-OT-2-decks.svg new file mode 100644 index 00000000000..d8615b34752 --- /dev/null +++ b/api/docs/img/Flex-and-OT-2-decks.svg @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/api/docs/img/complex_commands/robot_consolidate.png b/api/docs/img/complex_commands/robot_consolidate.png index 01244e8a829..3617df215c5 100755 Binary files a/api/docs/img/complex_commands/robot_consolidate.png and b/api/docs/img/complex_commands/robot_consolidate.png differ diff --git a/api/docs/img/complex_commands/robot_distribute.png b/api/docs/img/complex_commands/robot_distribute.png index 974e13891e0..74947b90f39 100755 Binary files a/api/docs/img/complex_commands/robot_distribute.png and b/api/docs/img/complex_commands/robot_distribute.png differ diff --git a/api/docs/img/feature_flags/door_safety_switch.png b/api/docs/img/feature_flags/door_safety_switch.png deleted file mode 100644 index 7705e02aee7..00000000000 Binary files a/api/docs/img/feature_flags/door_safety_switch.png and /dev/null differ diff --git a/api/docs/static/override_sphinx.css b/api/docs/static/override_sphinx.css index 61f9514e6ea..20e923f16c4 100644 --- a/api/docs/static/override_sphinx.css +++ b/api/docs/static/override_sphinx.css @@ -50,6 +50,37 @@ div.body h1 + p { padding-top: 24px; } +div.document { + position: relative; +} + +div.documentwrapper { + float: none; +} + +/* Don't allow inline items try to clear themselves below the sidebar */ +pre, div[class*="highlight-"], blockquote, blockquote::after, div.admonition::after { + clear: none; +} + +/* Sticky, scrolling sidebar. Height calc leaves room for header. */ +div.sphinxsidebar { + position: sticky; + top: 150px; + align-self: flex-start; + max-height: calc(100vh - 150px); + overflow-y: auto; +} + +div.sphinxsidebarwrapper { + padding-top: 6px; +} + +/* Hide the redundant 'description' tagline */ +p.blurb { + display: none; +} + div.sphinxsidebar h2, div.sphinxsidebar h3, div.sphinxsidebar h4, @@ -183,6 +214,21 @@ ul ul { div.body{ padding: 5%; } +/* In narrow viewports, order sidebar after main content using a flexbox */ + div.document{ + display: flex; + flex-direction: column; + padding-top: 60px; + } + div.sphinxsidebar{ + order: 2; + top: 0; + padding-top: 0; + max-height: none; + } + div.documentwrapper{ + order: 1; + } } /* Fixes overflowing grey box for autogenerated methods */ table.docutils.field-list { @@ -200,6 +246,7 @@ table.docutils.field-list { /* Adjustments to Note and Warning admonitions */ div.admonition { padding-top: 20px; + clear: none; } div.admonition p.admonition-title { diff --git a/api/docs/templates/v2/toc-with-nav.html b/api/docs/templates/v2/toc-with-nav.html index b4576cb3160..9f398deb22b 100644 --- a/api/docs/templates/v2/toc-with-nav.html +++ b/api/docs/templates/v2/toc-with-nav.html @@ -1,5 +1,5 @@ -

{{ _('Table Of Contents') }}

+

{{ _('Table of Contents') }}

{{ toctree(includehidden=theme_sidebar_showhidden, collapse=theme_sidebar_collapse) }}
    diff --git a/api/docs/v1/conf.py b/api/docs/v1/conf.py index 6f0f4fc6f20..a3a09ec6a65 100644 --- a/api/docs/v1/conf.py +++ b/api/docs/v1/conf.py @@ -150,7 +150,7 @@ 'font_family': "'Open Sans', sans-serif", 'head_font_family': "'AkkoPro-Regular', 'Open Sans'", 'sidebar_collapse': 'True', - 'fixed_sidebar': 'False', + 'fixed_sidebar': 'True', 'github_user': 'opentrons', 'github_repo': 'opentrons', 'github_button': True, diff --git a/api/docs/v2/adapting_ot2_flex.rst b/api/docs/v2/adapting_ot2_flex.rst new file mode 100644 index 00000000000..c92534c18c7 --- /dev/null +++ b/api/docs/v2/adapting_ot2_flex.rst @@ -0,0 +1,150 @@ +:og:description: How to adapt an OT-2 Python protocol to run on Opentrons Flex. + +.. _adapting-ot2-protocols: + +******************************** +Adapting OT-2 Protocols for Flex +******************************** + +Python protocols designed to run on the OT-2 can't be directly run on Flex without some modifications. This page describes the minimal steps that you need to take to get OT-2 protocols analyzing and running on Flex. + +Adapting a protocol for Flex lets you have parity across different Opentrons robots in your lab, or you can extend older protocols to take advantage of new features only available on Flex. Depending on your application, you may need to do additional verification of your adapted protocol. + +Examples on this page are in tabs so you can quickly move back and forth to see the differences between OT-2 and Flex code. + +Metadata and Requirements +========================= + +Flex requires you to specify an ``apiLevel`` of 2.15 or higher. If your OT-2 protocol specified ``apiLevel`` in the ``metadata`` dictionary, it's best to move it to the ``requirements`` dictionary. You can't specify it in both places, or the API will raise an error. + +.. note:: + Consult the :ref:`list of changes in API versions ` to see what effect raising the ``apiLevel`` will have. If you increased it by multiple minor versions to get your protocol running on Flex, make sure that your protocol isn't using removed commands or commands whose behavior has changed in a way that may affect your scientific results. + +You also need to specify ``'robotType': 'Flex'``. If you omit ``robotType`` in the ``requirements`` dictionary, the API will assume the protocol is designed for the OT-2. + +.. tabs:: + + .. tab:: Original OT-2 code + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + metadata = { + "protocolName": "My Protocol", + "description": "This protocol uses the OT-2", + "apiLevel": "2.14" + } + + .. tab:: Updated Flex code + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + metadata = { + "protocolName": "My Protocol", + "description": "This protocol uses the Flex", + } + + requirements = {"robotType": "Flex", "apiLevel": "|apiLevel|"} + + +Pipettes and Tip-rack Load Names +================================ + +Flex uses different types of pipettes and tip racks than OT-2, which have their own load names in the API. If possible, load Flex pipettes of the same capacity or larger than the OT-2 pipettes. See the :ref:`list of pipette API load names ` for the valid values of ``instrument_name`` in Flex protocols. And check `Labware Library `_ or the Opentrons App for the load names of Flex tip racks. + +.. note:: + If you use smaller capacity tips than in the OT-2 protocol, you may need to make further adjustments to avoid running out of tips. Also, the protocol may have more steps and take longer to execute. + +This example converts OT-2 code that uses a P300 Single-Channel GEN2 pipette and 300 µL tips to Flex code that uses a Flex 1-Channel 1000 µL pipette and 1000 µL tips. + +.. tabs:: + + .. tab:: Original OT-2 code + + .. code-block:: python + + def run(protocol: protocol_api.ProtocolContext): + tips = protocol.load_labware("opentrons_96_tiprack_300ul", 1) + left_pipette = protocol.load_instrument( + "p300_single_gen2", "left", tip_racks=[tips] + ) + + .. tab:: Updated Flex code + + .. code-block:: python + + def run(protocol: protocol_api.ProtocolContext): + tips = protocol.load_labware("opentrons_flex_96_tiprack_1000ul", "D1") + left_pipette = protocol.load_instrument( + "flex_1channel_1000", "left", tip_racks[tips] + ) + +Deck Slot Labels +================ + +It's good practice to update numeric labels for :ref:`deck slots ` (which match the labels on an OT-2) to coordinate ones (which match the labels on Flex). This is an optional step, since the two formats are interchangeable. + +For example, the code in the previous section changed the location of the tip rack from ``1`` to ``"D1"``. + + +Module Load Names +================= + +If your OT-2 protocol uses older generations of the Temperature Module or Thermocycler Module, update the load names you pass to :py:meth:`.load_module` to ones compatible with Flex: + + * ``temperature module gen2`` + * ``thermocycler module gen2`` or ``thermocyclerModuleV2`` + +The Heater-Shaker Module only has one generation, ``heaterShakerModuleV1``, which is compatible with Flex and OT-2. + +The Magnetic Module is not compatible with Flex. For protocols that load ``magnetic module``, ``magdeck``, or ``magnetic module gen2``, you will need to make further modifications to use the :ref:`Magnetic Block ` and Flex Gripper instead. This will require reworking some of your protocol steps, and you should verify that your new protocol design achieves similar results. + +This simplified example, taken from a DNA extraction protocol, shows how using the Flex Gripper and the Magnetic Block can save time. Instead of pipetting an entire plate's worth of liquid from the Heater-Shaker to the Magnetic Module and then engaging the module, the gripper moves the plate to the Magnetic Block in one step. + +.. tabs:: + + .. tab:: Original OT-2 code + + .. code-block:: python + + hs_mod.set_and_wait_for_shake_speed(2000) + protocol.delay(minutes=5) + hs_mod.deactivate_shaker() + + for i in sample_plate.wells(): + # mix, transfer, and blow-out all samples + pipette.pick_up_tip() + pipette.aspirate(100,hs_plate[i]) + pipette.dispense(100,hs_plate[i]) + pipette.aspirate(100,hs_plate[i]) + pipette.air_gap(10) + pipette.dispense(pipette.current_volume,mag_plate[i]) + pipette.aspirate(50,hs_plate[i]) + pipette.air_gap(10) + pipette.dispense(pipette.current_volume,mag_plate[i]) + pipette.blow_out(mag_plate[i].bottom(0.5)) + pipette.drop_tip() + + mag_mod.engage() + + # perform elution steps + + .. tab:: Updated Flex code + + .. code-block:: python + + hs_mod.set_and_wait_for_shake_speed(2000) + protocol.delay(minutes=5) + hs_mod.deactivate_shaker() + + # move entire plate + # no pipetting from Heater-Shaker needed + hs_mod.open_labware_latch() + protocol.move_labware(sample_plate, mag_block, use_gripper=True) + + # perform elution steps diff --git a/api/docs/v2/basic_commands/liquids.rst b/api/docs/v2/basic_commands/liquids.rst new file mode 100644 index 00000000000..00e81449a40 --- /dev/null +++ b/api/docs/v2/basic_commands/liquids.rst @@ -0,0 +1,237 @@ +:og:description: Basic commands for working with liquids. + +.. _liquid-control: + +************** +Liquid Control +************** + +After attaching a tip, your robot is ready to aspirate, dispense, and perform other liquid handling tasks. The API includes methods that help you perform these actions and the following sections show how to use them. The examples used here assume that you've loaded the pipettes and labware from the basic :ref:`protocol template `. + +.. _new-aspirate: + +Aspirate +======== + +To draw liquid up into a pipette tip, call the :py:meth:`.InstrumentContext.aspirate` method. Using this method, you can specify the aspiration volume in µL, the well location, and pipette flow rate. Other parameters let you position the pipette within a well. For example, this snippet tells the robot to aspirate 200 µL from well location A1. + +.. code-block:: python + + pipette.pick_up_tip() + pipette.aspirate(200, plate['A1']) + +If the pipette doesn't move, you can specify an additional aspiration action without including a location. To demonstrate, this code snippet pauses the protocol, automatically resumes it, and aspirates a second time from ``plate['A1']``). + +.. code-block:: python + + pipette.pick_up_tip() + pipette.aspirate(200, plate['A1']) + protocol.delay(seconds=5) # pause for 5 seconds + pipette.aspirate(100) # aspirate 100 µL at current position + +Now our pipette holds 300 µL. + +Aspirate by Well or Location +---------------------------- + +The :py:meth:`~.InstrumentContext.aspirate` method includes a ``location`` parameter that accepts either a :py:class:`.Well` or a :py:class:`~.types.Location`. + +If you specify a well, like ``plate['A1']``, the pipette will aspirate from a default position 1 mm above the bottom center of that well. To change the default clearance, first set the ``aspirate`` attribute of :py:obj:`.well_bottom_clearance`:: + + pipette.pick_up_tip + pipette.well_bottom_clearance.aspirate = 2 # tip is 2 mm above well bottom + pipette.aspirate(200, plate['A1']) + +You can also aspirate from a location along the center vertical axis within a well using the :py:meth:`.Well.top` and :py:meth:`.Well.bottom` methods. These methods move the pipette to a specified distance relative to the top or bottom center of a well:: + + pipette.pick_up_tip() + depth = plate['A1'].bottom(z=2) # tip is 2 mm above well bottom + pipette.aspirate(200, depth) + +See also: + +- :ref:`new-default-op-positions` for information about controlling pipette height for a particular pipette. +- :ref:`position-relative-labware` for information about controlling pipette height from within a well. +- :ref:`move-to` for information about moving a pipette to any reachable deck location. + +Aspiration Flow Rates +--------------------- + +Flex and OT-2 pipettes aspirate at :ref:`default flow rates ` measured in µL/s. Specifying the ``rate`` parameter multiplies the flow rate by that value. As a best practice, don't set the flow rate higher than 3x the default. For example, this code causes the pipette to aspirate at twice its normal rate:: + + + pipette.aspirate(200, plate['A1'], rate=2.0) + +.. versionadded:: 2.0 + +.. _new-dispense: + +Dispense +======== + +To dispense liquid from a pipette tip, call the :py:meth:`.InstrumentContext.dispense` method. Using this method, you can specify the dispense volume in µL, the well location, and pipette flow rate. Other parameters let you position the pipette within a well. For example, this snippet tells the robot to dispense 200 µL into well location B1. + +.. code-block:: python + + pipette.dispense(200, plate['B1']) + +If the pipette doesn’t move, you can specify an additional dispense action without including a location. To demonstrate, this code snippet pauses the protocol, automatically resumes it, and dispense a second time from location B1. + +.. code-block:: python + + pipette.dispense(100, plate['B1']) + protocol.delay(seconds=5) # pause for 5 seconds + pipette.dispense(100) # dispense 100 µL at current position + +Dispense by Well or Location +---------------------------- + +The :py:meth:`~.InstrumentContext.dispense` method includes a ``location`` parameter that accepts either a :py:class:`.Well` or a :py:class:`~.types.Location`. + +If you specify a well, like ``plate['B1']``, the pipette will dispense from a default position 1 mm above the bottom center of that well. To change the default clearance, you would call :py:obj:`.well_bottom_clearance`:: + + pipette.well_bottom_clearance.dispense=2 # tip is 2 mm above well bottom + pipette.dispense(200, plate['B1']) + +You can also dispense from a location along the center vertical axis within a well using the :py:meth:`.Well.top` and :py:meth:`.Well.bottom` methods. These methods move the pipette to a specified distance relative to the top or bottom center of a well:: + + depth = plate['B1'].bottom(z=2) # tip is 2 mm above well bottom + pipette.dispense(200, depth) + +See also: + +- :ref:`new-default-op-positions` for information about controlling pipette height for a particular pipette. +- :ref:`position-relative-labware` for formation about controlling pipette height from within a well. +- :ref:`move-to` for information about moving a pipette to any reachable deck location. + +Dispense Flow Rates +------------------- + +Flex and OT-2 pipettes dispense at :ref:`default flow rates ` measured in µL/s. Adding a number to the ``rate`` parameter multiplies the flow rate by that value. As a best practice, don't set the flow rate higher than 3x the default. For example, this code causes the pipette to dispense at twice its normal rate:: + + pipette.dispense(200, plate['B1'], rate=2.0) + +.. Removing the 2 notes here from the original. Covered by new revisions. + +.. versionadded:: 2.0 + +.. _new-blow-out: + +.. _blow-out: + +Blow Out +======== + +To blow an extra amount of air through the pipette's tip, call the :py:meth:`.InstrumentContext.blow_out` method. You can use a specific well in a well plate or reservoir as the blowout location. If no location is specified, the pipette will blowout from its current well position:: + + pipette.blow_out() + +You can also specify a particular well as the blowout location:: + + pipette.blow_out(plate['B1']) + +Many protocols use the trash bin for blowing out the pipette. You can specify the trash bin as the blowout location by using the :py:obj:`.ProtocolContext.fixed_trash` property:: + + pipette.blow_out(protocol.fixed_trash['A1']) + +.. versionadded:: 2.0 + +.. _touch-tip: + +Touch Tip +========= + +The :py:meth:`.InstrumentContext.touch_tip` method moves the pipette so the tip touches each wall of a well. A touch tip procedure helps knock off any droplets that might cling to the pipette's tip. This method includes optional arguments that allow you to control where the tip will touch the inner walls of a well and the touch speed. Calling :py:meth:`~.InstrumentContext.touch_tip` without arguments causes the pipette to touch the well walls from its current location:: + + pipette.touch_tip() + +Touch Location +-------------- + +These optional location arguments give you control over where the tip will touch the side of a well. + +This example demonstrates touching the tip in a specific well:: + + pipette.touch_tip(plate['B1']) + +This example uses an offset to set the touch tip location 2mm below the top of the current well:: + + pipette.touch_tip(v_offset=-2) + +This example moves the pipette 75% of well's total radius and 2 mm below the top of well:: + + pipette.touch_tip(plate['B1'], + radius=0.75, + v_offset=-2) + +The ``touch_tip`` feature allows the pipette to touch the edges of a well gently instead of crashing into them. It includes the ``radius`` argument. When ``radius=1`` the robot moves the centerline of the pipette’s plunger axis to the edge of a well. This means a pipette tip may sometimes touch the well wall too early, causing it to bend inwards. A smaller radius helps avoid premature wall collisions and a lower speed produces gentler motion. Different liquid droplets behave differently, so test out these parameters in a single well before performing a full protocol run. + +.. warning:: + *Do not* set the ``radius`` value greater than ``1.0``. When ``radius`` is > ``1.0``, the robot will forcibly move the pipette tip across a well wall or edge. This type of aggressive movement can damage the pipette tip and the pipette. + +Touch Speed +----------- + +Touch speed controls how fast the pipette moves in mm/s during a touch tip step. The default movement speed is 60 mm/s, the minimum is 1 mm/s, and the maximum is 80 mm/s. Calling ``touch_tip`` without any arguments moves a tip at the default speed in the current well:: + + pipette.touch_tip() + +This example specifies a well location and sets the speed to 20 mm/s:: + + pipette.touch_tip(plate['B1'], speed=20) + +This example uses the current well and sets the speed to 80 mm/s:: + + pipette.touch_tip(speed=80) + +.. versionadded:: 2.0 + +.. versionchanged:: 2.4 + Lowered minimum speed to 1 mm/s. + +.. _mix: + +Mix +==== + +The :py:meth:`~.InstrumentContext.mix` method aspirates and dispenses repeatedly in a single location. It's designed to mix the contents of a well together using a single command rather than using multiple ``aspirate()`` and ``dispense()`` calls. This method includes arguments that let you specify the number of times to mix, the volume (in µL) of liquid, and the well that contains the liquid you want to mix. + +This example draws 100 µL from the current well and mixes it three times:: + + pipette.mix(repetitions=3, volume=100) + +This example draws 100 µL from well B1 and mixes it three times:: + + pipette.mix(3, 100, plate['B1']) + +This example draws an amount equal to the pipette's maximum rated volume and mixes it three times:: + + pipette.mix(repetitions=3) + +.. note:: + + In API versions 2.2 and earlier, during a mix, the pipette moves up and out of the target well. In API versions 2.3 and later, the pipette does not move while mixing. + +.. versionadded:: 2.0 + +.. _air-gap: + +Air Gap +======= + +The :py:meth:`.InstrumentContext.air_gap` method tells the pipette to draw in air before or after a liquid. Creating an air gap helps keep liquids from seeping out of a pipette after drawing it from a well. This method includes arguments that give you control over the amount of air to aspirate and the pipette's height (in mm) above the well. By default, the pipette moves 5 mm above a well before aspirating air. Calling :py:meth:`~.InstrumentContext.air_gap` with no arguments uses the entire remaining volume in the pipette. + +This example aspirates 200 µL of air 5 mm above the current well:: + + pipette.air_gap(volume=200) + +This example aspirates 200 µL of air 20 mm above the the current well:: + + pipette.air_gap(volume=200, height=20) + +This example aspirates enough air to fill the remaining volume in a pipette:: + + pipette.air_gap() + +.. versionadded:: 2.0 + diff --git a/api/docs/v2/basic_commands/pipette_tips.rst b/api/docs/v2/basic_commands/pipette_tips.rst new file mode 100644 index 00000000000..4495ea1f71f --- /dev/null +++ b/api/docs/v2/basic_commands/pipette_tips.rst @@ -0,0 +1,115 @@ +:og:description: Basic commands for working with pipette tips. + +.. _pipette-tips: + +************************* +Manipulating Pipette Tips +************************* + +Your robot needs to attach a disposable tip to the pipette before it can aspirate or dispense liquids. The API provides three basic functions that help the robot attach and manage pipette tips during a protocol run. These methods are :py:meth:`.InstrumentContext.pick_up_tip`, :py:meth:`.InstrumentContext.drop_tip`, and :py:meth:`.InstrumentContext.return_tip`. Respectively, these methods tell the robot to pick up a tip from a tip rack, drop a tip into the trash (or another location), and return a tip to its location in the tip rack. + +The following sections demonstrate how to use each method and include sample code. The examples used here assume that you've loaded the pipettes and labware from the basic :ref:`protocol template `. + +Picking Up a Tip +================ + +To pick up a tip, call the :py:meth:`~.InstrumentContext.pick_up_tip` method without any arguments:: + + pipette.pick_up_tip() + +This simple statement works because the variable ``tiprack_1`` in the sample protocol includes the on-deck location of the tip rack (Flex ``location="D3"``, OT-2 ``location=3``) *and* the ``pipette`` variable includes the argument ``tip_racks=[tiprack_1]``. Given this information, the robot moves to the tip rack and picks up a tip from position A1 in the rack. On subsequent calls to ``pick_up_tip()``, the robot will use the next available tip. For example:: + + pipette.pick_up_tip() # picks up tip from rack location A1 + pipette.drop_tip() # drops tip in trash bin + pipette.pick_up_tip() # picks up tip from rack location B1 + pipette.drop_tip() # drops tip in trash bin + +If you omit the ``tip_rack`` argument from the ``pipette`` variable, the API will raise an error. You must pass in the tip rack's location to ``pick_up_tip`` like this:: + + pipette.pick_up_tip(tiprack_1['A1']) + pipette.drop_tip() + pipette.pick_up_tip(tiprack_1['B1']) + +If coding the location of each tip seems inefficient or tedious, try using a ``for`` loop to automate a sequential tip pick up process. When using a loop, the API keeps track of tips and manages tip pickup for you. But ``pick_up_tip`` is still a powerful feature. It gives you direct control over tip use when that’s important in your protocol. + +.. versionadded:: 2.0 + +Automating Tip Pick Up +====================== + +When used with Python's :py:class:`range` class, a ``for`` loop brings automation to the tip pickup and tracking process. It also eliminates the need to call ``pick_up_tip()`` multiple times. For example, this snippet tells the robot to sequentially use all the tips in a 96-tip rack:: + + for i in range(96): + pipette.pick_up_tip() + # liquid handling commands + pipette.drop_tip() + +If your protocol requires a lot of tips, add a second tip rack to the protocol. Then, associate it with your pipette and increase the number of repetitions in the loop. The robot will work through both racks. + +First, add another tip rack to the sample protocol:: + + tiprack_2 = protocol.load_labware( + load_name="opentrons_flex_96_tiprack_1000ul", + location="C3" + ) + +Next, append the new tip rack to the pipette's ``tip_rack`` property:: + + pipette = protocol.load_instrument( + instrument_name="flex_1channel_1000", + mount="left", + tip_racks=[tiprack_1, tiprack_2], + ) + pipette_1.tip_racks.append(tiprack_2) + +Finally, sum the tip count in the range:: + + for i in range(192): + pipette.pick_up_tip() + pipette.drop_tip() + +For a more advanced "real-world" example, review the :ref:`off-deck location protocol ` on the :ref:`moving-labware` page. This example also uses a ``for`` loop to iterate through a tip rack, but it includes other commands that pause the protocol and let you replace an on-deck tip rack with another rack stored in an off-deck location. + +Dropping a Tip +============== + +To drop a tip in the trash bin, call the :py:meth:`~.InstrumentContext.drop_tip` method with no arguments:: + + pipette.pick_up_tip() + +You can also specify where to drop the tip by passing in a location. For example, this code drops a tip in the trash bin and returns another tip to to a previously used well in a tip rack:: + + pipette.pick_up_tip() # picks up tip from rack location A1 + pipette.drop_tip() # drops tip in trash bin + pipette.pick_up_tip() # picks up tip from rack location B1 + pipette.drop_tip(tiprack['A1']) # drops tip in rack location A1 + +.. versionadded:: 2.0 + +.. _pipette-return-tip: + +Returning a Tip +=============== + +To return a tip to its original location, call the :py:meth:`~.InstrumentContext.return_tip` method with no arguments:: + + pipette.return_tip() + +Working With Used Tips +====================== + +Currently, the API considers tips as "used" after being picked up. For example, if the robot picked up a tip from rack location A1 and then returned it to the same location, it will not attempt to pick up this tip again, unless explicitly specified. Instead, the robot will pick up a tip starting from rack location B1. For example:: + + pipette.pick_up_tip() # picks up tip from rack location A1 + pipette.return_tip() # drops tip in rack location A1 + pipette.pick_up_tip() # picks up tip from rack location B1 + pipette.drop_tip() # drops tip in trash bin + pipette.pick_up_tip(tiprack_1['A1']) # picks up tip from rack location A1 + +Early API versions treated returned tips as unused items. They could be picked up again without an explicit argument. For example:: + + pipette.pick_up_tip() # picks up tip from rack location A1 + pipette.return_tip() # drops tip in rack location A1 + pipette.pick_up_tip() # picks up tip from rack location A1 + +.. versionchanged:: 2.2 diff --git a/api/docs/v2/basic_commands/utilities.rst b/api/docs/v2/basic_commands/utilities.rst new file mode 100644 index 00000000000..13633f419f3 --- /dev/null +++ b/api/docs/v2/basic_commands/utilities.rst @@ -0,0 +1,103 @@ +:og:description: Basic commands for working with robot utility features. + +.. _new-utility-commands: + +**************** +Utility Commands +**************** + +With utility commands, you can control various robot functions such as pausing or delaying a protocol, checking the robot's door, turning robot lights on/off, and more. The following sections show you how to these utility commands and include sample code. The examples used here assume that you’ve loaded the pipettes and labware from the basic :ref:`protocol template `. + +Delay and Resume +================ + +Call the :py:meth:`.ProtocolContext.delay` method to insert a timed delay into your protocol. This method accepts time increments in seconds, minutes, or combinations of both. Your protocol resumes automatically after the specified time expires. + +This example delays a protocol for 10 seconds:: + + protocol.delay(seconds=10) + +This example delays a protocol for 5 minutes:: + + protocol.delay(minutes=5) + +This example delays a protocol for 5 minutes and 10 seconds:: + + protocol.delay(minutes=5, seconds=10) + +Pause Until Resumed +=================== + +Call the :py:meth:`.ProtocolContext.pause` method to stop a protocol at a specific step. Unlike a delay, :py:meth:`~.ProtocolContext.pause` does not restart your protocol automatically. To resume, you'll respond to a prompt on the touchscreen or in the Opentrons App. This method also lets you specify an optional message that provides on-screen or in-app instructions on how to proceed. This example inserts a pause and includes a brief message:: + + protocol.pause('Remember to get more pipette tips') + +.. versionadded:: 2.0 + +Homing +====== + +Homing commands the robot to move the gantry, a pipette, or a pipette plunger to a defined position. For example, homing the gantry moves it to the back right of the working area. With the available homing methods you can home the gantry, home the mounted pipette and plunger, and home the pipette plunger. These functions take no arguments. + +To home the gantry, call :py:meth:`.ProtocolContext.home`:: + + protocol.home() + +To home a specific pipette's Z axis and plunger, call :py:meth:`.InstrumentContext.home`:: + + pipette = protocol.load_instrument('flex_1channel_1000', 'right') + pipette.home() + +To home a specific pipette's plunger only, you can call :py:meth:`.InstrumentContext.home_plunger`:: + + pipette = protocol.load_instrument('flex_1channel_1000', 'right') + pipette.home_plunger() + +.. versionadded:: 2.0 + +Comment +======= + +Call the :py:meth:`.ProtocolContext.comment` method if you want to write and display a brief message in the Opentrons App during a protocol run:: + + protocol.comment('Hello, world!') + +.. versionadded:: 2.0 + +Control and Monitor Robot Rail Lights +===================================== + +Call the :py:meth:`.ProtocolContext.set_rail_lights` method to turn the robot's rail lights on or off during a protocol. This method accepts Boolean ``True`` (lights on) or ``False`` (lights off) arguments. Rail lights are off by default. + +This example turns the rail lights on:: + + protocol.set_rail_lights(True) + + +This example turns the rail lights off:: + + protocol.set_rail_lights(False) + +.. versionadded:: 2.5 + +You can also check whether the rail lights are on or off in the protocol by using :py:obj:`.ProtocolContext.rail_lights_on`. This method returns ``True`` when lights are on and ``False`` when the lights are off. + +.. versionadded:: 2.5 + + +OT-2 Door Safety Switch +======================= + +Introduced with :ref:`robot software version ` 3.19, the safety switch feature prevents the OT-2, and your protocol, from running if the door is open. To operate properly, the front door and top window of your OT-2 must be closed. You can toggle the door safety switch on or off from **Robot Settings > Advanced > Usage Settings**. + +To check if the robot's door is closed at a specific point during a protocol run, call :py:obj:`.ProtocolContext.door_closed`. It returns a Boolean ``True`` (door closed) or ``False`` (door open) response. + +.. code-block:: python + + protocol.door_closed + +.. warning:: + + :py:obj:`~.ProtocolContext.door_closed` is a status check only. It does not control the robot's behavior. If you wish to implement a custom method to pause or resume a protocol using ``door_closed``, disable the door safety feature first (not recommended). + +.. versionadded:: 2.5 diff --git a/api/docs/v2/complex_commands/order_operations.rst b/api/docs/v2/complex_commands/order_operations.rst new file mode 100644 index 00000000000..a55143e33e3 --- /dev/null +++ b/api/docs/v2/complex_commands/order_operations.rst @@ -0,0 +1,167 @@ +:og:description: The order of basic commands that are part of a complex liquid handling commmand in the Python API. + +.. _complex-command-order: + +******************* +Order of Operations +******************* + +Complex commands perform a series of :ref:`building block commands ` in order. In fact, the run preview for your protocol in the Opentrons App lists all of these commands as separate steps. This lets you examine what effect your complex commands will have before running them. + +This page describes what steps you should expect the robot to perform when using different complex commands with different required and :ref:`optional ` parameters. + +Step Sequence +============= + +The order of steps is fixed within complex commands. Aspiration and dispensing are the only required actions. You can enable or disable all of the other actions with :ref:`complex liquid handling parameters `. A complex command designed to perform every possible action will proceed in this order: + + 1. Pick up tip + 2. Mix at source + 3. Aspirate from source + 4. Touch tip at source + 5. Air gap + 6. Dispense into destination + 7. Mix at destination + 8. Touch tip at destination + 9. Blow out + 10. Drop tip + +The command may repeat some or all of these steps in order to move liquid as requested. :py:meth:`.transfer` repeats as many times as there are wells in the longer of its ``source`` or ``dest`` argument. :py:meth:`.distribute` and :py:meth:`.consolidate` try to repeat as few times as possible. See :ref:`complex-tip-refilling` below for how they behave when they do need to repeat. + +Example Orders +============== + +The smallest possible number of steps in a complex command is just two: aspirating and dispensing. This is possible by omitting the tip pickup and drop steps:: + + pipette.transfer( + volume=100, + source=plate["A1"], + dest=plate["B1"], + new_tip="never", + ) + +Here's another example, a distribute command that adds touch tip steps (and does not turn off tip handling). The code for this command is:: + + pipette.distribute( + volume=100, + source=[plate["A1"]], + dest=[plate["B1"], plate["B2"]], + touch_tip=True, + ) + +Compared to the list of all possible actions, this code will only perform the following: + + 1. Pick up tip + 2. Aspirate from source + 3. Touch tip at source + 4. Dispense into destination + 5. Touch tip at destination + 6. Blow out + 7. Drop tip + +Let's unpack this. Picking up and dropping tips is default behavior for ``distribute()``. Specifying ``touch_tip=True`` adds two steps, as it is performed at both the source and destination. And it's also default behavior for ``distribute()`` to aspirate a disposal volume, which is blown out before dropping the tip. The exact order of steps in the run preview should look similar to this: + +.. code-block:: text + + Picking up tip from A1 of tip rack on 3 + Aspirating 220.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Touching tip + Dispensing 100.0 uL into B1 of well plate on 2 at 92.86 uL/sec + Touching tip + Dispensing 100.0 uL into B2 of well plate on 2 at 92.86 uL/sec + Touching tip + Blowing out at A1 of Opentrons Fixed Trash on 12 + Dropping tip into A1 of Opentrons Fixed Trash on 12 + +Since dispensing and touching the tip are both associated with the destination wells, those steps are performed at each of the two destination wells. + +.. _complex-tip-refilling: + +Tip Refilling +============= + +One factor that affects the exact order of steps for a complex command is whether the amount of liquid being moved can fit in the tip at once. If it won't fit, you don't have to adjust your command. The API will handle it for you by including additional steps to refill the tip when needed. + +For example, say you need to move 100 µL of liquid from one well to another, but you only have a 50 µL pipette attached to your robot. To accomplish this with building block commands, you'd need multiple aspirates and dispenses. ``aspirate(volume=100)`` would raise an error, since it exceeds the tip's volume. But you can accomplish this with a single transfer command:: + + pipette50.transfer( + volume=100, + source=plate["A1"], + dest=plate["B1"], + ) + +To effect the transfer, the API will aspirate and dispense the maximum volume of the pipette (50 µL) twice: + +.. code-block:: text + + Picking up tip from A1 of tip rack on D3 + Aspirating 50.0 uL from A1 of well plate on D2 at 57 uL/sec + Dispensing 50.0 uL into B1 of well plate on D2 at 57 uL/sec + Aspirating 50.0 uL from A1 of well plate on D2 at 57 uL/sec + Dispensing 50.0 uL into B1 of well plate on D2 at 57 uL/sec + Dropping tip into A1 of Opentrons Fixed Trash on A3 + +You can change ``volume`` to any value (above the minimum volume of the pipette) and the API will automatically calculate how many times the pipette needs to aspirate and dispense. ``volume=50`` would require just one repetition. ``volume=75`` would require two, split into 50 µL and 25 µL. ``volume=1000`` would repeat 20 times — not very efficient, but perhaps more useful than having to swap to a different pipette! + +Remember that ``distribute()`` includes a disposal volume by default, and this can affect the number of times the pipette refills its tip. Say you want to distribute 80 µL to each of the 12 wells in row A of a plate. That's 960 µL total — less than the capacity of the pipette — but the 100 µL disposal volume will cause the pipette to refill. + +.. code-block:: text + + Picking up tip from A1 of tip rack on 3 + Aspirating 980.0 uL from A1 of well plate on 2 at 274.7 uL/sec + Dispensing 80.0 uL into B1 of well plate on 2 at 274.7 uL/sec + Dispensing 80.0 uL into B2 of well plate on 2 at 274.7 uL/sec + ... + Dispensing 80.0 uL into B11 of well plate on 2 at 274.7 uL/sec + Blowing out at A1 of Opentrons Fixed Trash on 12 + Aspirating 180.0 uL from A1 of well plate on 2 at 274.7 uL/sec + Dispensing 80.0 uL into B12 of well plate on 2 at 274.7 uL/sec + Blowing out at A1 of Opentrons Fixed Trash on 12 + Dropping tip into A1 of Opentrons Fixed Trash on 12 + +This command will blow out 200 total µL of liquid in the trash. If you need to conserve liquid, use :ref:`complex liquid handling parameters ` to reduce or eliminate the :ref:`disposal volume `, or to :ref:`blow out ` in a location other than the trash. + +.. _distribute-consolidate-volume-list: +.. _complex-list-volumes: + +List of Volumes +=============== + +Complex commands can aspirate or dispense different amounts for different wells, rather than the same amount across all wells. To do this, set the ``volume`` parameter to a list of volumes instead of a single number. The list must be the same length as the longer of ``source`` or ``dest``, or the API will raise an error. For example, this command transfers a different amount of liquid into each of wells B1, B2, and B3:: + + pipette.transfer( + volume=[20, 40, 60], + source=plate["A1"], + dest=[plate["B1"], plate["B2"], plate["B3"]], + ) + +.. versionadded: 2.0 + +Setting any item in the list to ``0`` will skip aspirating and dispensing for the corresponding well. This example takes the command from above and skips B2:: + + pipette.transfer( + volume=[20, 0, 60], + source=plate["A1"], + dest=[plate["B1"], plate["B2"], plate["B3"]], + ) + +The pipette dispenses in B1 and B3, and does not move to B2 at all. + +.. code-block:: text + + Picking up tip from A1 of tip rack on 3 + Aspirating 20.0 uL from A1 of well plate on 2 at 274.7 uL/sec + Dispensing 20.0 uL into B1 of well plate on 2 at 274.7 uL/sec + Aspirating 60.0 uL from A1 of well plate on 2 at 274.7 uL/sec + Dispensing 60.0 uL into B3 of well plate on 2 at 274.7 uL/sec + Dropping tip into A1 of Opentrons Fixed Trash on 12 + +This is such a simple example that you might prefer to use two ``transfer()`` commands instead. Lists of volumes become more useful when they are longer than a couple elements. For example, you can specify ``volume`` as a list with 96 items and ``dest=plate.wells()`` to individually control amounts to dispense (and wells to skip) across an entire plate. + +.. note:: + When the optional ``new_tip`` parameter is set to ``"always"``, the pipette will pick up and drop a tip even for skipped wells. If you don't want to waste tips, pre-process your list of sources or destinations and use the result as the argument of your complex command. + +.. versionadded:: 2.0 + Skip wells for ``transfer()`` and ``distribute()``. +.. versionadded:: 2.8 + Skip wells for ``consolidate()``. diff --git a/api/docs/v2/complex_commands/parameters.rst b/api/docs/v2/complex_commands/parameters.rst new file mode 100644 index 00000000000..14658509c97 --- /dev/null +++ b/api/docs/v2/complex_commands/parameters.rst @@ -0,0 +1,383 @@ +:og:description: Parameters for fine-tuning complex liquid handling behavior in the Python API. + +.. _complex_params: + +********************************** +Complex Liquid Handling Parameters +********************************** + +Complex commands accept a number of optional parameters that give you greater control over the exact steps they perform. + +This page describes the accepted values and behavior of each parameter. The parameters are organized in the order that they first add a step. Some parameters, such as ``touch_tip``, add multiple steps. See :ref:`complex-command-order` for more details on the sequence of steps performed by complex commands. + +The API reference entry for :py:meth:`.InstrumentContext.transfer` also lists the parameters and has more information on their implementation as keyword arguments. + +.. _param-tip-handling: + +Tip Handling +============ + +The ``new_tip`` parameter controls if and when complex commands pick up new tips from the pipette's tip racks. It has three possible values: + +.. list-table:: + :header-rows: 1 + + * - Value + - Behavior + * - ``"once"`` + - + - Pick up a tip at the start of the command. + - Use the tip for all liquid handling. + - Drop the tip at the end of the command. + * - ``"always"`` + - Pick up and drop a tip for each set of aspirate and dispense steps. + * - ``"never"`` + - Do not pick up or drop tips at all. + +``"once"`` is the default behavior for all complex commands. + +.. versionadded:: 2.0 + +Tip Handling Requirements +------------------------- + +``"once"`` and ``"always"`` require that the pipette has an :ref:`associated tip rack `, or the API will raise an error (because it doesn't know where to pick up a tip from). If the pipette already has a tip attached, the API will also raise an error when it tries to pick up a tip. + +.. code-block:: python + + pipette.pick_up_tip() + pipette.transfer( + volume=100, + source=plate["A1"], + dest=[plate["B1"], plate["B2"], plate["B3"]], + new_tip="never", # "once", "always", or None will error + ) + +Conversely, ``"never"`` requires that the pipette has picked up a tip, or the API will raise an error (because it will attempt to aspirate without a tip attached). + +Avoiding Cross-Contamination +---------------------------- + +One reason to set ``new_tip="always"`` is to avoid cross-contamination between wells. However, you should always do a dry run of your protocol to test that the pipette is picking up and dropping tips in the way that your application requires. + +:py:meth:`~.InstrumentContext.transfer` will pick up a new tip before *every* aspirate when ``new_tip="always"``. This includes when :ref:`tip refilling ` requires multiple aspirations from a single source well. + +:py:meth:`~.InstrumentContext.distribute` and :py:meth:`~.InstrumentContext.consolidate` only pick up one tip, even when ``new_tip="always"``. For example, this distribute command returns to the source well a second time, because the amount to be distributed (400 µL total plus disposal volume) exceeds the pipette capacity (300 μL):: + + pipette.distribute( + volume=200, + source=plate["A1"], + dest=[plate["B1"], plate["B2"]], + new_tip="always", + ) + +But it *does not* pick up a new tip after dispensing into B1: + +.. code-block:: text + + Picking up tip from A1 of tip rack on 3 + Aspirating 220.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Dispensing 200.0 uL into B1 of well plate on 2 at 92.86 uL/sec + Blowing out at A1 of Opentrons Fixed Trash on 12 + Aspirating 220.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Dispensing 200.0 uL into B2 of well plate on 2 at 92.86 uL/sec + Blowing out at A1 of Opentrons Fixed Trash on 12 + Dropping tip into A1 of Opentrons Fixed Trash on 12 + +If this poses a contamination risk, you can work around it in a few ways: + + * Use ``transfer()`` with ``new_tip="always"`` instead. + * Set :py:obj:`.well_bottom_clearance` high enough that the tip doesn't contact liquid in the destination well. + * Use :ref:`building block commands ` instead of complex commands. + + +.. _param-mix-before: + +Mix Before +========== + +The ``mix_before`` parameter controls mixing in source wells before each aspiration. Its value must be a :py:class:`tuple` with two numeric values. The first value is the number of repetitions, and the second value is the amount of liquid to mix in µL. + +For example, this transfer command will mix 50 µL of liquid 3 times before each of its aspirations:: + + pipette.transfer( + volume=100, + source=plate["A1"], + dest=[plate["B1"], plate["B2"]], + mix_before=(3, 50), + ) + +.. versionadded:: 2.0 + +Mixing occurs before every aspiration, including when :ref:`tip refilling ` is required. + +.. note:: + :py:meth:`~.InstrumentContext.consolidate` ignores any value of ``mix_before``. Mixing on the second and subsequent aspirations of a consolidate command would defeat its purpose: to aspirate multiple times in a row, from different wells, *before* dispensing. + +.. _param-disposal-volume: + +Disposal Volume +=============== + +The ``disposal_volume`` parameter controls how much extra liquid is aspirated as part of a :py:meth:`~.InstrumentContext.distribute` command. Including a disposal volume can improve the accuracy of each dispense. The pipette blows out the disposal volume of liquid after dispensing. To skip aspirating and blowing out extra liquid, set ``disposal_volume=0``. + +By default, ``disposal_volume`` is the :ref:`minimum volume ` of the pipette, but you can set it to any amount:: + + pipette.distribute( + volume=100, + source=plate["A1"], + dest=[plate["B1"], plate["B2"]], + disposal_volume=10, # reduce from default 20 µL to 10 µL + ) + +.. versionadded:: 2.0 + +If the amount to aspirate plus the disposal volume exceeds the tip's capacity, ``distribute()`` will use a :ref:`tip refilling strategy `. In such cases, the pipette will aspirate and blow out the disposal volume *for each aspiration*. For example, this command will require tip refilling with a 1000 µL pipette:: + + pipette.distribute( + volume=120, + source=reservoir["A1"], + dest=[plate.columns()[0]], + disposal_volume=50, + ) + +The amount to dispense in the destination is 960 µL (120 µL for each of 8 wells in the column). Adding the 50 µL disposal volume exceeds the 1000 µL capacity of the tip. The command will be split across two aspirations, each with the full disposal volume of 50 µL. The pipette will dispose *a total of 100 µL* during the command. + +.. note:: + :py:meth:`~.InstrumentContext.transfer` will not aspirate additional liquid if you set ``disposal_volume``. However, it will perform a very small blow out after each dispense. + + :py:meth:`~.InstrumentContext.consolidate` ignores ``disposal_volume`` completely. + +.. _param-touch-tip: + +Touch Tip +========= + +The ``touch_tip`` parameter accepts a Boolean value. When ``True``, a touch tip step occurs after every aspirate and dispense. + +For example, this transfer command aspirates, touches the tip at the source, dispenses, and touches the tip at the destination:: + + pipette.transfer( + volume=100, + dest=plate["A1"], + source=plate["B1"], + touch_tip=True, + ) + +.. versionadded:: 2.0 + +Touch tip occurs after every aspiration, including when :ref:`tip refilling ` is required. + +This parameter always uses default motion behavior for touch tip. Use the :ref:`touch tip building block command ` if you need to: + + * Only touch the tip after aspirating or dispensing, but not both. + * Control the speed, radius, or height of the touch tip motion. + +.. _param-air-gap: + +Air Gap +======= + +The ``air_gap`` parameter controls how much air to aspirate and hold in the bottom of the tip when it contains liquid. The parameter's value is the amount of air to aspirate in µL. + +Air-gapping behavior is different for each complex command. The different behaviors all serve the same purpose, which is to never leave the pipette holding liquid at the very bottom of the tip. This helps keep liquids from seeping out of the pipette. + +.. list-table:: + :header-rows: 1 + + * - Method + - Air-gapping behavior + * - ``transfer()`` + - + - Air gap after each aspiration. + - Pipette is empty after dispensing. + * - ``distribute()`` + - + - Air gap after each aspiration. + - Air gap after dispensing if the pipette isn't empty. + * - ``consolidate()`` + - + - Air gap after each aspiration. This may create multiple air gaps within the tip. + - Pipette is empty after dispensing. + +For example, this transfer command will create a 20 µL air gap after each of its aspirations. When dispensing, it will clear the air gap and dispense the full 100 µL of liquid:: + + pipette.transfer( + volume=100, + source=plate["A1"], + dest=plate["B1"], + air_gap=20, + ) + +.. versionadded:: 2.0 + +When consolidating, air gaps still occur after every aspiration. In this example, the tip will use 210 µL of its capacity (50 µL of liquid followed by 20 µL of air, repeated three times):: + + pipette.consolidate( + volume=50, + source=[plate["A1"], plate["A2"], plate["A3"]], + dest=plate["B1"], + air_gap=20, + ) + +.. code-block:: text + + Picking up tip from A1 of tip rack on 3 + Aspirating 50.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Air gap + Aspirating 20.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Aspirating 50.0 uL from A2 of well plate on 2 at 92.86 uL/sec + Air gap + Aspirating 20.0 uL from A2 of well plate on 2 at 92.86 uL/sec + Aspirating 50.0 uL from A3 of well plate on 2 at 92.86 uL/sec + Air gap + Aspirating 20.0 uL from A3 of well plate on 2 at 92.86 uL/sec + Dispensing 210.0 uL into B1 of well plate on 2 at 92.86 uL/sec + Dropping tip into A1 of Opentrons Fixed Trash on 12 + +If adding an air gap would exceed the pipette's maximum volume, the complex command will use a :ref:`tip refilling strategy `. For example, this command uses a 300 µL pipette to transfer 300 µL of liquid plus an air gap:: + + pipette.transfer( + volume=300, + source=plate["A1"], + dest=plate["B1"], + air_gap=20, + ) + +As a result, the transfer is split into two aspirates of 150 µL, each with their own 20 µL air gap: + +.. code-block:: text + + Picking up tip from A1 of tip rack on 3 + Aspirating 150.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Air gap + Aspirating 20.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Dispensing 170.0 uL into B1 of well plate on 2 at 92.86 uL/sec + Aspirating 150.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Air gap + Aspirating 20.0 uL from A1 of well plate on 2 at 92.86 uL/sec + Dispensing 170.0 uL into B1 of well plate on 2 at 92.86 uL/sec + Dropping tip into A1 of Opentrons Fixed Trash on 12 + +.. _param-mix-after: + +Mix After +========= + +The ``mix_after`` parameter controls mixing in source wells after each dispense. Its value must be a :py:class:`tuple` with two numeric values. The first value is the number of repetitions, and the second value is the amount of liquid to mix in µL. + +For example, this transfer command will mix 50 µL of liquid 3 times after each of its dispenses:: + + pipette.transfer( + volume=100, + source=plate["A1"], + dest=[plate["B1"], plate["B2"]], + mix_after=(3, 50), + ) + +.. versionadded:: 2.0 + +.. note:: + :py:meth:`~.InstrumentContext.distribute` ignores any value of ``mix_after``. Mixing after dispensing would combine (and potentially contaminate) the remaining source liquid with liquid present at the destination. + +.. _param-blow-out: + +Blow Out +======== + +There are two parameters that control whether and where the pipette blows out liquid. The ``blow_out`` parameter accepts a Boolean value. When ``True``, the pipette blows out remaining liquid when the tip is empty or only contains the disposal volume. The ``blowout_location`` parameter controls in which of three locations these blowout actions occur. The default blowout location is the trash. Blowout behavior is different for each complex command. + +.. list-table:: + :header-rows: 1 + + * - Method + - Blowout behavior and location + * - ``transfer()`` + - + - Blow out after each dispense. + - Valid locations: ``"trash"``, ``"source well"``, ``"destination well"`` + * - ``distribute()`` + - + - Blow out after the final dispense. + - Valid locations: ``"trash"``, ``"source well"`` + * - ``consolidate()`` + - + - Blow out after the only dispense. + - Valid locations: ``"trash"``, ``"destination well"`` + +For example, this transfer command will blow out liquid in the trash twice, once after each dispense into a destination well:: + + pipette.transfer( + volume=100, + source=[plate["A1"], plate["A2"]], + dest=[plate["B1"], plate["B2"]], + blow_out=True, + ) + +.. versionadded:: 2.0 + +Set ``blowout_location`` when you don't want to waste any liquid by blowing it out into the trash. For example, you may want to make sure that every last bit of a sample is moved into a destination well. Or you may want to return every last bit of an expensive reagent to the source for use in later pipetting. + +If you need to blow out in a different well, or at a specific location within a well, use the :ref:`blow out building block command ` instead. + +When setting a blowout location, you *must* also set ``blow_out=True``, or the location will be ignored:: + + pipette.transfer( + volume=100, + source=plate["A1"], + dest=plate["B1"], + blow_out=True, # required to set location + blowout_location="destination well", + ) + +.. versionadded:: 2.8 + +With ``transfer()``, the pipette will not blow out at all if you only set ``blowout_location``. + +``blow_out=True`` is also required for distribute commands that blow out by virtue of having a disposal volume:: + + pipette.distribute( + volume=100, + source=plate["A1"], + dest=[plate["B1"], plate["B2"]], + disposal_volume=50, # causes blow out + blow_out=True, # still required to set location! + blowout_location="source well", + ) + +With ``distribute()``, the pipette will still blow out if you only set ``blowout_location``, but in the default location of the trash. + +.. note:: + If the tip already contains liquid before the complex command, the default blowout location will shift away from the trash. ``transfer()`` and ``distribute()`` shift to the source well, and ``consolidate()`` shifts to the destination well. For example, this transfer command will blow out in well B1 because it's the source:: + + pipette.pick_up_tip() + pipette.aspirate(100, plate["A1"]) + pipette.transfer( + volume=100, + source=plate["B1"], + dest=plate["C1"], + new_tip="never", + blow_out=True, + # no blowout_location + ) + pipette.drop_tip() + + This only occurs when you aspirate and then perform a complex command with ``new_tip="never"`` and ``blow_out=True``. + +.. _param-trash: + +Trash Tips +========== + +The ``trash`` parameter controls what the pipette does with tips at the end of complex commands. When ``True``, the pipette drops tips into the trash. When ``False``, the pipette returns tips to their original locations in their tip rack. + +The default is ``True``, so you only have to set ``trash`` when you want the tip-returning behavior:: + + pipette.transfer( + volume=100, + source=plate["A1"], + dest=plate["B1"], + trash=False, + ) + +.. versionadded:: 2.0 \ No newline at end of file diff --git a/api/docs/v2/complex_commands/sources_destinations.rst b/api/docs/v2/complex_commands/sources_destinations.rst new file mode 100644 index 00000000000..597f20787ea --- /dev/null +++ b/api/docs/v2/complex_commands/sources_destinations.rst @@ -0,0 +1,311 @@ +:og:description: How the Opentrons Python API moves liquids between wells when using complex commands. + +.. _complex-source-dest: + +************************ +Sources and Destinations +************************ + +The :py:meth:`.InstrumentContext.transfer`, :py:meth:`.InstrumentContext.distribute`, and :py:meth:`.InstrumentContext.consolidate` methods form the family of complex liquid handling commands. These methods require ``source`` and ``dest`` (destination) arguments to move liquid from one well, or group of wells, to another. In contrast, the :ref:`building block commands ` :py:meth:`~.InstrumentContext.aspirate` and :py:meth:`~.InstrumentContext.dispense` only operate in a single location. + +For example, this command performs a simple transfer between two wells on a plate:: + + pipette.transfer( + volume=100, + source=plate["A1"], + dest=plate["A2"], + ) + +.. versionadded:: 2.0 + +This page covers the restrictions on sources and destinations for complex commands, their different patterns of aspirating and dispensing, and how to optimize them for different use cases. + + +.. _source-dest-args: + +Source and Destination Arguments +================================ + +As noted above, the :py:meth:`~.InstrumentContext.transfer`, :py:meth:`~.InstrumentContext.distribute`, and :py:meth:`~.InstrumentContext.consolidate` methods require ``source`` and ``dest`` (destination) arguments to aspirate and dispense liquid. However, each method handles liquid sources and destinations differently. Understanding how complex commands work with source and destination wells is essential to using these methods effectively. + +:py:meth:`~.InstrumentContext.transfer` is the most versatile complex liquid handling function, because it has the fewest restrictions on what wells it can operate on. You will likely use transfer commands in many of your protocols. + +Certain liquid handling cases focus on moving liquid to or from a single well. :py:meth:`~.InstrumentContext.distribute` limits its source to a single well, while :py:meth:`~.InstrumentContext.consolidate` limits its destination to a single well. Distribute commands also make changes to liquid-handling behavior to improve the accuracy of dispensing. + +The following table summarizes the source and destination restrictions for each method. + +.. list-table:: + :header-rows: 1 + + * - Method + - Accepted wells + * - ``transfer()`` + - + - **Source:** Any number of wells. + - **Destination:** Any number of wells. + - The larger group of wells must be evenly divisible by the smaller group. + * - ``distribute()`` + - + - **Source:** Exactly one well. + - **Destination:** Any number of wells. + * - ``consolidate()`` + - + - **Source:** Any number of wells. + - **Destination:** Exactly one well. + +A single well can be passed by itself or as a list with one item: ``source=plate['A1']`` and ``source=[plate['A1']]`` are equivalent. + +The section on :ref:`many-to-many transfers ` below covers how ``transfer()`` works when specifying sources and destinations of different sizes. However, if they don't meet the even divisibility requirement, the API will raise an error. You can work around such situations by making multiple calls to ``transfer()`` in sequence or by using a :ref:`list of volumes ` to skip certain wells. + +For distributing and consolidating, the API will not raise an error if you use a list of wells as the argument that is limited to exactly one well. Instead, the API will ignore everything except the first well in the list. For example, the following command will only aspirate from well A1:: + + pipette.distribute( + volume=100, + source=[plate["A1"], plate["A2"]], # A2 ignored + dest=plate.columns()[1], + ) + +On the other hand, a transfer command with the same arguments would aspirate from both A1 and A2. The next section examines the exact order of aspiration and dispensing for all three methods. + +.. _complex-transfer-patterns: + +Transfer Patterns +================= + +Each complex command uses a different pattern of aspiration and dispensing. In addition, when you provide multiple wells as both the source and destination for ``transfer()``, it maps the source list onto the destination list in a certain way. + +Aspirating and Dispensing +------------------------- + +``transfer()`` always alternates between aspirating and dispensing, regardless of how many wells are in the source and destination. Its default behavior is: + + 1. Pick up a tip. + 2. Aspirate from the first source well. + 3. Dispense in the first destination well. + 4. Repeat the pattern of aspirating and dispensing, as needed. + 5. Drop the tip in the trash. + +.. figure:: ../../img/complex_commands/transfer.png + :name: Transfer + :scale: 35% + :align: center + + This transfer aspirates six times and dispenses six times. + +``distribute()`` always fills the tip with as few aspirations as possible, and then dispenses to the destination wells in order. Its default behavior is: + + 1. Pick up a tip. + 2. Aspirate enough to dispense in all the destination wells. This aspirate includes a disposal volume. + 3. Dispense in the first destination well. + 4. Continue to dispense in destination wells. + 5. Drop the tip in the trash. + +See :ref:`complex-tip-refilling` below for cases where the total amount to be dispensed is greater than the capacity of the tip. + +.. figure:: ../../img/complex_commands/robot_distribute.png + :name: Transfer + :scale: 35% + :align: center + + This distribute aspirates one time and dispenses three times. + +``consolidate()`` aspirates multiple times in a row, and then dispenses as few times as possible in the destination well. Its default behavior is: + + 1. Pick up a tip. + 2. Aspirate from the first source well. + 3. Continue aspirating from source wells. + 4. Dispense in the destination well. + 5. Drop the tip in the trash. + +See :ref:`complex-tip-refilling` below for cases where the total amount to be aspirated is greater than the capacity of the tip. + +.. figure:: ../../img/complex_commands/robot_consolidate.png + :name: Transfer + :scale: 35% + :align: center + + This consolidate aspirates three times and dispenses one time. + +.. note:: + By default, all three commands begin by picking up a tip and conclude by dropping a tip. In general, don't call :py:meth:`.pick_up_tip` just before a complex command, or the API will raise an error. You can override this behavior with the :ref:`tip handling complex parameter `, by setting ``new_tip="never"``. + + +.. _many-to-many: + +Many-to-Many +------------ + +``transfer()`` lets you specify both ``source`` and ``dest`` arguments that contain multiple wells. This section covers how the method determines which wells to aspirate from and dispense to in these cases. + +When the source and destination both contain the same number of wells, the mapping between wells is straightforward. You can imagine writing out the two lists one above each other, with each unique well in the source list paired to a unique well in the destination list. For example, here is the code for using one row as the source and another row as the destination, and the resulting correspondence between wells:: + + pipette.transfer( + volume=50, + source=plate.rows()[0], + dest=plate.rows()[1], + ) + +.. list-table:: + :stub-columns: 1 + + * - Source + - A1 + - A2 + - A3 + - A4 + - A5 + - A6 + - A7 + - A8 + - A9 + - A10 + - A11 + - A12 + * - Destination + - B1 + - B2 + - B3 + - B4 + - B5 + - B6 + - B7 + - B8 + - B9 + - B10 + - B11 + - B12 + +There's no requirement that the source and destination lists be mutually exclusive. In fact, this command adapted from the :ref:`tutorial ` deliberately uses slices of the same list, saved to the variable ``row``, with the effect that each aspiration happens in the same location as the previous dispense:: + + row = plate.rows()[0] + pipette.transfer( + volume=50, + source=row[:11], + dest=row[1:], + ) + +.. list-table:: + :stub-columns: 1 + + * - Source + - A1 + - A2 + - A3 + - A4 + - A5 + - A6 + - A7 + - A8 + - A9 + - A10 + - A11 + * - Destination + - A2 + - A3 + - A4 + - A5 + - A6 + - A7 + - A8 + - A9 + - A10 + - A11 + - A12 + +When the source and destination lists contain different numbers of wells, ``transfer()`` will always aspirate and dispense as many times as there are wells in the *longer* list. The shorter list will be "stretched" to cover the length of the longer list. Here is an example of transferring from 3 wells to a full row of 12 wells:: + + pipette.transfer( + volume=50, + source=[plate["A1"], plate["A2"], plate["A3"]], + dest=plate.rows()[1], + ) + +.. list-table:: + :stub-columns: 1 + + * - Source + - A1 + - A1 + - A1 + - A1 + - A2 + - A2 + - A2 + - A2 + - A3 + - A3 + - A3 + - A3 + * - Destination + - B1 + - B2 + - B3 + - B4 + - B5 + - B6 + - B7 + - B8 + - B9 + - B10 + - B11 + - B12 + +This is why the longer list must be evenly divisible by the shorter list. Changing the destination in this example to a column instead of a row will cause the API to raise an error, because 8 is not evenly divisible by 3:: + + pipette.transfer( + volume=50, + source=[plate["A1"], plate["A2"], plate["A3"]], + dest=plate.columns()[3], # labware column 4 + ) + # error: source and destination lists must be divisible + +The API raises this error rather than presuming which wells to aspirate from three times and which only two times. If you want to aspirate three times from A1, three times from A2, and two times from A3, use multiple ``transfer()`` commands in sequence:: + + pipette.transfer(50, plate["A1"], plate.columns()[3][:3]) + pipette.transfer(50, plate["A2"], plate.columns()[3][3:6]) + pipette.transfer(50, plate["A3"], plate.columns()[3][6:]) + +Finally, be aware of the ordering of source and destination lists when constructing them with :ref:`well accessor methods `. For example, at first glance this code may appear to take liquid from each well in the first row of a plate and move it to each of the other wells in the same column:: + + pipette.transfer( + volume=20, + source=plate.rows()[0], + dest=plate.rows()[1:], + ) + +However, because the well ordering of :py:meth:`.Labware.rows` goes *across* the plate instead of *down* the plate, liquid from A1 will be dispensed in B1–B7, liquid from A2 will be dispensed in B8–C2, etc. The intended task is probably better accomplished by repeating transfers in a ``for`` loop:: + + for i in range(12): + pipette.transfer( + volume=20, + source=plate.rows()[0][i], + dest=plate.columns()[i][1:], + ) + +Here the repeat index ``i`` picks out: + + - The individual well in the first row, for the source. + - The corresponding column, which is sliced to form the destination. + +.. _complex-optimizing-patterns: + +Optimizing Patterns +------------------- + +Choosing the right complex command optimizes gantry movement and helps save time in your protocol. For example, say you want to take liquid from a reservoir and put 50 µL in each well of the first row of a plate. You could use ``transfer()``, like this:: + + pipette.transfer( + volume=50, + source=reservoir["A1"], + destination=plate.rows()[0], + ) + +This will produce 12 aspirate steps and 12 dispense steps. The steps alternate, with the pipette moving back and forth between the reservoir and plate each time. Using ``distribute()`` with the same arguments is more optimal in this scenario:: + + pipette.distribute( + volume=50, + source=reservoir["A1"], + destination=plate.rows()[0], + ) + +This will produce *just 1* aspirate step and 12 dispense steps (when using a 1000 µL pipette). The pipette will aspirate enough liquid to fill all the wells, plus a disposal volume. Then it will move to A1 of the plate, dispense, move the short distance to A2, dispense, and so on. This greatly reduces gantry movement and the time to perform this action. And even if you're using a smaller pipette, ``distribute()`` will fill the pipette, dispense as many times as possible, and only then return to the reservoir to refill (see :ref:`complex-tip-refilling` for more information). diff --git a/api/docs/v2/conf.py b/api/docs/v2/conf.py index 50b617100f8..29524982522 100644 --- a/api/docs/v2/conf.py +++ b/api/docs/v2/conf.py @@ -71,8 +71,8 @@ master_doc = 'index' # General information about the project. -project = 'OT-2 API V2' -copyright = '2010, Opentrons' +project = 'Python Protocol API v2' +copyright = '2010–23, Opentrons' author = 'Opentrons Labworks' # The version info for the project you're documenting, acts as replacement for @@ -176,7 +176,8 @@ 'font_family': "'Open Sans', sans-serif", 'head_font_family': "'AkkoPro-Regular', 'Open Sans'", 'sidebar_collapse': 'True', - 'fixed_sidebar': 'False', + 'fixed_sidebar': 'True', + 'sidebar_width': '270px', 'github_user': 'opentrons', 'github_repo': 'opentrons', 'github_button': True, @@ -204,7 +205,7 @@ # The name of an image file (relative to this directory) to place at the top # of the sidebar. # -html_logo = '../img/logo.png' +# html_logo = '../img/logo.png' # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or diff --git a/api/docs/v2/deck_slots.rst b/api/docs/v2/deck_slots.rst index d9343a73566..83781227e3c 100644 --- a/api/docs/v2/deck_slots.rst +++ b/api/docs/v2/deck_slots.rst @@ -7,68 +7,79 @@ .. _deck-slots: -########## +********** Deck Slots -########## +********** -When you load an item onto the robot's deck, like with :py:obj:`ProtocolContext.load_labware()` or :py:obj:`ProtocolContext.load_module()`, you need to specify which slot to put it in. +When you load an item onto the robot's deck, like with :py:obj:`ProtocolContext.load_labware()` or :py:obj:`ProtocolContext.load_module()`, you need to specify which slot to put it in. The API accepts values that correspond to the physical deck slot labels on an OT-2 or Flex robot. -Specify a slot in one of two formats: +Physical Deck Labels +==================== -* A coordinate like ``"A1"``. This matches how the deck is physically labeled on an Opentrons Flex. -* A number like ``"10"`` or ``10``. This matches how the deck is physically labeled on an Opentrons OT-2. +The Opentrons Flex uses a coordinate labeling system for slots A1 (back left) through D3 (front right). +The Opentrons OT-2 uses a numeric labeling system for slots 1 (front left) through 11 (back center). The back right slot is occupied by the fixed trash. -Opentrons Flex Deck Layout -========================== +.. image:: ../img/Flex-and-OT-2-decks.svg + :width: 100% -.. - TODO(mm, 2023-06-05): Embed a nice SVG instead of having these tables. - -.. table:: - :widths: 1 1 1 - - +----+----+----+ - | A1 | A2 | A3 | - +----+----+----+ - | B1 | B2 | B3 | - +----+----+----+ - | C1 | C2 | C3 | - +----+----+----+ - | D1 | D2 | D3 | - +----+----+----+ - - -Opentrons OT-2 Deck Layout -========================== - -.. table:: - :widths: 1 1 1 - - +----+----+-----------+ - | 10 | 11 | 12 [#ft]_ | - +----+----+-----------+ - | 7 | 8 | 9 | - +----+----+-----------+ - | 4 | 5 | 6 | - +----+----+-----------+ - | 1 | 2 | 3 | - +----+----+-----------+ -.. [#ft] Slot 12 has the fixed trash. +API Deck Labels +=============== +Specify a slot in either the Flex or OT-2 format: -Equivalent Slots -================ +* A coordinate like ``"A1"``. This format must be a string. +* A number like ``"10"`` or ``10``. This format can be a string or an integer. -The Flex and OT-2 formats are interchangeable. You can use either format, regardless of which robot your protocol is for. +As of API version 2.15, the Flex and OT-2 formats are interchangeable. You can use either format, regardless of which robot your protocol is for. You could even mix and match formats within a protocol, although this is not recommended. -For example, these are equivalent: +For example, these two ``load_labware()`` commands are equivalent: .. code-block:: python - protocol.load_labware("my_labware", "A1") + protocol.load_labware("nest_96_wellplate_200ul_flat", "A1") + +.. versionadded:: 2.15 .. code-block:: python - protocol.load_labware("my_labware", 10) + protocol.load_labware("nest_96_wellplate_200ul_flat", 10) + +.. versionadded:: 2.0 + +Both of these commands would require you to load the well plate in the back left slot of the robot. + +The correspondence between deck labels is based on the relative locations of the slots. The full list of slot equivalencies is as follows: + +.. list-table:: + :stub-columns: 1 + + * - Flex + - A1 + - A2 + - A3 + - B1 + - B2 + - B3 + - C1 + - C2 + - C3 + - D1 + - D2 + - D3 + * - OT-2 + - 10 + - 11 + - Trash + - 7 + - 8 + - 9 + - 4 + - 5 + - 6 + - 1 + - 2 + - 3 + +.. TODO staging slots and error handling of A4–D4 in OT-2 protocols diff --git a/api/docs/v2/index.rst b/api/docs/v2/index.rst index c6f7e2cd190..c64af1c1082 100644 --- a/api/docs/v2/index.rst +++ b/api/docs/v2/index.rst @@ -19,11 +19,12 @@ Welcome robot_position new_advanced_running new_examples + adapting_ot2_flex new_protocol_api -The OT-2 Python Protocol API is a Python framework designed to make it easy to write automated biology lab protocols that use the OT-2 robot and optional hardware modules. We've designed the API to be accessible to anyone with basic Python and wet-lab skills. +The Opentrons Python Protocol API is a Python framework designed to make it easy to write automated biology lab protocols. Python protocols can control Opentrons Flex and OT-2 robots, their pipettes, and optional hardware modules. We've designed the API to be accessible to anyone with basic Python and wet-lab skills. -As a bench scientist, you should be able to code your protocols in a way that reads like a lab notebook. You can :ref:`write a fully functional protocol ` just by listing the equipment you'll use (modules, labware, and pipettes) and the exact sequence of movements the robot should make. +As a bench scientist, you should be able to code your protocols in a way that reads like a lab notebook. You can write a fully functional protocol just by listing the equipment you'll use (modules, labware, and pipettes) and the exact sequence of movements the robot should make. As a programmer, you can leverage the full power of Python for advanced automation in your protocols. Perform calculations, manage external data, use built-in and imported Python modules, and more to implement your custom lab workflow. @@ -33,9 +34,9 @@ Getting Started **New to Python protocols?** Check out the :ref:`tutorial` to learn about the different parts of a protocol file and build a working protocol from scratch. -If you want to **dive right into code**, take a look at our :ref:`new-examples` page and the comprehensive :ref:`protocol-api-reference`. +If you want to **dive right into code**, take a look at our :ref:`new-examples` and the comprehensive :ref:`protocol-api-reference`. -When you're ready to **try out a protocol**, you can :ref:`simulate it on your computer ` — regardless of whether you're connected to an OT-2 robot. To run your protocol on a robot, download and use our latest `desktop app `_. +When you're ready to **try out a protocol**, download the `Opentrons App `_, import the protocol file, and run it on your robot. .. _overview-section-v2: @@ -122,7 +123,7 @@ For example, if we wanted to transfer liquid from well A1 to well B1 on a plate, } # requirements - requirements = {"robotType": "OT-2", "apiLevel": "|apiLevel|"} + requirements = {"robotType": "OT-2", "apiLevel": "2.14"} # protocol run function def run(protocol: protocol_api.ProtocolContext): @@ -148,7 +149,7 @@ For example, if we wanted to transfer liquid from well A1 to well B1 on a plate, This example proceeds completely linearly. Following it line-by-line, you can see that it has the following effects: 1. Gives the name, contact information, and a brief description for the protocol. - 2. Indicates the protocol should run on an OT-2 robot, using API version |apiLevel|. + 2. Indicates the protocol should run on an OT-2 robot, using API version 2.14. 3. Tells the robot that there is: a. A 96-well flat plate in slot 1. b. A rack of 300 µL tips in slot 2. @@ -160,7 +161,7 @@ For example, if we wanted to transfer liquid from well A1 to well B1 on a plate, d. Dropping the tip in the trash. -There is much more that the OT-2 robot and the API can do! The :ref:`v2-atomic-commands`, :ref:`v2-complex-commands`, and :ref:`new_modules` pages cover many of these functions. +There is much more that Opentrons robots and the API can do! The :ref:`v2-atomic-commands`, :ref:`v2-complex-commands`, and :ref:`new_modules` pages cover many of these functions. More Resources @@ -174,12 +175,12 @@ The `Opentrons App `_ is the easiest way to run y Support +++++++ -Questions about `setting up your OT-2 `_, `using Opentrons software `_, or `troubleshooting `_? Check out our `support articles `_ or `get in touch directly `_ with Opentrons Support. +Questions about setting up your robot, using Opentrons software, or troubleshooting? Check out our `support articles `_ or `get in touch directly `_ with Opentrons Support. Custom Protocol Service +++++++++++++++++++++++ -Don't have the time or resources to write your own protocols? The `Opentrons Custom Protocols `_ service can get you set up in as little as a week. +Don't have the time or resources to write your own protocols? The `Opentrons Custom Protocols `_ service can get you set up in as little as a week. Contributing ++++++++++++ diff --git a/api/docs/v2/modules/heater_shaker.rst b/api/docs/v2/modules/heater_shaker.rst index b81ed074560..fb74aa5f23e 100644 --- a/api/docs/v2/modules/heater_shaker.rst +++ b/api/docs/v2/modules/heater_shaker.rst @@ -1,3 +1,5 @@ +:og:description: How to use the Heater-Shaker Module in a Python protocol, and where it can be safely placed on the deck. + .. _heater-shaker-module: ******************** diff --git a/api/docs/v2/modules/magnetic_block.rst b/api/docs/v2/modules/magnetic_block.rst index 98d52b33bde..a98fc08c83a 100644 --- a/api/docs/v2/modules/magnetic_block.rst +++ b/api/docs/v2/modules/magnetic_block.rst @@ -1,3 +1,5 @@ +:og:description: How to use the Magnetic Block with the Flex Gripper in a Python protocol. + .. _magnetic-block: ************** @@ -5,7 +7,7 @@ Magnetic Block ************** .. note:: - The Magnetic Block is compatible with Opentrons Flex only. If you have an OT-2, use the :ref:`magnetic-module`. + The Magnetic Block is compatible with Opentrons Flex only. If you have an OT-2, use the :ref:`Magnetic Module `. The Magnetic Block is an unpowered, 96-well plate that holds labware close to its high-strength neodymium magnets. This module is suitable for many magnetic bead-based protocols, but does not move beads up or down in solution. diff --git a/api/docs/v2/modules/magnetic_module.rst b/api/docs/v2/modules/magnetic_module.rst index 7c3561ab5e7..db12df7b58c 100644 --- a/api/docs/v2/modules/magnetic_module.rst +++ b/api/docs/v2/modules/magnetic_module.rst @@ -1,3 +1,5 @@ +:og:description: How to engage and disengage the Magnetic Module for the OT-2 in a Python protocol. + .. _magnetic-module: *************** @@ -5,7 +7,7 @@ Magnetic Module *************** .. note:: - The Magnetic Module is compatible with the OT-2 only. If you have a Flex, use the :ref:`magnetic-block`. + The Magnetic Module is compatible with the OT-2 only. If you have a Flex, use the :ref:`Magnetic Block `. The Magnetic Module controls a set of permanent magnets which can move vertically to induce a magnetic field in the labware loaded on the module. diff --git a/api/docs/v2/modules/multiple_same_type.rst b/api/docs/v2/modules/multiple_same_type.rst index 123dfd93a8c..ce44065487a 100644 --- a/api/docs/v2/modules/multiple_same_type.rst +++ b/api/docs/v2/modules/multiple_same_type.rst @@ -1,3 +1,5 @@ +:og:description: How to load and control multiple modules of the same type in a Python protocol. + .. _moam: ********************************* diff --git a/api/docs/v2/modules/setup.rst b/api/docs/v2/modules/setup.rst index 7c5b54c9ece..b538ceeff3a 100644 --- a/api/docs/v2/modules/setup.rst +++ b/api/docs/v2/modules/setup.rst @@ -1,3 +1,5 @@ +:og:description: How to load Opentrons hardware modules with adapters and labware in a Python protocol. + .. _module-setup: ************ @@ -31,7 +33,7 @@ Use :py:meth:`.ProtocolContext.load_module` to load a module. temperature_module = protocol.load_module( module_name='temperature module gen2', location='D3') - After the ``load_module()`` method loads labware into your protocol, it returns the :py:class:`~opentrons.protocol_api.HeaterShakerContext` and :py:class:`~opentrons.protocol_api.TemperatureModuleContext` objects. + After the ``load_module()`` method loads the modules into your protocol, it returns the :py:class:`~opentrons.protocol_api.HeaterShakerContext` and :py:class:`~opentrons.protocol_api.TemperatureModuleContext` objects. .. tab:: OT-2 @@ -40,7 +42,7 @@ Use :py:meth:`.ProtocolContext.load_module` to load a module. from opentrons import protocol_api - metadata = {'apiLevel': '2.13'} + metadata = {'apiLevel': '2.14'} def run(protocol: protocol_api.ProtocolContext): # Load a Magnetic Module GEN2 in deck slot 1. @@ -51,7 +53,7 @@ Use :py:meth:`.ProtocolContext.load_module` to load a module. temperature_module = protocol.load_module( module_name='temperature module', location=3) - After the ``load_module()`` method loads labware into your protocol, it returns the :py:class:`~opentrons.protocol_api.MagneticModuleContext` and :py:class:`~opentrons.protocol_api.TemperatureModuleContext` objects. + After the ``load_module()`` method loads the modules into your protocol, it returns the :py:class:`~opentrons.protocol_api.MagneticModuleContext` and :py:class:`~opentrons.protocol_api.TemperatureModuleContext` objects. .. versionadded:: 2.0 @@ -94,7 +96,7 @@ The first parameter of :py:meth:`.ProtocolContext.load_module` is the module's | GEN1 | | | +--------------------+-------------------------------+---------------------------+ -Some modules were added to our Python API later than others, and others span multiple hardware generations. When writing a protocol that requires a module, make sure your ``requirements`` or ``metadata`` code block specifies a :ref:`Protocol API version ` high enough to support all the module generations you want to use. +Some modules were added to our Python API later than others, and others span multiple hardware generations. When writing a protocol that requires a module, make sure your ``requirements`` or ``metadata`` code block specifies an :ref:`API version ` high enough to support all the module generations you want to use. .. _load-labware-module: @@ -115,7 +117,7 @@ Use the ``load_labware()`` method on the module context to load labware on a mod When you load labware on a module, you don’t need to specify the deck slot. In the above example, the ``load_module()`` method already specifies where the module is on the deck: ``location= "D1"``. -Any :ref:`v2-custom-labware` added to your Opentrons App is also accessible when loading labware onto a module. You can find and copy its load name by going to its card on the Labware page. +Any :ref:`custom labware ` added to your Opentrons App is also accessible when loading labware onto a module. You can find and copy its load name by going to its card on the Labware page. .. versionadded:: 2.1 diff --git a/api/docs/v2/modules/temperature_module.rst b/api/docs/v2/modules/temperature_module.rst index f1fd8782da4..095215ca6f7 100644 --- a/api/docs/v2/modules/temperature_module.rst +++ b/api/docs/v2/modules/temperature_module.rst @@ -1,3 +1,5 @@ +:og:description: How to heat and cool with the Temperature Module in a Python protocol. + .. _temperature-module: ****************** diff --git a/api/docs/v2/modules/thermocycler.rst b/api/docs/v2/modules/thermocycler.rst index 972db81a159..13aa1d3600a 100644 --- a/api/docs/v2/modules/thermocycler.rst +++ b/api/docs/v2/modules/thermocycler.rst @@ -1,3 +1,5 @@ +:og:description: How to control the lid, block, and temperature profile of the Thermocycler Module in a Python protocol. + .. _thermocycler-module: ******************* diff --git a/api/docs/v2/new_advanced_running.rst b/api/docs/v2/new_advanced_running.rst index f12402b495b..e642da6e9d1 100644 --- a/api/docs/v2/new_advanced_running.rst +++ b/api/docs/v2/new_advanced_running.rst @@ -7,25 +7,25 @@ Advanced Control As its name implies, the Python Protocol API is primarily designed for creating protocols that you upload via the Opentrons App and execute on the robot as a unit. But sometimes it's more convenient to control the robot outside of the app. For example, you might want to have variables in your code that change based on user input or the contents of a CSV file. Or you might want to only execute part of your protocol at a time, especially when developing or debugging a new protocol. -The OT-2 offers two ways of issuing Python API commands to the robot outside of the app: through Jupyter Notebook or on the command line with ``opentrons_execute``. +The Python API offers two ways of issuing commands to the robot outside of the app: through Jupyter Notebook or on the command line with ``opentrons_execute``. Jupyter Notebook ---------------- -The OT-2 runs a `Jupyter Notebook `_ server on port 48888, which you can connect to with your web browser. This is a convenient environment for writing and debugging protocols, since you can define different parts of your protocol in different notebook cells and run a single cell at a time. +The Flex and OT-2 run `Jupyter Notebook `_ servers on port 48888, which you can connect to with your web browser. This is a convenient environment for writing and debugging protocols, since you can define different parts of your protocol in different notebook cells and run a single cell at a time. .. note:: - The Jupyter Notebook server only supports Python Protocol API versions 2.13 and earlier. Use the Opentrons App to run protocols that require functionality added in newer versions. + Currently, the Jupyter Notebook server does not work with Python Protocol API versions 2.14 and 2.15. It does work with API versions 2.13 and earlier. Use the Opentrons App to run protocols that require functionality added in newer versions. -Access the OT-2’s Jupyter Notebook by either: +Access your robot's Jupyter Notebook by either: - Going to the **Advanced** tab of Robot Settings and clicking **Launch Jupyter Notebook**. - Going directly to ``http://:48888`` in your web browser (if you know your robot's IP address). -Once you've launched Jupyter Notebook, you can create a notebook file or edit an existing one. These notebook files are stored on the OT-2 itself. If you want to save code from a notebook to your computer, go to **File > Download As** in the notebook interface. +Once you've launched Jupyter Notebook, you can create a notebook file or edit an existing one. These notebook files are stored on the the robot. If you want to save code from a notebook to your computer, go to **File > Download As** in the notebook interface. Protocol Structure -++++++++++++++++++ +^^^^^^^^^^^^^^^^^^ Jupyter Notebook is structured around `cells`: discrete chunks of code that can be run individually. This is nearly the opposite of Opentrons protocols, which bundle all commands into a single ``run`` function. Therefore, to take full advantage of Jupyter Notebook, you have to restructure your protocol. @@ -34,7 +34,7 @@ Rather than writing a ``run`` function and embedding commands within it, start .. code-block:: python import opentrons.execute - protocol = opentrons.execute.get_protocol_api('2.13') + protocol = opentrons.execute.get_protocol_api("2.13") protocol.home() The first command you execute should always be :py:meth:`~opentrons.protocol_api.ProtocolContext.home`. If you try to execute other commands first, you will get a ``MustHomeError``. (When running protocols through the Opentrons App, the robot homes automatically.) @@ -42,7 +42,7 @@ The first command you execute should always be :py:meth:`~opentrons.protocol_api You should use the same :py:class:`.ProtocolContext` throughout your notebook, unless you need to start over from the beginning of your protocol logic. In that case, call :py:meth:`~opentrons.execute.get_protocol_api` again to get a new :py:class:`.ProtocolContext`. Running a Previously Written Protocol -+++++++++++++++++++++++++++++++++++++ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You can also use Jupyter to run a protocol that you have already written. To do so, first copy the entire text of the protocol into a cell and run that cell: @@ -54,23 +54,86 @@ You can also use Jupyter to run a protocol that you have already written. To do # the contents of your previously written protocol go here -Since a typical protocol only `defines` the ``run`` function but doesn't `call` it, this won't immediately cause the OT-2 to move. To begin the run, instantiate a :py:class:`.ProtocolContext` and pass it to the ``run`` function you just defined: +Since a typical protocol only `defines` the ``run`` function but doesn't `call` it, this won't immediately cause the robot to move. To begin the run, instantiate a :py:class:`.ProtocolContext` and pass it to the ``run`` function you just defined: .. code-block:: python - protocol = opentrons.execute.get_protocol_api('2.13') + protocol = opentrons.execute.get_protocol_api("2.13") run(protocol) # your protocol will now run +.. _using_lpc: + +Setting Labware Offsets +----------------------- + +All positions relative to labware are adjusted automatically based on labware offset data. When you're running your code in Jupyter Notebook or with ``opentrons_execute``, you need to set your own offsets because you can't perform run setup and Labware Position Check in the Opentrons App or on the Flex touchscreen. For these applications, do the following to calculate and apply labware offsets: + + 1. Create a "dummy" protocol that loads your labware and has each used pipette pick up a tip from a tip rack. + 2. Import the dummy protocol to the Opentrons App. + 3. Run Labware Position Check from the app or touchscreen. + 4. Add the offsets to your code with :py:meth:`.set_offset`. + +Creating the dummy protocol requires you to: + + 1. Use the ``metadata`` or ``requirements`` dictionary to specify the API version. (See :ref:`v2-versioning` for details.) Use the same API version as you did in :py:meth:`opentrons.execute.get_protocol_api`. + 2. Define a ``run()`` function. + 3. Load all of your labware in their initial locations. + 4. Load your smallest capacity pipette and specify its ``tipracks``. + 5. Call ``pick_up_tip()``. Labware Position Check can't run if you don't pick up a tip. + +For example, the following dummy protocol will use a P300 Single-Channel GEN2 pipette to enable Labware Position Check for an OT-2 tip rack, NEST reservoir, and NEST flat well plate. + +.. code-block:: python + + metadata = {"apiLevel": "2.13"} + + def run(protocol): + tiprack = protocol.load_labware("opentrons_96_tiprack_300ul", 1) + reservoir = protocol.load_labware("nest_12_reservoir_15ml", 2) + plate = protocol.load_labware("nest_96_wellplate_200ul_flat", 3) + p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tiprack]) + p300.pick_up_tip() + p300.return_tip() + +After importing this protocol to the Opentrons App, run Labware Position Check to get the x, y, and z offsets for the tip rack and labware. When complete, you can click **Get Labware Offset Data** to view automatically generated code that uses :py:meth:`.set_offset` to apply the offsets to each piece of labware. + +.. code-block:: python + + labware_1 = protocol.load_labware("opentrons_96_tiprack_300ul", location="1") + labware_1.set_offset(x=0.00, y=0.00, z=0.00) + + labware_2 = protocol.load_labware("nest_12_reservoir_15ml", location="2") + labware_2.set_offset(x=0.10, y=0.20, z=0.30) + + labware_3 = protocol.load_labware("nest_96_wellplate_200ul_flat", location="3") + labware_3.set_offset(x=0.10, y=0.20, z=0.30) + +This automatically generated code uses generic names for the loaded labware. If you want to match the labware names already in your protocol, change the labware names to match your original code: + +.. code-block:: python + + reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2") + reservoir.set_offset(x=0.10, y=0.20, z=0.30) + +.. versionadded:: 2.12 + +Once you've executed this code in Jupyter Notebook, all subsequent positional calculations for this reservoir in slot D2 will be adjusted 0.1 mm to the right, 0.2 mm to the back, and 0.3 mm up. + +Remember, you should only add ``.set_offset()`` commands to protocols run outside of the Opentrons App. And you should follow the behavior of Labware Position Check, i.e., *do not* reuse offset measurements unless they apply to the *same labware* in the *same deck slot* on the *same robot*. + +.. warning:: + + Improperly reusing offset data may cause your robot to move to an unexpected position or crash against other labware, which can lead to incorrect protocol execution or damage your equipment. The same applies when running protocols with ``.set_offset()`` commands in the Opentrons App. When in doubt: run Labware Position Check again and update your code! Using Custom Labware -++++++++++++++++++++ +-------------------- If you have custom labware definitions you want to use with Jupyter, make a new directory called ``labware`` in Jupyter and put the definitions there. These definitions will be available when you call :py:meth:`~opentrons.protocol_api.ProtocolContext.load_labware`. Using Modules -+++++++++++++ +------------- -If your protocol uses :ref:`new_modules`, you need to take additional steps to make sure that Jupyter Notebook doesn't send commands that conflict with the robot server. Sending commands to modules while the robot server is running will likely cause errors, and the module commands may not execute as expected. +If your protocol uses :ref:`modules `, you need to take additional steps to make sure that Jupyter Notebook doesn't send commands that conflict with the robot server. Sending commands to modules while the robot server is running will likely cause errors, and the module commands may not execute as expected. To disable the robot server, open a Jupyter terminal session by going to **New > Terminal** and run ``systemctl stop opentrons-robot-server``. Then you can run code from cells in your notebook as usual. When you are done using Jupyter Notebook, you should restart the robot server with ``systemctl start opentrons-robot-server``. @@ -82,7 +145,7 @@ To disable the robot server, open a Jupyter terminal session by going to **New > Command Line ------------ -The OT-2's command line is accessible either by going to **New > Terminal** in Jupyter or `via SSH `_. +The robot's command line is accessible either by going to **New > Terminal** in Jupyter or `via SSH `_. To execute a protocol from the robot's command line, copy the protocol file to the robot with ``scp`` and then run the protocol with ``opentrons_execute``: @@ -91,4 +154,4 @@ To execute a protocol from the robot's command line, copy the protocol file to t opentrons_execute /data/my_protocol.py -By default, ``opentrons_execute`` will print out the same run log shown in the Opentrons App, as the protocol executes. It also prints out internal logs at the level ``warning`` or above. Both of these behaviors can be changed; for further details, run ``opentrons_execute --help``. +By default, ``opentrons_execute`` will print out the same run log shown in the Opentrons App, as the protocol executes. It also prints out internal logs at the level ``warning`` or above. Both of these behaviors can be changed. Run ``opentrons_execute --help`` for more information. diff --git a/api/docs/v2/new_atomic_commands.rst b/api/docs/v2/new_atomic_commands.rst index 98de928849e..d72a16bd765 100644 --- a/api/docs/v2/new_atomic_commands.rst +++ b/api/docs/v2/new_atomic_commands.rst @@ -1,545 +1,20 @@ -:og:description: Building block commands are the smallest individual actions that Opentrons robots can perform. +:og:description: Basic commands that Opentrons robots can perform with pipette tips, for liquid handling, and other utility features. .. _v2-atomic-commands: -####################### +*********************** Building Block Commands -####################### +*********************** -Building block, or basic, commands are the smallest individual actions that can be completed on an OT-2. -For example, the complex command ``transfer`` (see :ref:`v2-complex-commands`) executes a series of ``pick_up_tip()``, ``aspirate()``, ``dispense()`` and ``drop_tip()`` basic commands. +.. toctree:: + basic_commands/pipette_tips + basic_commands/liquids + basic_commands/utilities -The examples in this section would be added to the following: +Building block commands execute some of the most basic actions that your robot can complete. But basic doesn’t mean these commands lack capabilities. They perform important tasks in your protocols. They're also foundational to the :ref:`complex commands ` that help you combine multiple actions into fewer lines of code. -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - tiprack = protocol.load_labware('corning_96_wellplate_360ul_flat', 2) - plate = protocol.load_labware('opentrons_96_tiprack_300ul', 3) - pipette = protocol.load_instrument('p300_single_gen2', mount='left') - # the example code below would go here, inside the run function - - -This loads a `Corning 96 Well Plate `_ in slot 2 and a `Opentrons 300 µL Tiprack `_ in slot 3, and uses a P300 Single GEN2 pipette. - - -************** -Tip Handling -************** - -When the OT-2 handle liquids with, it constantly exchanges old, used tips for new ones to prevent cross-contamination between wells. Tip handling uses the functions :py:meth:`.InstrumentContext.pick_up_tip`, :py:meth:`.InstrumentContext.drop_tip`, and :py:meth:`.InstrumentContext.return_tip`. - -Pick Up Tip -=========== - -Before any liquid handling can be done, your pipette must have a tip on it. The command :py:meth:`.InstrumentContext.pick_up_tip` will move the pipette over to the specified tip, then press down into it to create a vacuum seal. The below example picks up the tip at location ``'A1'`` of the tiprack previously loaded in slot 3. - -.. code-block:: python - - pipette.pick_up_tip(tiprack['A1']) - -If you have associated a tiprack with your pipette such as in the :ref:`new-pipette` or :ref:`protocol_api-protocols-and-instruments` sections, then you can simply call - -.. code-block:: python - - pipette.pick_up_tip() - -This will use the next available tip from the list of tipracks passed in to the ``tip_racks`` argument of :py:meth:`.ProtocolContext.load_instrument`. - -.. versionadded:: 2.0 - -Drop Tip -======== - -Once finished with a tip, the pipette will remove the tip when we call :py:meth:`.InstrumentContext.drop_tip`. You can specify where to drop the tip by passing in a location. The below example drops the tip back at its original location on the tip rack. -If no location is specified, the OT-2 will drop the tip in the fixed trash in slot 12 of the deck. - -.. code-block:: python - - pipette.pick_up_tip() - pipette.drop_tip(tiprack['A1']) # drop back in A1 of the tiprack - pipette.pick_up_tip() - pipette.drop_tip() # drop in the fixed trash on the deck - - -.. versionadded:: 2.0 - -.. _pipette-return-tip: - -Return Tip -=========== - -To return the tip to the original location, you can call :py:meth:`.InstrumentContext.return_tip`. The example below will automatically return the tip to ``'A3'`` on the tip rack. - -.. code-block:: python - - pipette.pick_up_tip(tiprack['A3']) - pipette.return_tip() - -.. note: - - In API Version 2.0 and 2.1, the returned tips are added back into the tip-tracker and thus treated as `unused`. If you make a subsequent call to `pick_up_tip` then the software will treat returned tips as valid locations. - In API Version 2.2, returned tips are no longer added back into the tip tracker. This means that returned tips are no longer valid locations and the pipette will not attempt to pick up tips from these locations. - Also in API Version 2.2, the return tip height was corrected to utilize values determined by hardware testing. This is more in-line with return tip behavior from Python Protocol API Version 1. - -In API version 2.2 or above: - -.. code-block:: python - - tip_rack = protocol.load_labware( - 'opentrons_96_tiprack_300ul', 1) - pipette = protocol.load_instrument( - 'p300_single_gen2', mount='left', tip_racks=[tip_rack]) - - pipette.pick_up_tip() # picks up tip_rack:A1 - pipette.return_tip() - pipette.pick_up_tip() # picks up tip_rack:B1 - -In API version 2.0 and 2.1: - -.. code-block:: python - - tip_rack = protocol.load_labware( - 'opentrons_96_tiprack_300ul', 1) - pipette = protocol.load_instrument( - 'p300_single_gen2', mount='left', tip_racks=[tip_rack]) - - pipette.pick_up_tip() # picks up tip_rack:A1 - pipette.return_tip() - pipette.pick_up_tip() # picks up tip_rack:A1 - -Iterating Through Tips ----------------------- - -For this section, instead of using the protocol defined above, consider this setup: - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware( - 'corning_96_wellplate_360ul_flat', 2) - tip_rack_1 = protocol.load_labware( - 'opentrons_96_tiprack_300ul', 3) - tip_rack_2 = protocol.load_labware( - 'opentrons_96_tiprack_300ul', 4) - pipette = protocol.load_instrument( - 'p300_single_gen2', mount='left', tip_racks=[tip_rack_1, tip_rack_2]) - -This loads a `Corning 96 Well Plate `_ in slot 2 and two `Opentrons 300ul Tiprack `_ in slots 3 and 4 respectively, and uses a P300 Single GEN2 pipette. - -When a list of tip racks is associated with a pipette in its ``tip_racks`` argument, the pipette will automatically pick up the next unused tip in the list whenever you call :py:meth:`.InstrumentContext.pick_up_tip`. The pipette will first use all tips in the first tiprack, then move on to the second, and so on: - -.. code-block:: python - - pipette.pick_up_tip() # picks up tip_rack_1:A1 - pipette.return_tip() - pipette.pick_up_tip() # picks up tip_rack_1:A2 - pipette.drop_tip() # automatically drops in trash - - # use loop to pick up tips tip_rack_1:A3 through tip_rack_2:H12 - tips_left = 94 + 96 # add up the number of tips leftover in both tipracks - for _ in range(tips_left): - pipette.pick_up_tip() - pipette.return_tip() - -If you try to :py:meth:`.InstrumentContext.pick_up_tip()` again when all the tips have been used, the Protocol API will show you an error: - -.. code-block:: python - - # this will raise an exception if run after the previous code block - pipette.pick_up_tip() - -To change the location of the first tip used by the pipette, you can use :py:obj:`.InstrumentContext.starting_tip`: - -.. code-block:: python - - pipette.starting_tip = tip_rack_1.well('C3') - pipette.pick_up_tip() # pick up C3 from "tip_rack_1" - pipette.return_tip() - -To reset the tip tracking, you can call :py:meth:`.InstrumentContext.reset_tipracks`: - -.. code-block:: python - - # Use up all tips - for _ in range(96+96): - pipette.pick_up_tip() - pipette.return_tip() - - # Reset the tip tracker - pipette.reset_tipracks() - - # Picks up a tip from well A1 of the first tip rack - pipette.pick_up_tip() - - -.. versionadded:: 2.0 - -To check whether you should pick up a tip or not, you can utilize :py:meth:`.InstrumentContext.has_tip`: - -.. code-block:: python - - for block in range(3): - if block == 0 and not pipette.has_tip: - pipette.pick_up_tip() - else: - m300.mix(mix_repetitions, 250, d) - m300.blow_out(s.bottom(10)) - m300.return_tip() - -.. versionadded:: 2.7 - -********************** - -**************** -Liquid Control -**************** - -This section describes the :py:class:`.InstrumentContext` 's liquid-handling commands. - -The examples in this section should be inserted in the following: - -.. code-block:: python - :substitutions: - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 2) - tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', 3) - pipette = protocol.load_instrument('p300_single_gen2', mount='left', tip_racks=[tiprack]) - pipette.pick_up_tip() - # example code goes here - - -This loads a `Corning 96 Well Plate `_ in slot 2 and a `Opentrons 300ul Tiprack `_ in slot 3, and uses a P300 Single GEN2 pipette. - - -.. _new-aspirate: - -Aspirate -======== - -To aspirate is to pull liquid up into the pipette's tip. When calling :py:meth:`.InstrumentContext.aspirate` on a pipette, you can specify the volume to aspirate in µL, where to aspirate from, and how fast to aspirate liquid. - -.. code-block:: python - - pipette.aspirate(50, plate['A1'], rate=2.0) # aspirate 50uL from plate:A1 - -Now the pipette's tip is holding 50 µL. - -The ``location`` parameter is either a well (like ``plate['A1']``) or a position within a well, like the return value of ``plate['A1'].bottom``. - -The ``rate`` parameter is a multiplication factor of the pipette's default aspiration flow rate. The default aspiration flow rate for all pipettes is in the :ref:`defaults` section. - -You can also simply specify the volume to aspirate, and not mention a location. The pipette will aspirate from its current location (which we previously set as ``plate['A1'])``. - -.. code-block:: python - - pipette.aspirate(50) # aspirate 50uL from current position - -Now our pipette's tip is holding 100 µL. - -.. note:: - - In version 1 of this API, ``aspirate`` (and ``dispense``) would inspect the types of the ``volume`` and ``location`` arguments and do the right thing if you specified only a location or specified location and volume out of order. In this and future versions of the Python Protocol API, this is no longer true. Like any other Python function, if you are specifying arguments by position without using their names, you must always specify them in order. - -.. note:: - - By default, the OT-2 will move to 1mm above the bottom of the target well before aspirating. - You can change this by using a well position function like :py:meth:`.Well.bottom` (see - :ref:`v2-location-within-wells`) every time you call ``aspirate``, or - if you want to change - the default throughout your protocol - you can change the default offset with - :py:obj:`.InstrumentContext.well_bottom_clearance` (see :ref:`new-default-op-positions`). - -.. versionadded:: 2.0 - -.. _new-dispense: - -Dispense -======== - -To dispense is to push out liquid from the pipette's tip. The usage of :py:meth:`.InstrumentContext.dispense` in the Protocol API is similar to :py:meth:`.InstrumentContext.aspirate`, in that you can specify volume in µL and location, or only volume. - -.. code-block:: python - - pipette.dispense(50, plate['B1'], rate=2.0) # dispense 50uL to plate:B1 at twice the normal rate - pipette.dispense(50) # dispense 50uL to current position at the normal rate - - -The ``location`` parameter is either a well (like ``plate['A1']``) or a position within a well, like the return value of ``plate['A1'].bottom``. - -The ``rate`` parameter is a multiplication factor of the pipette's default dispense flow rate. The default dispense flow rate for all pipettes is in the :ref:`defaults` section. - -.. note:: - - By default, the OT-2 will move to 1mm above the bottom of the target well before dispensing. - You can change this by using a well position function like :py:meth:`.Well.bottom` (see - :ref:`v2-location-within-wells`) every time you call ``dispense``, or - if you want to change - the default throughout your protocol - you can change the default offset with - :py:obj:`.InstrumentContext.well_bottom_clearance` (see :ref:`new-default-op-positions`). - -.. note:: - - In version 1 of this API, ``dispense`` (and ``aspirate``) would inspect the types of the ``volume`` and ``location`` arguments and do the right thing if you specified only a location or specified location and volume out of order. In this and future versions of the Python Protocol API, this is no longer true. Like any other Python function, if you are specifying arguments by position without using their names, you must always specify them in order. - -.. versionadded:: 2.0 - -.. _new-blow-out: - -.. _blow-out: - -Blow Out -======== - -To blow out is to push an extra amount of air through the pipette's tip, to make sure that any remaining droplets are expelled. - -When calling :py:meth:`.InstrumentContext.blow_out`, you can specify a location to blow out the remaining liquid. If no location is specified, the pipette will blow out from its current position. - -.. code-block:: python - - pipette.blow_out() # blow out in current location - pipette.blow_out(plate['B3']) # blow out in current plate:B3 - - -.. versionadded:: 2.0 - -.. _touch-tip: - -Touch Tip -========= - -To touch tip is to move the pipette's currently attached tip to four opposite edges of a well, to knock off any droplets that might be hanging from the tip. - -When calling :py:meth:`.InstrumentContext.touch_tip` on a pipette, you have the option to specify a location where the tip will touch the inner walls. - -:py:meth:`.InstrumentContext.touch_tip` can take up to 4 arguments: ``touch_tip(location, radius, v_offset, speed)``. - -.. code-block:: python - - pipette.touch_tip() # touch tip within current location - pipette.touch_tip(v_offset=-2) # touch tip 2mm below the top of the current location - pipette.touch_tip(plate['B1']) # touch tip within plate:B1 - pipette.touch_tip(plate['B1'], speed=100) # touch tip within plate:B1 at 100 mm/s - pipette.touch_tip(plate['B1'], # touch tip in plate:B1, at 75% of total radius and -2mm from top of well - radius=0.75, - v_offset=-2) - - -.. versionadded:: 2.0 - -.. note: - - It is recommended that you change your API version to 2.4 to take advantage of new - features added into `touch_tip` such as: - - A lower minimum speed (1 mm/s) - - Better handling around near by geometry considerations - - Removed certain extraneous behaviors such as a diagonal move from X -> Y and - moving directly to the height offset specified. - -.. _mix: - -Mix -=== - -To mix is to perform a series of ``aspirate`` and ``dispense`` commands in a row on a single location. Instead of having to write those commands out every time, you can call :py:meth:`.InstrumentContext.mix`. - -The ``mix`` command takes up to three arguments: ``mix(repetitions, volume, location)``: - -.. code-block:: python - - # mix 4 times, 100uL, in plate:A2 - pipette.mix(4, 100, plate['A2']) - # mix 3 times, 50uL, in current location - pipette.mix(3, 50) - # mix 2 times, pipette's max volume, in current location - pipette.mix(2) - -.. note:: - - In API Versions 2.2 and earlier, mixes consist of aspirates and then immediate dispenses. In between these actions, the pipette moves up and out of the target well. In API Version 2.3 and later, the pipette will not move between actions. - -.. versionadded:: 2.0 - -.. _air-gap: - -Air Gap -======= - -When dealing with certain liquids, you may need to aspirate air after aspirating the liquid to prevent it from sliding out of the pipette's tip. A call to :py:meth:`.InstrumentContext.air_gap` with a volume in µL will aspirate that much air into the tip. ``air_gap`` takes up to two arguments: ``air_gap(volume, height)``: - -.. code-block:: python - - pipette.aspirate(100, plate['B4']) - pipette.air_gap(20) - pipette.drop_tip() - -.. versionadded:: 2.0 - -********************** - -.. _new-utility-commands: - -**************** -Utility Commands -**************** - -Delay for an Amount of Time -=========================== - -Sometimes you need to wait as a step in your protocol, for instance to wait for something to incubate. You can use :py:meth:`.ProtocolContext.delay` to wait your protocol for a specific amount of time. ``delay`` is a method of :py:class:`.ProtocolContext` since it concerns the protocol and the OT-2 as a whole. - -The values passed into ``delay()`` specify the number of minutes and seconds that the OT-2 will wait until moving on to the next command. - -.. code-block:: python - - protocol.delay(seconds=2) # delay for 2 seconds - protocol.delay(minutes=5) # delay for 5 minutes - protocol.delay(minutes=5, seconds=2) # delay for 5 minutes and 2 seconds - - -Pause Until Resumed -=================== - -The method :py:meth:`.ProtocolContext.pause` will pause protocol execution at a specific step. -You can resume by pressing 'resume' in your Opentrons App. You can optionally specify a message that -will be displayed in the Opentrons App when protocol execution pauses. - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - # The start of your protocol goes here... - - # The OT-2 stops here until you press resume. It will display the message in - # the Opentrons App. You do not need to specify a message, but it makes things - # more clear. - protocol.pause('Time to take a break') - -.. versionadded:: 2.0 - -Homing -====== - -You can manually request that the OT-2 home during protocol execution. This is typically -not necessary; however, if at any point you will disengage motors or move -the gantry with your hand, you may want to command a home afterwards. - -To home the entire OT-2, you can call :py:meth:`.ProtocolContext.home`. - -To home a specific pipette's Z axis and plunger, you can call :py:meth:`.InstrumentContext.home`. - -To home a specific pipette's plunger only, you can call :py:meth:`.InstrumentContext.home_plunger`. - -None of these functions take any arguments: - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api, types - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - pipette = protocol.load_instrument('p300_single', 'right') - protocol.home() # Homes the gantry, z axes, and plungers - pipette.home() # Homes the right z axis and plunger - pipette.home_plunger() # Homes the right plunger - -.. versionadded:: 2.0 - - -Comment -======= - -The method :py:meth:`.ProtocolContext.comment` lets you display messages in the Opentrons App during protocol execution: - - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api, types - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - protocol.comment('Hello, world!') - -.. versionadded:: 2.0 - - -Control and Monitor Robot Rail Lights -===================================== - -You can turn the robot rail lights on or off in the protocol using :py:meth:`.ProtocolContext.set_rail_lights`: - - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - # turn on robot rail lights - protocol.set_rail_lights(True) - - # turn off robot rail lights - protocol.set_rail_lights(False) - -.. versionadded:: 2.5 - - -You can also check whether the rail lights are on or off in the protocol using :py:obj:`.ProtocolContext.rail_lights_on`: - - -.. code-block:: python - - protocol.rail_lights_on # returns True when the lights are on, - # False when the lights are off - -.. versionadded:: 2.5 - - -Monitor Robot Door -================== - -The door safety switch feature flag has been added to the OT-2 software since the 3.19.0 release. Enabling the feature flag allows your robot to pause a running protocol and prohibit the protocol from running when the robot door is open. - -.. image:: ../img/feature_flags/door_safety_switch.png - -You can also check whether or not the robot door is closed at a specific point in time in the protocol using :py:obj:`.ProtocolContext.door_closed`: - - -.. code-block:: python - - protocol.door_closed # return True when the door is closed, - # False when the door is open - - -.. note:: - - Both the top window and the front door must be closed in order for the robot to report the door is closed. - - -.. warning:: - - If you chose to enable the door safety switch feature flag, you should only use :py:obj:`.ProtocolContext.door_closed` as a form of status check, and should not use it to control robot behavior. If you wish to implement custom method to pause or resume protocol using :py:obj:`.ProtocolContext.door_closed`, make sure you have first disabled the feature flag. - -.. versionadded:: 2.5 +Pages in this section of the documentation cover: +- :ref:`pipette-tips`: Get started with commands for picking up pipette tips, dropping tips, returning tips, and working with used tips. +- :ref:`liquid-control`: Learn about aspirating and dispensing liquids, blow out and touch tip procedures, mixing, and creating air gaps. +- :ref:`new-utility-commands`: Control various robot functions such as pausing or delaying a protocol, checking the robot’s door, turning robot lights on/off, and more. diff --git a/api/docs/v2/new_complex_commands.rst b/api/docs/v2/new_complex_commands.rst index 4e71483fd3f..161a6dc8549 100644 --- a/api/docs/v2/new_complex_commands.rst +++ b/api/docs/v2/new_complex_commands.rst @@ -2,947 +2,27 @@ .. _v2-complex-commands: -################ +**************** Complex Commands -################ +**************** -.. _overview: +.. toctree:: + complex_commands/sources_destinations + complex_commands/order_operations + complex_commands/parameters -Overview -======== +Complex liquid handling commands combine multiple :ref:`building block commands ` into a single method call. These commands make it easier to handle larger groups of wells and repeat actions without having to write your own control flow code. They integrate tip-handling behavior and can pick up, use, and drop multiple tips depending on how you want to handle your liquids. They can optionally perform other actions, like adding air gaps, knocking droplets off the tip, mixing, and blowing out excess liquid from the tip. -The commands in this section execute long or complex series of the commands described in the :ref:`v2-atomic-commands` section. These advanced commands make it easier to handle larger groups of wells and repetitive actions. +There are three complex liquid handling commands, each optimized for a different liquid handling scenario: -The examples in this section will use the following set up: + - :py:meth:`.InstrumentContext.transfer` + - :py:meth:`.InstrumentContext.distribute` + - :py:meth:`.InstrumentContext.consolidate` -.. code-block:: python - :substitutions: +Pages in this section of the documentation cover: - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 1) - tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', 2) - tiprack_multi = protocol.load_labware('opentrons_96_tiprack_300ul', 3) - pipette = protocol.load_instrument('p300_single', mount='left', tip_racks=[tiprack]) - pipette_multi = protocol.load_instrument('p300_multi', mount='right', tip_racks=[tiprack_multi]) - - # The code used in the rest of the examples goes here - - -This loads a `Corning 96 Well Plate `_ in slot 1 and a `Opentrons 300 µL Tiprack `_ in slot 2 and 3, and uses a P300 Single pipette and a P300 Multi pipette. - -You can follow along and simulate the protocol using our protocol simulator, which can be installed by following the instructions at :ref:`writing`. - -There are three complex liquid handling commands: - -+------------------------------------------+----------------------------------------------------+------------------------------------------------------+-------------------------------------------+ -| Method | One source well to a group of destination wells | Many source wells to a group of destination wells | Many source wells to one destination well | -+==========================================+====================================================+======================================================+===========================================+ -| :py:meth:`.InstrumentContext.transfer` | Yes | Yes | Yes | -+------------------------------------------+----------------------------------------------------+------------------------------------------------------+-------------------------------------------+ -| :py:meth:`.InstrumentContext.distribute` | Yes | Yes | No | -+------------------------------------------+----------------------------------------------------+------------------------------------------------------+-------------------------------------------+ -| :py:meth:`.InstrumentContext.consolidate`| No | Yes | Yes | -+------------------------------------------+----------------------------------------------------+------------------------------------------------------+-------------------------------------------+ - -You can also refer to these images for further clarification. - - -.. _transfer-image: - -Transfer --------- - -.. image:: ../img/complex_commands/transfer.png - :scale: 75 % - :name: Transfer - :align: center - - -.. _distribute-image: - -Distribute ----------- - -.. image:: ../img/complex_commands/robot_distribute.png - :scale: 50 % - :name: Distribute - :align: center - -.. _consolidate-image: - -Consolidate ------------ - -.. image:: ../img/complex_commands/robot_consolidate.png - :scale: 50 % - :name: Consolidate - :align: center - -********************** - -.. _params_table: - -Parameters ----------- - -Parameters for the complex liquid handling are listed here in order of operation. Check out the :ref:`complex_params` section for examples on how to use these parameters. - -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| Parameter(s) | Options | Transfer Defaults | Distribute Defaults | Consolidate Defaults | -+================================+======================================================+============================+====================================+====================================+ -| ``new_tip`` | ``'always'``, ``'never'``, ``'once'`` | ``'once'`` | ``'once'`` | ``'once'`` | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``mix_before``, ``mix_after`` | ``mix_before`` and ``mix_after`` require a tuple | No mixing either before | No mixing before aspirate, | Mixing before aspirate is ignored, | -| | of (repetitions, volume) | aspirate or after dispense | mixing after dispense is ignored | no mix after dispense by default | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``touch_tip`` | ``True`` or ``False``, if true touch tip on both | No touch tip by default | No touch tip by default | No touch tip by default | -| | source and destination wells | | | | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``air_gap`` | Volume in µL | 0 | 0 | 0 | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``blow_out`` | ``True`` or ``False``, if true and no location | ``False`` | ``False`` | ``False`` | -| | specified it will blow out in the trash. | | | | -| | | | | | -| | **Note**: | | | | -| | 1. If the pipette tip is empty, and no location is | | | | -| | specified, the pipette will blow out in the trash. | | | | -| | 2. If the pipette tip is not empty, and no | | | | -| | location is specified, the pipette will blow out | | | | -| | into the source well. | | | | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``blowout_location`` | ``trash``, ``source well``, ``destination well`` | There is no location by | There is no location by | There is no location by | -| | | default. Please see | default. Please see ``blow_out`` | default. Please see ``blow_out`` | -| | **Note**: If ``blow_out`` is set to ``False`` this | ``blow_out`` above for | above for default behavior. | above for default behavior. | -| | parameter will be ignored. | default behavior. | | | -| | | | | | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``trash`` | ``True`` or ``False``, if false return tip to tiprack| ``True`` | ``True`` | ``True`` | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``carryover`` | ``True`` or ``False``, if true split volumes that | ``True`` | ``False`` | ``False`` | -| | exceed max volume of pipette into smaller quantities | | | | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ -| ``disposal_volume`` | Extra volume in µL to hold in tip while | 0 | 10% of pipette max volume | 0 | -| | dispensing; better accuracies in multi-dispense | | | | -+--------------------------------+------------------------------------------------------+----------------------------+------------------------------------+------------------------------------+ - -Transfer -======== - -The most versatile complex liquid handling function is :py:meth:`.InstrumentContext.transfer`. For a majority of use cases you will most likely want to use this complex command. - -Below you will find a few scenarios using the :py:meth:`.InstrumentContext.transfer` command. - -.. versionadded:: 2.0 - -Basic ------ - -This example below transfers 100 µL from well ``'A1'`` to well ``'B1'`` using the P300 Single pipette, automatically picking up a new tip and then disposing of it when finished. - -.. code-block:: python - - pipette.transfer(100, plate.wells_by_name()['A1'], plate.wells_by_name()['B1']) - -When you are using a multi-channel pipette, you can transfer the entire column (8 wells) in the plate to another using: - -.. code-block:: python - - pipette.transfer(100, plate.wells_by_name()['A1'], plate.wells_by_name()['A2']) - -.. note:: - - In API Versions 2.0 and 2.1, multichannel pipettes could only access the first row of a 384 well plate, and access to the second row would be ignored. If you need to transfer from all wells of a 384-well plate, please make sure to use API Version 2.2 - -.. note:: - - Multichannel pipettes can only access a limited number of rows in a plate during `transfer`, `distribute` and `consolidate`: the first row (wells A1 - A12) of a 96-well plate, and (since API Version 2.2) the first two rows (wells A1 - B24) for a 384-well plate. Wells specified outside of the limit will be ignored. - -Transfer commands will automatically create entire series of :py:meth:`.InstrumentContext.aspirate`, :py:meth:`.InstrumentContext.dispense`, and other :py:obj:`.InstrumentContext` commands. - - -Large Volumes -------------- - -Volumes larger than the pipette's ``max_volume`` (see :ref:`defaults`) will automatically divide into smaller transfers. - -.. code-block:: python - - pipette.transfer(700, plate.wells_by_name()['A2'], plate.wells_by_name()['B2']) - -will have the steps... - -.. code-block:: python - - Transferring 700 from well A2 in "1" to well B2 in "1" - Picking up tip well A1 in "2" - Aspirating 300.0 uL from well A2 in "1" at 1 speed - Dispensing 300.0 uL into well B2 in "1" - Aspirating 200.0 uL from well A2 in "1" at 1 speed - Dispensing 200.0 uL into well B2 in "1" - Aspirating 200.0 uL from well A2 in "1" at 1 speed - Dispensing 200.0 uL into well B2 in "1" - Dropping tip well A1 in "12" - -One to One ------------ - -Transfer commands are most useful when moving liquid between multiple wells. This will be a one to one transfer -from where well ``A1``'s contents are transferred to well ``A2``, well ``B1``'s contents to ``B2``,and so on. This is the scenario displayed in the :ref:`transfer-image` visualization. - -.. code-block:: python - - pipette.transfer(100, plate.columns_by_name()['1'], plate.columns_by_name()['2']) - -will have the steps... - -.. code-block:: python - - Transferring 100 from wells A1...H1 in "1" to wells A2...H2 in "1" - Picking up tip well A1 in "2" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well A2 in "1" - Aspirating 100.0 uL from well B1 in "1" at 1 speed - Dispensing 100.0 uL into well B2 in "1" - Aspirating 100.0 uL from well C1 in "1" at 1 speed - Dispensing 100.0 uL into well C2 in "1" - Aspirating 100.0 uL from well D1 in "1" at 1 speed - Dispensing 100.0 uL into well D2 in "1" - Aspirating 100.0 uL from well E1 in "1" at 1 speed - Dispensing 100.0 uL into well E2 in "1" - Aspirating 100.0 uL from well F1 in "1" at 1 speed - Dispensing 100.0 uL into well F2 in "1" - Aspirating 100.0 uL from well G1 in "1" at 1 speed - Dispensing 100.0 uL into well G2 in "1" - Aspirating 100.0 uL from well H1 in "1" at 1 speed - Dispensing 100.0 uL into well H2 in "1" - Dropping tip well A1 in "12" - -.. versionadded:: 2.0 - -One to Many ------------- - -You can transfer from a single source to multiple destinations, and the other way around (many sources to one destination). - -.. code-block:: python - - pipette.transfer(100, plate.wells_by_name()['A1'], plate.columns_by_name()['2']) - - -will have the steps... - -.. code-block:: python - - Transferring 100 from well A1 in "1" to wells A2...H2 in "1" - Picking up tip well A1 in "2" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well A2 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well B2 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well C2 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well D2 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well E2 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well F2 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well G2 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well H2 in "1" - Dropping tip well A1 in "12" - -.. versionadded:: 2.0 - -List of Volumes ---------------- - -Instead of applying a single volume amount to all source/destination wells, you can instead pass a list of volumes. - -.. code-block:: python - - pipette.transfer( - [20, 40, 60], - plate['A1'], - [plate.wells_by_name()[well_name] for well_name in ['B1', 'B2', 'B3']]) - - -will have the steps... - -.. code-block:: python - - Transferring [20, 40, 60] from well A1 in "1" to wells B1...B3 in "1" - Picking up tip well A1 in "2" - Aspirating 20.0 uL from well A1 in "1" at 1 speed - Dispensing 20.0 uL into well B1 in "1" - Aspirating 40.0 uL from well A1 in "1" at 1 speed - Dispensing 40.0 uL into well B2 in "1" - Aspirating 60.0 uL from well A1 in "1" at 1 speed - Dispensing 60.0 uL into well B3 in "1" - Dropping tip well A1 in "12" - -Skipping Wells -++++++++++++++ -If you only wish to transfer to certain wells from a column, you -can use a list of volumes to skip over certain wells by setting the volume to zero. - -.. code-block:: python - - pipette.transfer( - [20, 0, 60], - plate['A1'], - [plate.wells_by_name()[well_name] for well_name in ['B1', 'B2', 'B3']]) - -will have the steps... - -.. code-block:: python - - Transferring [20, 40, 60] from well A1 in "1" to wells B1...B3 in "1" - Picking up tip well A1 in "2" - Aspirating 20.0 uL from well A1 in "1" at 1 speed - Dispensing 20.0 uL into well B1 in "1" - Aspirating 60.0 uL from well A1 in "1" at 1 speed - Dispensing 60.0 uL into well B3 in "1" - Dropping tip well A1 in "12" - - -.. versionadded:: 2.0 - -********************** - -Distribute and Consolidate -========================== - -:py:meth:`.InstrumentContext.distribute` and :py:meth:`.InstrumentContext.consolidate` are similar to :py:meth:`.InstrumentContext.transfer`, but optimized for specific uses. :py:meth:`.InstrumentContext.distribute` is optimized for taking a large volume from a single (or a small number) of source wells, and distributing it to many smaller volumes in destination wells. Rather than using one-to-one transfers, it dispense many times for each aspirate. :py:meth:`.InstrumentContext.consolidate` is optimized for taking small volumes from many source wells and consolidating them into one (or a small number) of destination wells, aspirating many times for each dispense. - -Consolidate ------------ - -Volumes going to the same destination well are combined within the same tip, so that multiple aspirates can be combined to a single dispense. This is the scenario described by the :ref:`consolidate-image` graphic. - -.. code-block:: python - - pipette.consolidate(30, plate.columns_by_name()['2'], plate.wells_by_name()['A1']) - -will have the steps... - -.. code-block:: python - - Consolidating 30 from wells A2...H2 in "1" to well A1 in "1" - Transferring 30 from wells A2...H2 in "1" to well A1 in "1" - Picking up tip well A1 in "2" - Aspirating 30.0 uL from well A2 in "1" at 1 speed - Aspirating 30.0 uL from well B2 in "1" at 1 speed - Aspirating 30.0 uL from well C2 in "1" at 1 speed - Aspirating 30.0 uL from well D2 in "1" at 1 speed - Aspirating 30.0 uL from well E2 in "1" at 1 speed - Aspirating 30.0 uL from well F2 in "1" at 1 speed - Aspirating 30.0 uL from well G2 in "1" at 1 speed - Aspirating 30.0 uL from well H2 in "1" at 1 speed - Dispensing 240.0 uL into well A1 in "1" - Dropping tip well A1 in "12" - -If there are multiple destination wells, the pipette will not combine the transfers - it will aspirate from one source, dispense into the target, then aspirate from the other source. - -.. code-block:: python - - pipette.consolidate( - 30, - plate.columns_by_name()['1'], - [plate.wells_by_name()[well_name] for well_name in ['A1', 'A2']]) - - -will have the steps... - -.. code-block:: python - - Consolidating 30 from wells A1...H1 in "1" to wells A1...A2 in "1" - Transferring 30 from wells A1...H1 in "1" to wells A1...A2 in "1" - Picking up tip well A1 in "2" - Aspirating 30.0 uL from well A1 in "1" at 1 speed - Aspirating 30.0 uL from well B1 in "1" at 1 speed - Aspirating 30.0 uL from well C1 in "1" at 1 speed - Aspirating 30.0 uL from well D1 in "1" at 1 speed - Dispensing 120.0 uL into well A1 in "1" - Aspirating 30.0 uL from well E1 in "1" at 1 speed - Aspirating 30.0 uL from well F1 in "1" at 1 speed - Aspirating 30.0 uL from well G1 in "1" at 1 speed - Aspirating 30.0 uL from well H1 in "1" at 1 speed - Dispensing 120.0 uL into well A2 in "1" - Dropping tip well A1 in "12" - - -.. versionadded:: 2.0 - -Distribute ----------- - -Volumes from the same source well are combined within the same tip, so that one aspirate can provide for multiple dispenses. This is the scenario in the :ref:`distribute-image` graphic. - -.. code-block:: python - - pipette.distribute(55, plate.wells_by_name()['A1'], plate.rows_by_name()['A']) - - -will have the steps... - -.. code-block:: python - - Distributing 55 from well A1 in "1" to wells A1...A12 in "1" - Transferring 55 from well A1 in "1" to wells A1...A12 in "1" - Picking up tip well A1 in "2" - Aspirating 250.0 uL from well A1 in "1" at 1 speed - Dispensing 55.0 uL into well A1 in "1" - Dispensing 55.0 uL into well A2 in "1" - Dispensing 55.0 uL into well A3 in "1" - Dispensing 55.0 uL into well A4 in "1" - Blowing out at well A1 in "12" - Aspirating 250.0 uL from well A1 in "1" at 1 speed - Dispensing 55.0 uL into well A5 in "1" - Dispensing 55.0 uL into well A6 in "1" - Dispensing 55.0 uL into well A7 in "1" - Dispensing 55.0 uL into well A8 in "1" - Blowing out at well A1 in "12" - Aspirating 250.0 uL from well A1 in "1" at 1 speed - Dispensing 55.0 uL into well A9 in "1" - Dispensing 55.0 uL into well A10 in "1" - Dispensing 55.0 uL into well A11 in "1" - Dispensing 55.0 uL into well A12 in "1" - Blowing out at well A1 in "12" - Dropping tip well A1 in "12" - -The pipette will aspirate more liquid than it intends to dispense by the minimum volume of the pipette. This is called the ``disposal_volume``, and can be specified in the call to ``distribute``. - -If there are multiple source wells, the pipette will never combine their volumes into the same tip. - -.. code-block:: python - - pipette.distribute( - 30, - [plate.wells_by_name()[well_name] for well_name in ['A1', 'A2']], - plate.rows()['A']) - -will have the steps... - -.. code-block:: python - - Distributing 30 from wells A1...A2 in "1" to wells A1...A12 in "1" - Transferring 30 from wells A1...A2 in "1" to wells A1...A12 in "1" - Picking up tip well A1 in "2" - Aspirating 210.0 uL from well A1 in "1" at 1 speed - Dispensing 30.0 uL into well A1 in "1" - Dispensing 30.0 uL into well A2 in "1" - Dispensing 30.0 uL into well A3 in "1" - Dispensing 30.0 uL into well A4 in "1" - Dispensing 30.0 uL into well A5 in "1" - Dispensing 30.0 uL into well A6 in "1" - Blowing out at well A1 in "12" - Aspirating 210.0 uL from well A2 in "1" at 1 speed - Dispensing 30.0 uL into well A7 in "1" - Dispensing 30.0 uL into well A8 in "1" - Dispensing 30.0 uL into well A9 in "1" - Dispensing 30.0 uL into well A10 in "1" - Dispensing 30.0 uL into well A11 in "1" - Dispensing 30.0 uL into well A12 in "1" - Blowing out at well A1 in "12" - Dropping tip well A1 in "12" - -.. versionadded:: 2.0 - - -.. _distribute-consolidate-volume-list: - -List of Volumes ---------------- - -Instead of applying a single volume amount to all source/destination wells, you can instead pass a list of volumes to either -consolidate or distribute. - -For example, this distribute command - -.. code-block:: python - - pipette.distribute( - [20, 40, 60], - plate['A1'], - [plate.wells_by_name()[well_name] for well_name in ['B1', 'B2', 'B3']]) - - -will have the steps... - -.. code-block:: python - - Distributing [20, 40, 60] from well A1 in "1" to wells B1...B3 in "1" - Picking up tip well A1 in "2" - Aspirating 150.0 uL from well A1 in "1" at 1 speed - Dispensing 20.0 uL into well B1 in "1" - Dispensing 40.0 uL into well B2 in "1" - Dispensing 60.0 uL into well B3 in "1" - Blowing out in well A1 in "12" - Dropping tip well A1 in "12" - -and this consolidate command - -.. code-block:: python - - pipette.consolidate( - [20, 40, 60], - [plate.wells_by_name()[well_name] for well_name in ['B1', 'B2', 'B3']], - plate['A1']) - -will have the steps... - -.. code-block:: python - - Consolidating [20, 40, 60] from wells B1...B3 in "1" to well A1 in "1" - Picking up tip well A1 in "2" - Aspirating 20.0 uL from well B1 in "1" - Aspirating 40.0 uL into well B2 in "1" - Aspirating 60.0 uL into well B3 in "1" - Dispensing 120.0 uL into well A1 in "1" - Dropping tip well A1 in "12" - - -Skipping Wells -++++++++++++++ - -If you only wish to distribute or consolidate certain wells from a column, you -can use a list of volumes to skip over certain wells by setting the volume to zero. - -.. code-block:: python - - pipette.distribute( - [20, 40, 60, 0, 0, 0, 50, 100], - plate['A1'], - plate.columns_by_name()['2']) - -will have the steps... - -.. code-block:: python - - Distributing [20, 40, 60] from well A1 in "1" to column 2 in "1" - Picking up tip well A1 in "2" - Aspirating 300.0 uL from well A1 in "1" at 1 speed - Dispensing 20.0 uL into well A2 in "1" - Dispensing 40.0 uL into well B2 in "1" - Dispensing 60.0 uL into well C2 in "1" - Dispensing 50.0 uL into well G2 in "1" - Dispensing 100.0 uL into well H2 in "1" - Blowing out in well A1 in "12" - Dropping tip well A1 in "12" - -.. warning:: - - This functionality is only available in Python API Version 2.8 or later. - - -.. versionadded:: 2.8 - -Order of Operations In Complex Commands -======================================= - -Parameters to complex commands add behaviors to the generated complex command in a specific order which cannot be changed. Specifically, advanced commands execute their atomic commands in this order: - -1. Tip logic -2. Mix at source location -3. Aspirate + Any disposal volume -4. Touch tip -5. Air gap -6. Dispense -7. Touch tip - -<------Repeat above for all wells------> - -8. Empty disposal volume into trash, if any -9. Blow Out - -Notice how blow out only occurs after getting rid of disposal volume. If you want blow out to occu after every dispense, you should not include a disposal volume. - -********************** - -.. _complex_params: - -Complex Liquid Handling Parameters -================================== - -Below are some examples of the parameters described in the :ref:`params_table` table. - -``new_tip`` ------------ - -This parameter handles tip logic. You have options of the strings ``'always'``, ``'once'`` and ``'never'``. The default for every complex command is ``'once'``. - -If you want to avoid cross-contamination and increase accuracy, you should set this parameter to ``'always'``. - -.. versionadded:: 2.0 - -Always Get a New Tip -++++++++++++++++++++ - -Transfer commands will by default use the same tip for each well, then finally drop it in the trash once finished. - -The pipette can optionally get a new tip at the beginning of each aspirate, to help avoid cross contamination. - -.. code-block:: python - - pipette.transfer( - 100, - [plate.wells_by_name()[well_name] for well_name in ['A1', 'A2', 'A3']], - [plate.wells_by_name()[well_name] for well_name in ['B1', 'B2', 'B3']], - new_tip='always') # always pick up a new tip - - -will have the steps... - -.. code-block:: python - - Transferring 100 from wells A1...A3 in "1" to wells B1...B3 in "1" - Picking up tip well A1 in "2" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well B1 in "1" - Dropping tip well A1 in "12" - Picking up tip well B1 in "2" - Aspirating 100.0 uL from well A2 in "1" at 1 speed - Dispensing 100.0 uL into well B2 in "1" - Dropping tip well A1 in "12" - Picking up tip well C1 in "2" - Aspirating 100.0 uL from well A3 in "1" at 1 speed - Dispensing 100.0 uL into well B3 in "1" - Dropping tip well A1 in "12" - - -Never Get a New Tip -+++++++++++++++++++ - -For scenarios where you instead are calling ``pick_up_tip()`` and ``drop_tip()`` elsewhere in your protocol, the transfer command can ignore picking up or dropping tips. - -.. code-block:: python - - pipette.pick_up_tip() - ... - pipette.transfer( - 100, - [plate.wells_by_name()[well_name] for well_name in ['A1', 'A2', 'A3']], - [plate.wells_by_name()[well_name] for well_name in ['B1', 'B2', 'B3']], - new_tip='never') # never pick up or drop a tip - ... - pipette.drop_tip() - - -will have the steps... - -.. code-block:: python - - Picking up tip well A1 in "2" - ... - Transferring 100 from wells A1...A3 in "1" to wells B1...B3 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well B1 in "1" - Aspirating 100.0 uL from well A2 in "1" at 1 speed - Dispensing 100.0 uL into well B2 in "1" - Aspirating 100.0 uL from well A3 in "1" at 1 speed - Dispensing 100.0 uL into well B3 in "1" - ... - Dropping tip well A1 in "12" - - -Use One Tip -+++++++++++ - -The default behavior of complex commands is to use one tip: - -.. code-block:: python - - pipette.transfer( - 100, - [plate.wells_by_name()[well_name] for well_name in ['A1', 'A2', 'A3']], - [plate.wells_by_name()[well_name] for well_name in ['B1', 'B2', 'B3']], - new_tip='once') # use one tip (default behavior) - -will have the steps... - -.. code-block:: python - - Picking up tip well A1 in "2" - Transferring 100 from wells A1...A3 in "1" to wells B1...B3 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well B1 in "1" - Aspirating 100.0 uL from well A2 in "1" at 1 speed - Dispensing 100.0 uL into well B2 in "1" - Aspirating 100.0 uL from well A3 in "1" at 1 speed - Dispensing 100.0 uL into well B3 in "1" - Dropping tip well A1 in "12" - -``trash`` ---------- - -By default, compelx commands will drop the pipette's tips in the trash container. However, if you wish to instead return the tip to its tip rack, you can set ``trash=False``. - -.. code-block:: python - - pipette.transfer( - 100, - plate['A1'], - plate['B1'], - trash=False) # do not trash tip - - -will have the steps... - -.. code-block:: python - - Transferring 100 from well A1 in "1" to well B1 in "1" - Picking up tip well A1 in "2" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well B1 in "1" - Returning tip - Dropping tip well A1 in "2" - -.. versionadded:: 2.0 - -``touch_tip`` -------------- - -A :ref:`touch-tip` can be performed after every aspirate and dispense by setting ``touch_tip=True``. - -.. code-block:: python - - pipette.transfer( - 100, - plate['A1'], - plate['A2'], - touch_tip=True) # touch tip to each well's edge - - -will have the steps... - -.. code-block:: python - - Transferring 100 from well A1 in "1" to well A2 in "1" - Picking up tip well A1 in "2" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Touching tip - Dispensing 100.0 uL into well A2 in "1" - Touching tip - Dropping tip well A1 in "12" - -.. versionadded:: 2.0 - -``blow_out`` ------------- - -A :ref:`blow-out` into the trash can be performed after every dispense that leaves the tip empty by setting ``blow_out=True``. - -.. code-block:: python - - pipette.transfer( - 100, - plate['A1'], - plate['A2'], - blow_out=True) # blow out droplets when tip is empty - - -will have the steps... - -.. code-block:: python - - Transferring 100 from well A1 in "1" to well A2 in "1" - Picking up tip well A1 in "2" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well A2 in "1" - Blowing out - Dropping tip well A1 in "12" - -.. versionadded:: 2.0 - -The robot will automatically dispense any left over liquid that is *not* from using ``disposal_volume`` into -the source well of your transfer function. - -.. code-block:: python - - pipette.pick_up_tip() - pipette.aspirate(10, plate['A1']) - pipette.transfer( - 100, - plate['A1'], - plate['A2'], - blow_out=True, # blow out droplets into the source well (A1 of "plate") - new_tip='never') - -.. versionadded:: 2.8 - - -``blowout_location`` --------------------- - -Starting in Python API Version 2.8 and above, you can specify the well type you would like your pipette to blow out in. -Specifying a location will override any defaults from the ``blow_out`` argument. - -The code below is the same default behavior you would see utilizing ``blow_out=True`` only. It will blow out for -every transfer into the trash. - -.. code-block:: python - - pipette.transfer( - 100, - plate['A1'], - plate['A2'], - blow_out=True, - blowout_location='trash') # blow out droplets into the trash - -.. versionadded:: 2.8 - -The same is true even if you have extra liquid left in your tip shown below. - -.. code-block:: python - - pipette.pick_up_tip() - pipette.aspirate(10, plate['A1']) - pipette.transfer( - 100, - plate['A1'], - plate['A2'], - blow_out=True, - blowout_location='trash', # blow out droplets into the trash - new_tip='never') - -.. versionadded:: 2.8 - -If you wish to blow out in the source or destination well you can do so by specifying the location as either ``source well`` or ``destination well``. -For example, to blow out in the destination well you can do the following: - -.. code-block:: python - - pipette.transfer( - 100, - plate['A1'], - plate.wells(), - blow_out=True, - blowout_location='destination well') # blow out droplets into each destination well (this will blow out in wells `A1`, `B1`, `C1`..etc) - -.. versionadded:: 2.8 - - -.. note:: - - You *must* specify ``blow_out=True`` in order to utilize the new argument ``blowout_location`` - - - -``mix_before``, ``mix_after`` ------------------------------ - -A :ref:`mix` can be performed before every aspirate by setting ``mix_before=``, and after every dispense by setting ``mix_after=``. The value of ``mix_before=`` or ``mix_after=`` must be a tuple; the first value is the number of repetitions, the second value is the amount of liquid to mix. - -.. code-block:: python - - pipette.transfer( - 100, - plate['A1'], - plate['A2'], - mix_before=(2, 50), # mix 2 times with 50uL before aspirating - mix_after=(3, 75)) # mix 3 times with 75uL after dispensing - - -will have the steps... - -.. code-block:: python - - Transferring 100 from well A1 in "1" to well A2 in "1" - Picking up tip well A1 in "2" - Mixing 2 times with a volume of 50ul - Aspirating 50 uL from well A1 in "1" at 1.0 speed - Dispensing 50 uL into well A1 in "1" - Aspirating 50 uL from well A1 in "1" at 1.0 speed - Dispensing 50 uL into well A1 in "1" - Aspirating 100.0 uL from well A1 in "1" at 1 speed - Dispensing 100.0 uL into well A2 in "1" - Mixing 3 times with a volume of 75ul - Aspirating 75 uL from well A2 in "1" at 1.0 speed - Dispensing 75.0 uL into well A2 in "1" - Aspirating 75 uL from well A2 in "1" at 1.0 speed - Dispensing 75.0 uL into well A2 in "1" - Aspirating 75 uL from well A2 in "1" at 1.0 speed - Dispensing 75.0 uL into well A2 in "1" - Dropping tip well A1 in "12" - -.. versionadded:: 2.0 - -``air_gap`` ------------ - -An :ref:`air-gap` can be performed after every aspirate by setting ``air_gap=volume``, where the value is the volume of air in µL to aspirate after aspirating the liquid. The entire volume in the tip, air gap and the liquid volume, will be dispensed all at once at the destination specified in the complex command. - -.. code-block:: python - - pipette.transfer( - 100, - plate['A1'], - plate['A2'], - air_gap=20) # add 20uL of air after each aspirate - - -will have the steps... - -.. code-block:: python - - Transferring 100 from well A1 in "1" to well A2 in "1" - Picking up tip well A1 in "2" - Aspirating 100.0 uL from well A1 in "1" at 1.0 speed - Air gap - Aspirating 20 uL from well A1 in "1" at 1.0 speed - Dispensing 120.0 uL into well A2 in "1" - Dropping tip well A1 in "12" - -.. versionadded:: 2.0 - -``disposal_volume`` -------------------- - -When dispensing multiple times from the same tip in :py:meth:`.InstrumentContext.distribute`, it is recommended to aspirate an extra amount of liquid to be disposed of after distributing. This added ``disposal_volume`` can be set as an optional argument. - -The default disposal volume is the pipette's minimum volume (see :ref:`Defaults`), which will be blown out at the trash after the dispenses. - -.. code-block:: python - - pipette.distribute( - 30, - [plate.wells_by_name()[well_name] for well_name in ['A1', 'A2']], - plate.columns_by_name()['2'], - disposal_volume=60) # include extra liquid to make dispenses more accurate, 20% of total volume - - -will have the steps... - -.. code-block:: python - - Distributing 30 from wells A1...A2 in "1" to wells A2...H2 in "1" - Transferring 30 from wells A1...A2 in "1" to wells A2...H2 in "1" - Picking up tip well A1 in "2" - Aspirating 130.0 uL from well A1 in "1" at 1 speed - Dispensing 30.0 uL into well A2 in "1" - Dispensing 30.0 uL into well B2 in "1" - Dispensing 30.0 uL into well C2 in "1" - Dispensing 30.0 uL into well D2 in "1" - Blowing out at well A1 in "12" - Aspirating 130.0 uL from well A2 in "1" at 1 speed - Dispensing 30.0 uL into well E2 in "1" - Dispensing 30.0 uL into well F2 in "1" - Dispensing 30.0 uL into well G2 in "1" - Dispensing 30.0 uL into well H2 in "1" - Blowing out at well A1 in "12" - Dropping tip well A1 in "12" - - -See this image for example, - -.. image:: ../img/complex_commands/distribute_illustration_tip.png - :scale: 50 % - :align: center - -.. versionadded:: 2.0 + - :ref:`complex-source-dest`: Which wells complex commands aspirate from and dispense to. + - :ref:`complex-command-order`: The order of basic commands that are part of a complex commmand. + - :ref:`complex_params`: Additional keyword arguments that affect complex command behavior. + +Code samples throughout these pages assume that you've loaded the pipettes and labware from the :ref:`basic protocol template `. diff --git a/api/docs/v2/new_examples.rst b/api/docs/v2/new_examples.rst index ea1af4ee642..c68a39fcbf0 100644 --- a/api/docs/v2/new_examples.rst +++ b/api/docs/v2/new_examples.rst @@ -2,187 +2,585 @@ .. _new-examples: -######## -Examples -######## - -All examples on this page use a ``'corning_96_wellplate_360ul_flat'`` (`an ANSI standard 96-well plate `_) in slot 1, and two ``'opentrons_96_tiprack_300ul'`` (`the Opentrons standard 300 µL tiprack `_) in slots 2 and 3. They also require a P300 Single attached to the right mount. Some examples also use a ``'usascientific_12_reservoir_22ml'`` (`a USA Scientific 12-row reservoir `_) in slot 4. - -****************************** - -************** -Basic Transfer -************** - -Moving 100 µL from one well to another: - -.. code-block:: python - :substitutions: +***************** +Protocol Examples +***************** - from opentrons import protocol_api +This page provides simple, ready-made protocols for Flex and OT-2. Feel free to copy and modify these examples to create unique protocols that help automate your laboratory workflows. Also, experimenting with these protocols is another way to build upon the skills you've learned from working through the :ref:`tutorial `. Try adding different hardware, labware, and commands to a sample protocol and test its validity after importing it into the Opentrons App. - metadata = {'apiLevel': '|apiLevel|'} +Using These Protocols +===================== + +These sample protocols are designed for anyone using an Opentrons Flex or OT-2 liquid handling robot. For our users with little to no Python experience, we’ve taken some liberties with the syntax and structure of the code to make it easier to understand. For example, we’ve formatted the samples with line breaks to show method arguments clearly and to avoid horizontal scrolling. Additionally, the methods use `named arguments `_ instead of positional arguments. For example:: + + # This code uses named arguments + tiprack_1 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D2') + + # This code uses positional arguments + tiprack_1 = protocol.load_labware('opentrons_flex_96_tiprack_200ul','D2') + +Both examples instantiate the variable ``tiprack_1`` with a Flex tip rack, but the former is more explicit. It shows the parameter name and its value together (e.g. ``location='D2'``), which may be helpful when you're unsure about what's going on in a protocol code sample. + +Python developers with more experience should feel free to ignore the code styling used here and work with these examples as you like. + +Instruments and Labware +======================= + +The sample protocols all use the following pipettes: + +* Flex 1-Channel Pipette (5–1000 µL). The API load name for this pipette is ``flex_1channel_1000``. +* P300 Single-Channel GEN2 pipette for the OT-2. The API load name for this pipette is ``p300_single_gen2``. + +They also use the labware listed below: + +.. list-table:: + :header-rows: 1 + + * - Labware type + - Labware name + - API load name + * - Reservoir + - USA Scientific 12-Well Reservoir 22 mL + - ``usascientific_12_reservoir_22ml`` + * - Well plate + - Corning 96-Well Plate 360 µL Flat + - ``corning_96_wellplate_360ul_flat`` + * - Flex tip rack + - Opentrons Flex 96 Tip Rack 200 µL + - ``opentrons_flex_96_tiprack_200ul`` + * - OT-2 tip rack + - Opentrons 96 Tip Rack 300 µL + - ``opentrons_96_tiprack_300ul`` + +.. _protocol-template: + +Protocol Template +================= + +This code only loads the instruments and labware listed above, and performs no other actions. Many code snippets from elsewhere in the documentation will run without modification when added at the bottom of this template. You can also use it to start writing and testing your own code. + +.. tabs:: + + .. tab:: Flex + + .. code-block:: python + :substitutions: - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 1) - tiprack_1 = protocol.load_labware('opentrons_96_tiprack_300ul', 2) - p300 = protocol.load_instrument('p300_single', 'right', tip_racks=[tiprack_1]) + from opentrons import protocol_api - p300.transfer(100, plate['A1'], plate['B1']) + requirements = {"robotType": "Flex", "apiLevel": "|apiLevel|"} + def run(protocol: protocol_api.ProtocolContext): + # load tip rack in deck slot D3 + tiprack = protocol.load_labware( + load_name="opentrons_flex_96_tiprack_1000ul", location="D3" + ) + # attach pipette to left mount + pipette = protocol.load_instrument( + instrument_name="flex_1channel_1000", + mount="left", + tip_racks=[tiprack] + ) + # load well plate in deck slot D2 + plate = protocol.load_labware( + load_name="corning_96_wellplate_360ul_flat", location="D2" + ) + # load reservoir in deck slot D1 + reservoir = protocol.load_labware( + load_name="usascientific_12_reservoir_22ml", location="D1" + ) + # Put protocol commands here + + .. tab:: OT-2 + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api -This accomplishes the same thing as the following basic commands: + metadata = {'apiLevel': '2.14'} -.. code-block:: python - :substitutions: + def run(protocol: protocol_api.ProtocolContext): + # load tip rack in deck slot 3 + tiprack = protocol.load_labware( + load_name="opentrons_96_tiprack_300ul", location=3 + ) + # attach pipette to left mount + pipette = protocol.load_instrument( + instrument_name="p300_single_gen2", + mount="left", + tip_racks=[tiprack] + ) + # load well plate in deck slot 2 + plate = protocol.load_labware( + load_name="corning_96_wellplate_360ul_flat", location=2 + ) + # load reservoir in deck slot 1 + reservoir = protocol.load_labware( + load_name="usascientific_12_reservoir_22ml", location=1 + ) + # Put protocol commands here + +Transferring Liquids +==================== + +These protocols demonstrate how to move 100 µL of liquid from one well to another. + +Basic Method +------------ + +This protocol uses some :ref:`building block commands ` to tell the robot, explicitly, where to go to aspirate and dispense liquid. These commands include the :py:meth:`~.InstrumentContext.pick_up_tip`, :py:meth:`~.InstrumentContext.aspirate`, and :py:meth:`~.InstrumentContext.dispense` methods. + +.. tabs:: + + .. tab:: Flex + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + requirements = {'robotType': 'Flex', 'apiLevel':'|apiLevel|'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location='D1') + tiprack_1 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D2') + pipette_1 = protocol.load_instrument( + instrument_name='flex_1channel_1000', + mount='left', + tip_racks=[tiprack_1]) + + pipette_1.pick_up_tip() + pipette_1.aspirate(100, plate['A1']) + pipette_1.dispense(100, plate['B1']) + pipette_1.drop_tip() + + .. tab:: OT-2 + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + metadata = {'apiLevel': '2.14'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location=1) + tiprack_1 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=2) + p300 = protocol.load_instrument( + instrument_name='p300_single', + mount='left', + tip_racks=[tiprack_1]) + + p300.pick_up_tip() + p300.aspirate(100, plate['A1']) + p300.dispense(100, plate['B1']) + p300.drop_tip() + +Advanced Method +--------------- + +This protocol accomplishes the same thing as the previous example, but does it a little more efficiently. Notice how it uses the :py:meth:`.InstrumentContext.transfer` method to move liquid between well plates. The source and destination well arguments (e.g., ``plate['A1'], plate['B1']``) are part of ``transfer()`` method parameters. You don't need separate calls to ``aspirate`` or ``dispense`` here. + +.. tabs:: + + .. tab:: Flex + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + requirements = {'robotType': 'Flex', 'apiLevel': '|apiLevel|'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location='D1') + tiprack_1 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D2') + pipette_1 = protocol.load_instrument( + instrument_name='flex_1channel_1000', + mount='left', + tip_racks=[tiprack_1]) + # transfer 100 µL from well A1 to well B1 + pipette_1.transfer(100, plate['A1'], plate['B1']) + + .. tab:: OT-2 - from opentrons import protocol_api + .. code-block:: python + :substitutions: - metadata = {'apiLevel': '|apiLevel|'} + from opentrons import protocol_api - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 1) - tiprack_1 = protocol.load_labware('opentrons_96_tiprack_300ul', 2) - p300 = protocol.load_instrument('p300_single', 'right', tip_racks=[tiprack_1]) + metadata = {'apiLevel': '2.14'} - p300.pick_up_tip() - p300.aspirate(100, plate['A1']) - p300.dispense(100, plate['B1']) - p300.drop_tip() + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location=1) + tiprack_1 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=2) + p300 = protocol.load_instrument( + instrument_name='p300_single', + mount='left', + tip_racks=[tiprack_1]) + # transfer 100 µL from well A1 to well B1 + p300.transfer(100, plate['A1'], plate['B1']) -****************************** -***** Loops -***** - -Loops in Python allow your protocol to perform many actions, or act upon many wells, all within just a few lines. The below example loops through the numbers ``0`` to ``7``, and uses that loop's current value to transfer from all wells in a reservoir to each row of a plate: - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} +===== + +In Python, a loop is an instruction that keeps repeating an action until a specific condition is met. + +When used in a protocol, loops automate repetitive steps such as aspirating and dispensing liquids from a reservoir to a a range of wells, or all the wells, in a well plate. For example, this code sample loops through the numbers 0 to 7, and uses the loop's current value to transfer liquid from all the wells in a reservoir to all the wells in a 96-well plate. + +.. tabs:: + + .. tab:: Flex + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + requirements = {'robotType': 'Flex', 'apiLevel':'|apiLevel|'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location='D1') + tiprack_1 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D2') + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location='D3') + pipette_1 = protocol.load_instrument( + instrument_name='flex_1channel_1000', + mount='left', + tip_racks=[tiprack_1]) + + # distribute 20 µL from reservoir:A1 -> plate:row:1 + # distribute 20 µL from reservoir:A2 -> plate:row:2 + # etc... + # range() starts at 0 and stops before 8, creating a range of 0-7 + for i in range(8): + pipette_1.distribute(200, reservoir.wells()[i], plate.rows()[i]) + + .. tab:: OT-2 + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + metadata = {'apiLevel': '2.14'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location=1) + tiprack_1 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=2) + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location=4) + p300 = protocol.load_instrument( + instrument_name='p300_single', + mount='left', + tip_racks=[tiprack_1]) + + # distribute 20 µL from reservoir:A1 -> plate:row:1 + # distribute 20 µL from reservoir:A2 -> plate:row:2 + # etc... + # range() starts at 0 and stops before 8, creating a range of 0-7 + for i in range(8): + p300.distribute(200, reservoir.wells()[i], plate.rows()[i]) + +Notice here how Python's :py:class:`range` class (e.g., ``range(8)``) determines how many times the code loops. Also, in Python, a range of numbers is *exclusive* of the end value and counting starts at 0, not 1. For the Corning 96-well plate used here, this means well A1=0, B1=1, C1=2, and so on to the last well in the row, which is H1=7. - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 1) - tiprack_1 = protocol.load_labware('opentrons_96_tiprack_300ul', 2) - reservoir = protocol.load_labware('usascientific_12_reservoir_22ml', 4) - p300 = protocol.load_instrument('p300_single', 'right', tip_racks=[tiprack_1]) - # distribute 20uL from reservoir:A1 -> plate:row:1 - # distribute 20uL from reservoir:A2 -> plate:row:2 - # etc... - - # range() starts at 0 and stops before 8, creating a range of 0-7 - for i in range(8): - p300.distribute(200, reservoir.wells()[i], plate.rows()[i]) - -****************************** - -***************** Multiple Air Gaps -***************** +================= -The OT-2 pipettes can do some things that a human cannot do with a pipette, like accurately alternate between aspirating and creating air gaps within the same tip. The below example will aspirate from the first five wells in the reservoir, while creating an air gap between each sample. +Opentrons electronic pipettes can do some things that a human cannot do with a pipette, like accurately alternate between liquid and air aspirations that create gaps within the same tip. The protocol shown below shows you how to aspirate from the first five wells in the reservoir and create an air gap between each sample. -.. code-block:: python - :substitutions: +.. tabs:: - from opentrons import protocol_api + .. tab:: Flex - metadata = {'apiLevel': '|apiLevel|'} + .. code-block:: python + :substitutions: - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 1) - tiprack_1 = protocol.load_labware('opentrons_96_tiprack_300ul', 2) - reservoir = protocol.load_labware('usascientific_12_reservoir_22ml', 4) - p300 = protocol.load_instrument('p300_single', 'right', tip_racks=[tiprack_1]) + from opentrons import protocol_api - p300.pick_up_tip() + requirements = {'robotType': 'Flex', 'apiLevel':'|apiLevel|'} - for well in reservoir.wells()[:4]: - p300.aspirate(35, well) - p300.air_gap(10) - - p300.dispense(225, plate['A1']) + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location='D1') + tiprack_1 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D2') + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location='D3') + pipette_1 = protocol.load_instrument( + instrument_name='flex_1channel_1000', + mount='left', + tip_racks=[tiprack_1]) - p300.return_tip() + pipette_1.pick_up_tip() -****************************** + # aspirate from the first 5 wells + for well in reservoir.wells()[:4]: + pipette_1.aspirate(volume=35, location=well) + pipette_1.air_gap(10) + + pipette_1.dispense(225, plate['A1']) -******** -Dilution -******** + pipette_1.return_tip() -This example first spreads a diluent to all wells of a plate. It then dilutes 8 samples from the reservoir across the 8 columns of the plate. + .. tab:: OT-2 -.. code-block:: python - :substitutions: + .. code-block:: python + :substitutions: - from opentrons import protocol_api + from opentrons import protocol_api - metadata = {'apiLevel': '|apiLevel|'} + metadata = {'apiLevel': '2.14'} - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 1) - tiprack_1 = protocol.load_labware('opentrons_96_tiprack_300ul', 2) - tiprack_2 = protocol.load_labware('opentrons_96_tiprack_300ul', 3) - reservoir = protocol.load_labware('usascientific_12_reservoir_22ml', 4) - p300 = protocol.load_instrument('p300_single', 'right', tip_racks=[tiprack_1, tiprack_2]) - p300.distribute(50, reservoir['A12'], plate.wells()) # dilutent + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location=1) + tiprack_1 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=2) + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location=3) + p300 = protocol.load_instrument( + instrument_name='p300_single', + mount='right', + tip_racks=[tiprack_1]) - # loop through each row - for i in range(8): + p300.pick_up_tip() - # save the source well and destination column to variables - source = reservoir.wells()[i] - row = plate.rows()[i] + # aspirate from the first 5 wells + for well in reservoir.wells()[:4]: + p300.aspirate(volume=35, location=well) + p300.air_gap(10) + + p300.dispense(225, plate['A1']) - # transfer 30uL of source to first well in column - p300.transfer(30, source, row[0], mix_after=(3, 25)) + p300.return_tip() - # dilute the sample down the column - p300.transfer( - 30, row[:11], row[1:], - mix_after=(3, 25)) +Notice here how Python's :py:class:`slice` functionality (in the code sample as ``[:4]``) lets us select the first five wells of the well plate only. Also, in Python, a range of numbers is *exclusive* of the end value and counting starts at 0, not 1. For the Corning 96-well plate used here, this means well A1=0, B1=1, C1=2, and so on to the last well used, which is E1=4. See also, the :ref:`tutorial-commands` section of the Tutorial. -****************************** +Dilution +======== + +This protocol dispenses diluent to all wells of a Corning 96-well plate. Next, it dilutes 8 samples from the reservoir across all 8 columns of the plate. + +.. tabs:: + + .. tab:: Flex + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + requirements = {'robotType': 'Flex', 'apiLevel': '|apiLevel|'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location='D1') + tiprack_1 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D2') + tiprack_2 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D3') + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location='C1') + pipette_1 = protocol.load_instrument( + instrument_name='flex_1channel_1000', + mount='left', + tip_racks=[tiprack_1, tiprack_2]) + # Dispense diluent + pipette_1.distribute(50, reservoir['A12'], plate.wells()) + + # loop through each row + for i in range(8): + # save the source well and destination column to variables + source = reservoir.wells()[i] + row = plate.rows()[i] + + # transfer 30 µL of source to first well in column + pipette_1.transfer(30, source, row[0], mix_after=(3, 25)) + + # dilute the sample down the column + pipette_1.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) + + .. tab:: OT-2 + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + metadata = {'apiLevel': '2.14'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location=1) + tiprack_1 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=2) + tiprack_2 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=3) + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location=4) + p300 = protocol.load_instrument( + instrument_name='p300_single', + mount='right', + tip_racks=[tiprack_1, tiprack_2]) + # Dispense diluent + p300.distribute(50, reservoir['A12'], plate.wells()) + + # loop through each row + for i in range(8): + # save the source well and destination column to variables + source = reservoir.wells()[i] + source = reservoir.wells()[i] + row = plate.rows()[i] + + # transfer 30 µL of source to first well in column + p300.transfer(30, source, row[0], mix_after=(3, 25)) + + # dilute the sample down the column + p300.transfer( + 30, row[:11], row[1:], + mix_after=(3, 25)) + +Notice here how the code sample loops through the rows and uses slicing to distribute the diluent. For information about these features, see the Loops and Air Gaps examples above. See also, the :ref:`tutorial-commands` section of the Tutorial. -************* Plate Mapping -************* - -This example deposits various volumes of liquids into the same plate of wells and automatically refill the tip volume when it runs out. - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 1) - tiprack_1 = protocol.load_labware('opentrons_96_tiprack_300ul', 2) - tiprack_2 = protocol.load_labware('opentrons_96_tiprack_300ul', 3) - reservoir = protocol.load_labware('usascientific_12_reservoir_22ml', 4) - p300 = protocol.load_instrument('p300_single', 'right', tip_racks=[tiprack_1, tiprack_2]) - - # these uL values were created randomly for this example - water_volumes = [ - 1, 2, 3, 4, 5, 6, 7, 8, - 9, 10, 11, 12, 13, 14, 15, 16, - 17, 18, 19, 20, 21, 22, 23, 24, - 25, 26, 27, 28, 29, 30, 31, 32, - 33, 34, 35, 36, 37, 38, 39, 40, - 41, 42, 43, 44, 45, 46, 47, 48, - 49, 50, 51, 52, 53, 54, 55, 56, - 57, 58, 59, 60, 61, 62, 63, 64, - 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75, 76, 77, 78, 79, 80, - 81, 82, 83, 84, 85, 86, 87, 88, - 89, 90, 91, 92, 93, 94, 95, 96 - ] - - p300.distribute(water_volumes, reservoir['A12'], plate.wells()) +============= + +This protocol dispenses different volumes of liquids to a well plate and automatically refills the pipette when empty. + +.. tabs:: + + .. tab:: Flex + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + + requirements = {'robotType': 'Flex', 'apiLevel': '2.15'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location='D1') + tiprack_1 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D2') + tiprack_2 = protocol.load_labware( + load_name='opentrons_flex_96_tiprack_200ul', + location='D3') + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location='C1') + pipette_1 = protocol.load_instrument( + instrument_name='flex_1channel_1000', + mount='right', + tip_racks=[tiprack_1, tiprack_2]) + + # Volume amounts are for demonstration purposes only + water_volumes = [ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 63, 64, + 65, 66, 67, 68, 69, 70, 71, 72, + 73, 74, 75, 76, 77, 78, 79, 80, + 81, 82, 83, 84, 85, 86, 87, 88, + 89, 90, 91, 92, 93, 94, 95, 96 + ] + + pipette_1.distribute(water_volumes, reservoir['A12'], plate.wells()) + + .. tab:: OT-2 + + .. code-block:: python + :substitutions: + + from opentrons import protocol_api + metadata = {'apiLevel': '2.14'} + + def run(protocol: protocol_api.ProtocolContext): + plate = protocol.load_labware( + load_name='corning_96_wellplate_360ul_flat', + location=1) + tiprack_1 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=2) + tiprack_2 = protocol.load_labware( + load_name='opentrons_96_tiprack_300ul', + location=3) + reservoir = protocol.load_labware( + load_name='usascientific_12_reservoir_22ml', + location=4) + p300 = protocol.load_instrument( + instrument_name='p300_single', + mount='right', + tip_racks=[tiprack_1, tiprack_2]) + + # Volume amounts are for demonstration purposes only + water_volumes = [ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 63, 64, + 65, 66, 67, 68, 69, 70, 71, 72, + 73, 74, 75, 76, 77, 78, 79, 80, + 81, 82, 83, 84, 85, 86, 87, 88, + 89, 90, 91, 92, 93, 94, 95, 96 + ] + + p300.distribute(water_volumes, reservoir['A12'], plate.wells()) diff --git a/api/docs/v2/new_labware.rst b/api/docs/v2/new_labware.rst index a776dc94369..ab4f0683715 100644 --- a/api/docs/v2/new_labware.rst +++ b/api/docs/v2/new_labware.rst @@ -21,6 +21,8 @@ Default Labware Default labware is everything listed in the `Opentrons Labware Library `_. When used in a protocol, your Flex or OT-2 knows how to work with default labware. However, you must first inform the API about the labware you will place on the robot’s deck. Search the library when you’re looking for the API load names of the labware you want to use. You can copy the load names from the library and pass them to the :py:meth:`~.ProtocolContext.load_labware` method in your protocol. +.. _v2-custom-labware: + Custom Labware ============== @@ -109,9 +111,9 @@ Loading Together Use the ``adapter`` argument of ``load_labware()`` to load an adapter at the same time as labware. For example, to load the same 96-well plate and adapter from the previous section at once:: hs_plate = hs_mod.load_labware( - load_name='nest_96_wellplate_200ul_flat', - location='D1', - adapter='opentrons_96_flat_bottom_adapter') + name='nest_96_wellplate_200ul_flat', + adapter='opentrons_96_flat_bottom_adapter' + ) .. versionadded:: 2.15 The ``adapter`` parameter. @@ -149,6 +151,8 @@ The code in this section assumes that ``plate`` is a 24-well plate. For example: plate = protocol.load_labware('corning_24_wellplate_3.4ml_flat', location='D1') +.. _well-accessor-methods: + Accessor Methods ================ @@ -314,7 +318,7 @@ The ``load_liquid`` arguments include a volume amount (``volume=n`` in µL). Thi Well Dimensions *************** -The functions in the :ref:`new-well-access` section above return a single :py:class:`.Well` object or a larger object representing many wells. :py:class:`.Well` objects have attributes that provide information about their physical shape, such as the depth or diameter, as specified in their corresponding labware definition. These properties can be used for different applications, such as calculating the volume of a well or a :ref:`position-relative-labware`. +The functions in the :ref:`new-well-access` section above return a single :py:class:`.Well` object or a larger object representing many wells. :py:class:`.Well` objects have attributes that provide information about their physical shape, such as the depth or diameter, as specified in their corresponding labware definition. These properties can be used for different applications, such as calculating the volume of a well or a :ref:`position relative to the well `. Depth ===== diff --git a/api/docs/v2/new_modules.rst b/api/docs/v2/new_modules.rst index 8483f8e78b4..5bd9912213f 100644 --- a/api/docs/v2/new_modules.rst +++ b/api/docs/v2/new_modules.rst @@ -1,4 +1,4 @@ -:og:description: How to load and work with Opentrons hardware modules in a Python protocol. +:og:description: How to work with powered and unpowered Opentrons hardware modules in a Python protocol. .. _new_modules: diff --git a/api/docs/v2/new_pipette.rst b/api/docs/v2/new_pipette.rst index 2f296316e2b..317bb67a3a7 100644 --- a/api/docs/v2/new_pipette.rst +++ b/api/docs/v2/new_pipette.rst @@ -68,7 +68,7 @@ This code sample loads a P1000 Single-Channel GEN2 pipette in the left mount and from opentrons import protocol_api - metadata = {'apiLevel': '|apiLevel|'} + metadata = {'apiLevel': '2.14'} def run(protocol: protocol_api.ProtocolContext): tiprack1 = protocol.load_labware( @@ -183,6 +183,8 @@ Finally, let's tell the robot to dispense 100 µL into the well plate at locatio The eight pipette channels will only dispense into every other well in the column: B1, D1, F1, H1, J1, L1, N1, and P1. +.. _pipette-tip-racks: + Adding Tip Racks ================ @@ -406,6 +408,8 @@ The following table provides data on the default aspirate, dispense, and blow-ou Additionally, all Flex pipettes have a well bottom clearance of 1 mm for aspirate and dispense actions. +.. _ot2-flow-rates: + OT-2 Pipette Flow Rates ----------------------- diff --git a/api/docs/v2/new_protocol_api.rst b/api/docs/v2/new_protocol_api.rst index f4271f2c35a..f6e35f6653b 100644 --- a/api/docs/v2/new_protocol_api.rst +++ b/api/docs/v2/new_protocol_api.rst @@ -13,7 +13,7 @@ Protocols and Instruments .. autoclass:: opentrons.protocol_api.ProtocolContext :members: - :exclude-members: location_cache, cleanup, clear_commands, commands + :exclude-members: location_cache, cleanup, clear_commands .. autoclass:: opentrons.protocol_api.InstrumentContext :members: diff --git a/api/docs/v2/robot_position.rst b/api/docs/v2/robot_position.rst index a6df78214a7..40c8891f241 100644 --- a/api/docs/v2/robot_position.rst +++ b/api/docs/v2/robot_position.rst @@ -2,33 +2,35 @@ .. _robot-position: -######################## -Position Within the OT-2 -######################## - -Most of the time when executing a protocol, the Python Protocol API's methods take care of determining where the robot needs to go to perform the commands you've given. But sometimes you need to modify how the robot moves in order to achieve the purposes of your protocol. This document will cover the two main ways to define positions — relative to labware, or relative to the entire deck — as well as how to alter the robot's speed or trajectory as it moves to those positions. +************************** +Labware and Deck Positions +************************** +The API automatically determines how the robot needs to move when working with the instruments and labware in your protocol. But sometimes you need direct control over these activities. The API lets you do just that. Specifically, you can control movements relative to labware and deck locations. You can also manage the gantry’s speed and trajectory as it traverses the working area. This document explains how to use API commands to take direct control of the robot and position it exactly where you need it. .. _position-relative-labware: -**************************** + Position Relative to Labware -**************************** +============================ -When you instruct the robot to move to a position on a piece of labware, the exact point in space it moves to is calculated based on the labware definition, the type of action the robot will perform there, and labware offsets for the specific deck slot on your robot. This section describes how each of these components of a position are calculated and methods for modifying them. +When the robot positions itself relative to a piece of labware, where it moves is determined by the labware definition, the actions you want it to perform, and the labware offsets for a specific deck slot. This section describes how these positional components are calculated and how to change them. Top, Bottom, and Center -======================= +----------------------- -Every well on every piece of labware you load has three addressable positions — top, bottom, and center — that are determined by the labware definition and whether the labware is on a module or directly on the deck. You can use these positions as-is or calculate other positions relative to them. +Every well on every piece of labware has three addressable positions: top, bottom, and center. The position is determined by the labware definition and what the labware is loaded on top of. You can use these positions as-is or calculate other positions relative to them. -:py:meth:`.Well.top` returns a position level with the top of the well, centered in both horizontal directions. +Top +^^^^ -.. code-block:: python +Let's look at the :py:meth:`.Well.top` method. It returns a position level with the top of the well, centered in both horizontal directions. - plate['A1'].top() # the top center of the well +.. code-block:: python + + plate['A1'].top() # the top center of the well -This is a good position to use for :ref:`new-blow-out` or any other operation where you don't want the tip to contact the liquid. In addition, you can adjust the height of this position with the optional argument ``z``, which is measured in mm. Positive ``z`` numbers move the position up, and negative ``z`` numbers move it down: +This is a good position to use for a :ref:`blow out operation ` or an activity where you don't want the tip to contact the liquid. In addition, you can adjust the height of this position with the optional argument ``z``, which is measured in mm. Positive ``z`` numbers move the position up, negative ``z`` numbers move it down. .. code-block:: python @@ -37,13 +39,16 @@ This is a good position to use for :ref:`new-blow-out` or any other operation wh .. versionadded:: 2.0 -:py:meth:`.Well.bottom` returns a position level with the bottom of the well, centered in both horizontal directions. +Bottom +^^^^^^ + +Let's look at the :py:meth:`.Well.bottom` method. It returns a position level with the bottom of the well, centered in both horizontal directions. .. code-block:: python - plate['A1'].bottom() # the bottom center of the well + plate['A1'].bottom() # the bottom center of the well -This is a good position to start for aspiration or any other operation where you want the tip to contact the liquid. The same as with :py:meth:`.Well.top`, you can adjust the height of this position with the optional argument ``z``, which is measured in mm. Positive ``z`` numbers move the position up, and negative ``z``` numbers move it down: +This is a good position for :ref:`aspirating liquid ` or an activity where you want the tip to contact the liquid. Similar to the ``Well.top()`` method, you can adjust the height of this position with the optional argument ``z``, which is measured in mm. Positive ``z`` numbers move the position up, negative ``z`` numbers move it down. .. code-block:: python @@ -51,15 +56,20 @@ This is a good position to start for aspiration or any other operation where you plate['A1'].bottom(z=-1) # 1 mm below the bottom center of the well # this may be dangerous! - .. warning:: - Negative ``z`` arguments to :py:meth:`.Well.bottom` may cause the tip to collide with the bottom of the well. The OT-2 has no sensors to detect this. A collision may bend the tip (affecting liquid handling) and the pipette may be higher on the z-axis than expected until it picks up another tip. + Negative ``z`` arguments to ``Well.bottom()`` will cause the pipette tip to collide with the bottom of the well. Collisions may bend the tip (affecting liquid handling) and the pipette may be higher than expected on the z-axis until it picks up another tip. + + Flex can detect collisions, and even gentle contact may trigger an overpressure error and cause the protocol to fail. Avoid ``z`` values less than 1, if possible. + + The OT-2 has no sensors to detect contact with a well bottom. The protocol will continue even after a collision. .. versionadded:: 2.0 +Center +^^^^^^ -:py:meth:`.Well.center` returns a position centered in the well both vertically and horizontally. This can be a good place to start for precise control of positions within the well for unusual or custom labware. +Let's look at the :py:meth:`.Well.center` method. It returns a position centered in the well both vertically and horizontally. This can be a good place to start for precise control of positions within the well for unusual or custom labware. .. code-block:: python @@ -71,135 +81,66 @@ This is a good position to start for aspiration or any other operation where you .. _new-default-op-positions: Default Positions -================= - -By default, the OT-2 will aspirate and dispense 1 mm above the bottom of wells, which may not be suitable for some labware geometries, liquids, or protocols. You can change this by using :py:meth:`.Well.bottom` with the ``z`` argument, although it can be cumbersome to do this repeatedly. If you need to change the aspiration or dispensing height for many operations, specify the distance from the well bottom with :py:obj:`.InstrumentContext.well_bottom_clearance`. This attribute has two sub-attributes: ``well_bottom_clearance.aspirate`` changes the height for aspiration, and ``well_bottom_clearance.dispense`` changes the height for dispensing. - -Changing these attributes will affect all subsequent aspirate and dispense actions performed by that pipette, even those executed as part of a :py:meth:`.transfer`. - -.. code-block:: python - :substitutions: +----------------- - from opentrons import protocol_api, types +By default, your robot will aspirate and dispense 1 mm above the bottom of wells. This default clearance may not be suitable for some labware geometries, liquids, or protocols. You can change this value by using the :py:meth:`.Well.bottom` method with the ``z`` argument, though it can be cumbersome to do so repeatedly. - metadata = {'apiLevel': '|apiLevel|'} +If you need to change the aspiration or dispensing height for multiple operations, specify the distance in mm from the well bottom with the :py:obj:`.InstrumentContext.well_bottom_clearance` object. It has two attributes: ``well_bottom_clearance.aspirate`` and ``well_bottom_clearance.dispense``. These change the aspiration height and dispense height, respectively. - def run(protocol: protocol_api.ProtocolContext): - tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', '1') - pipette = protocol.load_instrument('p300_single', 'right', tip_racks = [tiprack]) - plate = protocol.load_labware('corning_384_wellplate_112ul_flat', 3) +Modifying these attributes will affect all subsequent aspirate and dispense actions performed by the attached pipette, even those executed as part of a :py:meth:`.transfer` operation. This snippet from a sample protocol demonstrates how to work with and change the default clearance:: - pipette.pick_up_tip() + # aspirate 1 mm above the bottom of the well (default) + pipette.aspirate(50, plate['A1']) + # dispense 1 mm above the bottom of the well (default) + pipette.dispense(50, plate['A1']) - # aspirate 1 mm above the bottom of the well (default) - pipette.aspirate(50, plate['A1']) - # dispense 1 mm above the bottom of the well (default) - pipette.dispense(50, plate['A1']) + # change clearance for aspiration to 2 mm + pipette.well_bottom_clearance.aspirate = 2 + # aspirate 2 mm above the bottom of the well + pipette.aspirate(50, plate['A1']) + # still dispensing 1 mm above the bottom + pipette.dispense(50, plate['A1']) - # change clearance for aspiration to 2 mm - pipette.well_bottom_clearance.aspirate = 2 - # aspirate 2 mm above the bottom of the well - pipette.aspirate(50, plate['A1']) - # still dispensing 1 mm above the bottom - pipette.dispense(50, plate['A1']) - - pipette.aspirate(50, plate['A1']) - # change clearance for dispensing to 10 mm - pipette.well_bottom_clearance.dispense = 10 - # dispense high above the well - pipette.dispense(50, plate['A1']) + pipette.aspirate(50, plate['A1']) + # change clearance for dispensing to 10 mm + pipette.well_bottom_clearance.dispense = 10 + # dispense high above the well + pipette.dispense(50, plate['A1']) .. versionadded:: 2.0 - -.. _using_lpc: - Using Labware Position Check ============================ -All positions relative to labware are automatically adjusted based on the labware's offset, an x, y, z vector. The best way to calculate and apply these offsets is by using Labware Position Check when you run your protocol in the Opentrons App. As of version 6.0 of the app, you can apply previously calculated offsets — even across different protocols — as long as they are for the same type of labware in the same deck slot on the same robot. - -You shouldn't adjust labware offsets in your Python code if you plan to run your protocol in the app. However, if you are running your protocol in Jupyter notebook or with ``opentrons_execute``, Labware Position Check is not directly available. For these applications, you can calculate and apply labware offsets by: - - 1. Creating a "dummy" protocol that loads your labware and has each used pipette pick up a tip from a tip rack - 2. Importing the dummy protocol to the Opentrons App - 3. Running Labware Position Check - 4. Adding the offsets to your protocol - -To prepare code written for Jupyter notebook so it can be run in the app, you need to include a metadata block and a ``run()`` function. And to enable Labware Position Check, you need to add a :py:meth:`.pick_up_tip` action for each pipette the protocol uses. For example, a dummy protocol using a P300 Single-Channel pipette, a reservoir, and a well plate would look like this: - -.. code-block:: python - - metadata = {'apiLevel': '2.12'} - - def run(protocol): - tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', 1) - reservoir = protocol.load_labware('nest_12_reservoir_15ml', 2) - plate = protocol.load_labware('nest_96_wellplate_200ul_flat', 3) - p300 = protocol.load_instrument('p300_single_gen2', 'left', tip_racks=[tiprack]) - p300.pick_up_tip() - p300.return_tip() - -After importing this protocol to the Opentrons App, run Labware Position Check to get the x, y, and z offsets for the tip rack and labware. When complete, you can click **Get Labware Offset Data** to view automatically generated code that uses :py:meth:`.set_offset` to apply the offsets to each piece of labware: - -.. code-block:: python - - labware_1 = protocol.load_labware("opentrons_96_tiprack_300ul", location="1") - labware_1.set_offset(x=0.00, y=0.00, z=0.00) - - labware_2 = protocol.load_labware("nest_12_reservoir_15ml", location="2") - labware_2.set_offset(x=0.10, y=0.20, z=0.30) - - labware_3 = protocol.load_labware("nest_96_wellplate_200ul_flat", location="3") - labware_3.set_offset(x=0.10, y=0.20, z=0.30) - -You'll notice that this code uses generic names for the loaded labware. If you want to match the labware names already in your protocol, add your own ``.set_offset()`` calls using the arguments provided by Labware Position Check: - -.. code-block:: python - - reservoir = protocol.load_labware('nest_12_reservoir_15ml', 2) - reservoir.set_offset(x=0.10, y=0.20, z=0.30) - -.. versionadded:: 2.12 - -Once you've executed this code in Jupyter notebook, all subsequent positional calculations for this reservoir in slot 2 will be adjusted 0.1 mm to the right, 0.2 mm to the back, and 0.3 mm up. +All positions relative to labware are adjusted automatically based on labware offset data. Calculate labware offsets by running Labware Position Check during protocol setup, either in the Opentrons App or on the Flex touchscreen. Version 6.0.0 and later of the robot software can apply previously calculated offsets on the same robot for the same labware type and deck slot, even across different protocols. -Remember, you should only add ``.set_offset()`` commands to protocols run outside of the Opentrons App. And you should follow the behavior of Labware Position Check: do not reuse offset measurements unless they apply to the *same labware* in the *same deck slot* on the *same robot*. - -.. warning:: +You should only adjust labware offsets in your Python code if you plan to run your protocol in Jupyter Notebook or from the command line. See :ref:`using_lpc` in the Advanced Control article for information. - Improperly reusing offset data may cause your robot to move to unforeseen positions, including crashing on labware, which can lead to incorrect protocol execution or damage to your equipment. The same is true of running protocols with ``.set_offset()`` commands in the Opentrons App. When in doubt: run Labware Position Check again and update your code! - - -.. _protocol-api-deck-coords: - -***************************** Position Relative to the Deck -***************************** - +============================= -The OT-2’s base coordinate system is known as *deck coordinates*. Many API functions use this coordinate system, and you can also reference it directly. It is a right-handed coordinate system always specified in mm, with the origin ``(0, 0, 0)`` at the front left of the robot. The positive ``x`` direction is to the right, the positive ``y`` direction is to the back, and the positive ``z`` direction is up. +The robot's base coordinate system is known as *deck coordinates*. Many API functions use this coordinate system, and you can also reference it directly. It is a right-handed coordinate system always specified in mm, with the origin ``(0, 0, 0)`` at the front left of the robot. The positive ``x`` direction is to the right, the positive ``y`` direction is to the back, and the positive ``z`` direction is up. You can identify a point in this coordinate system with a :py:class:`.types.Location` object, either as a standard Python :py:class:`tuple` of three floats, or as an instance of the :py:obj:`~collections.namedtuple` :py:class:`.types.Point`. .. note:: - There are technically multiple vertical axes: ``z`` is the axis of the left pipette mount and ``a`` is the axis of the right pipette mount. There are also pipette plunger axes: ``b`` (left) and ``c`` (right). You usually don't have to refer to these axes directly, since most motion commands are issued to a particular pipette and the OT-2 automatically selects the correct axis to move. Similarly, :py:class:`.types.Location` only deals with ``x``, ``y``, and ``z`` values. + There are technically multiple vertical axes. For example, ``z`` is the axis of the left pipette mount and ``a`` is the axis of the right pipette mount. There are also pipette plunger axes: ``b`` (left) and ``c`` (right). You usually don't have to refer to these axes directly, since most motion commands are issued to a particular pipette and the robot automatically selects the correct axis to move. Similarly, :py:class:`.types.Location` only deals with ``x``, ``y``, and ``z`` values. -******************** Independent Movement -******************** +==================== For convenience, many methods have location arguments and incorporate movement automatically. This section will focus on moving the pipette independently, without performing other actions like ``aspirate()`` or ``dispense()``. +.. _move-to: Move To -======= +------- -You can use the :py:meth:`.InstrumentContext.move_to` method to move a pipette to any reachable location on the deck. If the pipette has picked up a tip, it will move the end of the tip to that position; if it hasn't, it will move the pipette nozzle to that position. As with all movement in a protocol, the OT-2 calculates where to move in physical space by using its `pipette offset and tip length calibration `_ data. +The :py:meth:`.InstrumentContext.move_to` method moves a pipette to any reachable location on the deck. If the pipette has picked up a tip, it will move the end of the tip to that position; if it hasn't, it will move the pipette nozzle to that position. -The argument of ``move_to()`` must be a :py:class:`.Location`, either one automatically generated by methods like :py:meth:`.Well.top` and :py:meth:`.Well.bottom` or one you've created yourself — you can't move to a well directly: +The :py:meth:`~.InstrumentContext.move_to` method requires the :py:class:`.Location` argument. The location can be automatically generated by methods like ``Well.top()`` and ``Well.bottom()`` or one you've created yourself, but you can't move a pipette to a well directly: .. code-block:: python @@ -219,9 +160,7 @@ When using ``move_to()``, by default the pipette will move in an arc: first upwa Moving without an arc runs the risk of the pipette colliding with objects on the deck. Be very careful when using this option, especially when moving longer distances. -Small, direct movements can be useful for working inside of a well, without having the tip exit and re-enter the well. Here is how to move the pipette to a well, make direct movements inside that well, and then move on to a different well: - -.. code-block:: python +Small, direct movements can be useful for working inside of a well, without having the tip exit and re-enter the well. This code sample demonstrates how to move the pipette to a well, make direct movements inside that well, and then move on to a different well:: pipette.move_to(plate['A1'].top()) pipette.move_to(plate['A1'].bottom(1), force_direct=True) @@ -232,37 +171,28 @@ Small, direct movements can be useful for working inside of a well, without havi Points and Locations -==================== - -When instructing the OT-2 to move, it's important to consider the difference between the :py:class:`~opentrons.types.Point` and :py:class:`~opentrons.types.Location` types. Points are ordered tuples or named tuples: ``Point(10, 20, 30)``, ``Point(x=10, y=20, z=30)``, and ``Point(z=30, y=20, x=10)`` are all equivalent. Locations are a higher-order tuple that combines a point with a reference object: a well, a piece of labware, or ``None`` (the deck). - -.. TODO document position_for and other methods in deck.py that return Locations +-------------------- -This distinction is important for the :py:meth:`.Location.move` method, which operates on a location, takes a point as an argument, and outputs an updated location. To use this method, include ``from opentrons import types`` at the start of your protocol. The ``move()`` method does not mutate the location it is called on, so to perform an action at the updated location, use it as an argument of another method or save it to a variable: +When instructing the robot to move, it's important to consider the difference between the :py:class:`~opentrons.types.Point` and :py:class:`~opentrons.types.Location` types. -.. code-block:: python - :substitutions: +* Points are ordered tuples or named tuples: ``Point(10, 20, 30)``, ``Point(x=10, y=20, z=30)``, and ``Point(z=30, y=20, x=10)`` are all equivalent. +* Locations are a higher-order tuple that combines a point with a reference object: a well, a piece of labware, or ``None`` (the deck). - from opentrons import types - - metadata = {'apiLevel': '|apiLevel|'} +.. TODO document position_for and other methods in deck.py that return Locations - def run(protocol): - plate = protocol.load_labware('corning_24_wellplate_3.4ml_flat', location='1') - tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', '2') - pipette = protocol.load_instrument('p300_single', 'right', tip_racks = [tiprack]) - pipette.pick_up_tip() +This distinction is important for the :py:meth:`.Location.move` method, which operates on a location, takes a point as an argument, and outputs an updated location. To use this method, include ``from opentrons import types`` at the start of your protocol. The ``move()`` method does not mutate the location it is called on, so to perform an action at the updated location, use it as an argument of another method or save it to a variable. For example:: - # get the location at the center of well A1 - center_location = plate['A1'].center() + # get the location at the center of well A1 + center_location = plate['A1'].center() - # get a location 1 mm right, 1 mm back, and 1 mm up from the center of well A1 - adjusted_location = center_location.move(types.Point(x=1, y=1, z=1)) + # get a location 1 mm right, 1 mm back, and 1 mm up from the center of well A1 + adjusted_location = center_location.move(types.Point(x=1, y=1, z=1)) - # aspirate 1 mm right, 1 mm back, and 1 mm up from the center of well A1 - pipette.aspirate(50, adjusted_location) - # dispense at the same location - pipette.dispense(50, center_location.move(types.Point(x=1, y=1, z=1))) + # aspirate 1 mm right, 1 mm back, and 1 mm up from the center of well A1 + pipette.aspirate(50, adjusted_location) + + # dispense at the same location + pipette.dispense(50, center_location.move(types.Point(x=1, y=1, z=1))) .. note:: @@ -280,32 +210,26 @@ This distinction is important for the :py:meth:`.Location.move` method, which op .. versionadded:: 2.0 -*************** Movement Speeds -*************** +=============== -In addition to instructing the OT-2 where to move a pipette, you can also control the speed at which it moves. Speed controls can be applied either to all pipette motions or to movement along a particular axis. +In addition to instructing the robot where to move a pipette, you can also control the speed at which it moves. Speed controls can be applied either to all pipette motions or to movement along a particular axis. .. _gantry_speed: Gantry Speed -============ - -The OT-2's gantry usually moves as fast as it can given its construction: 400 mm/s. Moving at this speed saves time when executing protocols. However, some experiments or liquids may require slower movements. In this case, you can reduce the gantry speed for a specific pipette by setting :py:obj:`.InstrumentContext.default_speed`: +------------ -.. code-block:: python - :substitutions: - - # move to the first well at default speed - pipette.move_to(plate['A1'].top()) - # slow down the pipette - pipette.default_speed = 100 - # move to the last well much more slowly - pipette.move_to(plate['D6'].top()) +The robot's gantry usually moves as fast as it can given its construction. The default speed for Flex varies between 300 and 350 mm/s. The OT-2 default is 400 mm/s. However, some experiments or liquids may require slower movements. In this case, you can reduce the gantry speed for a specific pipette by setting :py:obj:`.InstrumentContext.default_speed` like this:: + + pipette.move_to(plate['A1'].top()) # move to the first well at default speed + pipette.default_speed = 100 # reduce pipette speed + pipette.move_to(plate['D6'].top()) # move to the last well at the slower speed + .. warning:: - The default of 400 mm/s was chosen because it is the maximum speed Opentrons knows will work with the gantry. Your specific robot may be able to move faster, but you shouldn't increase this value above 400 unless instructed by Opentrons Support. + These default speeds were chosen because they're the maximum speeds that Opentrons knows will work with the gantry. Your robot may be able to move faster, but you shouldn't increase this value unless instructed by Opentrons Support. .. versionadded:: 2.0 @@ -314,17 +238,17 @@ The OT-2's gantry usually moves as fast as it can given its construction: 400 mm .. _axis_speed_limits: Axis Speed Limits -================= +----------------- In addition to controlling the overall gantry speed, you can set speed limits for each of the individual axes: ``x`` (gantry left/right motion), ``y`` (gantry forward/back motion), ``z`` (left pipette up/down motion), and ``a`` (right pipette up/down motion). Unlike ``default_speed``, which is a pipette property, axis speed limits are stored in a protocol property :py:obj:`.ProtocolContext.max_speeds`; therefore the ``x`` and ``y`` values affect all movements by both pipettes. This property works like a dictionary, where the keys are axes, assigning a value to a key sets a max speed, and deleting a key or setting it to ``None`` resets that axis's limit to the default: .. code-block:: python :substitutions: - protocol.max_speeds['x'] = 50 # limit x-axis to 50 mm/s - del protocol.max_speeds['x'] # reset x-axis limit - protocol.max_speeds['a'] = 10 # limit a-axis to 10 mm/s - protocol.max_speeds['a'] = None # reset a-axis limit + protocol.max_speeds['x'] = 50 # limit x-axis to 50 mm/s + del protocol.max_speeds['x'] # reset x-axis limit + protocol.max_speeds['a'] = 10 # limit a-axis to 10 mm/s + protocol.max_speeds['a'] = None # reset a-axis limit Note that ``max_speeds`` can't set limits for the pipette plunger axes (``b`` and ``c``); instead, set the flow rates or plunger speeds as described in :ref:`new-plunger-flow-rates`. diff --git a/api/docs/v2/tutorial.rst b/api/docs/v2/tutorial.rst index 8ef5eb0571e..5f22ac49155 100644 --- a/api/docs/v2/tutorial.rst +++ b/api/docs/v2/tutorial.rst @@ -73,10 +73,12 @@ Everything else in the protocol file is required. Next, you’ll specify the ver For this tutorial, you’ll write very little Python outside of the ``run()`` function. But for more complex applications it’s worth remembering that your protocol file *is* a Python script, so any Python code that can run on your robot can be a part of a protocol. +.. _tutorial-metadata: + Metadata ^^^^^^^^ -Every protocol needs to have a metadata dictionary with information about the protocol. At minimum, you need to specify what :ref:`version ` of the API the protocol requires. The `scripts `_ for this tutorial were validated against API version 2.15, so specify: +Every protocol needs to have a metadata dictionary with information about the protocol. At minimum, you need to specify what :ref:`version of the API ` the protocol requires. The `scripts `_ for this tutorial were validated against API version 2.15, so specify: .. code-block:: python @@ -192,7 +194,7 @@ Next you’ll specify what pipette to use in the protocol. Loading a pipette is .. code-block:: python # Flex - left_pipette = protocol.load_instrument('flex_1channel_1000', 'left', tip_racks[tips]) + left_pipette = protocol.load_instrument('flex_1channel_1000', 'left', tip_racks=[tips]) .. code-block:: python @@ -362,4 +364,4 @@ When it’s all done, check the results of your serial dilution procedure — yo Next Steps ********** -This tutorial has relied heavily on the ``transfer()`` method, but there's much more that the Python Protocol API can do. Many advanced applications use :ref:`building block commands ` for finer control over the robot. These commands let you aspirate and dispense separately, add air gaps, blow out excess liquid, move the pipette to any location, and more. For protocols that use Opentrons :ref:`new_modules`, there are methods to control their behavior. And all of the API's classes and methods are catalogued in the :ref:`protocol-api-reference`. +This tutorial has relied heavily on the ``transfer()`` method, but there's much more that the Python Protocol API can do. Many advanced applications use :ref:`building block commands ` for finer control over the robot. These commands let you aspirate and dispense separately, add air gaps, blow out excess liquid, move the pipette to any location, and more. For protocols that use :ref:`Opentrons hardware modules `, there are methods to control their behavior. And all of the API's classes and methods are catalogued in the :ref:`API Reference `. diff --git a/api/docs/v2/versioning.rst b/api/docs/v2/versioning.rst index ccf2cad34d1..d6187d4e1c8 100644 --- a/api/docs/v2/versioning.rst +++ b/api/docs/v2/versioning.rst @@ -17,6 +17,8 @@ The major version of the API increases whenever there are significant structural The minor version of the API increases whenever there is new functionality that might change the way a protocol is written, or when a behavior changes in one aspect of the API but does not affect all protocols. For instance, adding support for a new hardware module, adding new parameters for a function, or deprecating a feature would increase the minor version of the API. +.. _specifying-versions: + Specifying Versions =================== @@ -69,7 +71,7 @@ The maximum supported API version for your robot is listed in the Opentrons App If you upload a protocol that specifies a higher API level than the maximum supported, your robot won't be able to analyze or run your protocol. You can increase the maximum supported version by updating your robot software and Opentrons App. -Opentrons robots running the latest software support the following version ranges: +Opentrons robots running the latest software (7.0.0) support the following version ranges: * **Flex:** version 2.15. * **OT-2:** versions 2.0–|apiLevel|. @@ -138,7 +140,7 @@ This version introduces support for the Opentrons Flex robot, instruments, modul - The new :py:meth:`.move_labware` method can move labware automatically using the Flex Gripper. You can also move labware manually on Flex. - - :py:meth:`.load_module` supports loading the :ref:`magnetic-block`. + - :py:meth:`.load_module` supports loading the :ref:`Magnetic Block `. - The API does not enforce placement restrictions for the Heater-Shaker module on Flex, because it is installed below-deck in a module caddy. Pipetting restrictions are still in place when the Heater-Shaker is shaking or its labware latch is open. @@ -148,7 +150,7 @@ This version introduces support for the Opentrons Flex robot, instruments, modul - Optionally specify ``"robotType": "OT-2"`` in ``requirements``. - - Use coordinates or numbers to specify :ref:`deck-slots`. These formats match physical labels on Flex and OT-2, but you can use either system, regardless of ``robotType``. + - Use coordinates or numbers to specify :ref:`deck slots `. These formats match physical labels on Flex and OT-2, but you can use either system, regardless of ``robotType``. - The new :py:meth:`.load_adapter` method lets you load adapters and labware separately on modules, and lets you load adapters directly in deck slots. See :ref:`labware-on-adapters`. @@ -290,7 +292,7 @@ Version 2.8 - You can now pass in a list of volumes to distribute and consolidate. See :ref:`distribute-consolidate-volume-list` for more information. - - Passing in a zero volume to any :ref:`v2-complex-commands` will result in no actions taken for aspirate or dispense + - Passing in a zero volume to any :ref:`complex command ` will result in no actions taken for aspirate or dispense - :py:meth:`.Well.from_center_cartesian` can be used to find a point within a well using normalized distance from the center in each axis. @@ -321,13 +323,13 @@ Version 2.6 - Protocols that manually configure pipette flow rates will be unaffected - - For a comparison between API Versions, see :ref:`defaults` + - For a comparison between API Versions, see :ref:`ot2-flow-rates` Version 2.5 ----------- -- New :ref:`new-utility-commands` were added: +- New :ref:`utility commands ` were added: - :py:meth:`.ProtocolContext.set_rail_lights`: turns robot rail lights on or off - :py:obj:`.ProtocolContext.rail_lights_on`: describes whether or not the rail lights are on @@ -351,7 +353,7 @@ Version 2.3 module gen2"`` and ``"temperature module gen2"``, respectively. - All pipettes will return tips to tip racks from a higher position to avoid possible collisions. -- During a :ref:`mix`, the pipette will no longer move up to clear the liquid in +- During a :py:meth:`.mix`, the pipette will no longer move up to clear the liquid in between every dispense and following aspirate. - You can now access the Temperature Module's status via :py:obj:`.TemperatureModuleContext.status`. diff --git a/api/docs/v2/writing.rst b/api/docs/v2/writing.rst deleted file mode 100644 index 0736ae7fd42..00000000000 --- a/api/docs/v2/writing.rst +++ /dev/null @@ -1,287 +0,0 @@ -.. _writing: - -########################## -Using Python For Protocols -########################## - -Writing protocols in Python requires some up-front design before seeing your liquid handling automation in action. At a high-level, writing protocols with the OT-2 Python Protocol API looks like: - -1) Write a Python protocol -2) Test the code for errors -3) Repeat steps 1 & 2 -4) Calibrate labware on your OT-2 -5) Run your protocol - -These sets of documents aim to help you get the most out of steps 1 & 2, the "design" stage. - -******************************* - -******************** -Python for Beginners -******************** - -If Python is new to you, we suggest going through a few simple tutorials to acquire a base understanding to build upon. The following tutorials are a great starting point for working with the Protocol API (from `learnpython.org `_): - -1) `Hello World `_ -2) `Variables and Types `_ -3) `Lists `_ -4) `Basic Operators `_ -5) `Conditions `_ -6) `Loops `_ -7) `Functions `_ -8) `Dictionaries `_ - -After going through the above tutorials, you should have enough of an understanding of Python to work with the Protocol API and start designing your experiments! -More detailed information on Python can always be found in `the Python docs `_. - -******************************* - -******************* -Working with Python -******************* - - -Using a popular and free code editor, like `Visual Studio Code`__, is a common method for writing Python protocols. Download onto your computer, and you can now write Python scripts. - -__ https://code.visualstudio.com/ - -.. note:: - - Make sure that when saving a protocol file, it ends with the ``.py`` file extension. This will ensure the Opentrons App and other programs are able to properly read it. - - For example, ``my_protocol.py`` - -How Protocols Are Organized -=========================== - -When writing protocols using the Python Protocol API, there are generally five sections: - -1) Metadata and Version Selection -2) Run function -3) Labware -4) Pipettes -5) Commands - -Metadata and Version Selection -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Metadata is a dictionary of data that is read by the server and returned to client applications (such as the Opentrons App). Most metadata is not needed to run a protocol, but if present can help the Opentrons App display additional data about the protocol currently being executed. These optional (but recommended) fields are (``"protocolName"``, ``"author"``, and ``"description"``). - -The required element of the metadata is ``"apiLevel"``. This must contain a string specifying the major and minor version of the Python Protocol API that your protocol is designed for. For instance, a protocol written for version 2.0 of the Python Protocol API (only launch version of the Protocol API should have in its metadata ``"apiLevel": "2.0"``. - - -For more information on Python Protocol API versioning, see :ref:`v2-versioning`. - -The Run Function and the Protocol Context -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Protocols are structured around a function called ``run(protocol)``, defined in code like this: - -.. code-block:: python - :substitutions: - - from opentrons import protocol_api - - metadata = {'apiLevel': '|apiLevel|'} - - def run(protocol: protocol_api.ProtocolContext): - pass - -This function must be named exactly ``run`` and must take exactly one mandatory argument (its name doesn’t matter, but we recommend ``protocol`` since this argument represents the protocol that the robot will execute). - -The function ``run`` is the container for the code that defines your protocol. - -The object ``protocol`` is the *protocol context*, which represents the robot and its capabilities. It is always an instance of the :py:class:`opentrons.protocol_api.ProtocolContext` class (though you'll never have to instantiate one yourself - it is always passed in to ``run()``), and it is tagged as such in the example protocol to allow most editors to give you autocomplete. - -The protocol context has two responsibilities: - -1) Remember, track, and check the robot’s state -2) Expose the functions that make the robot execute actions - -The protocol context plays the same role as the ``robot``, ``labware``, ``instruments``, and ``modules`` objects in past versions of the API, with one important difference: it is only one object; and because it is passed in to your protocol rather than imported, it is possible for the API to be much more rigorous about separating simulation from reality. - -The key point is that there is no longer any need to ``import opentrons`` at the top of every protocol, since the *robot* now *runs the protocol*, rather than the *protocol running the robot*. The example protocol imports the definition of the protocol context to provide editors with autocomplete sources. - - -Labware -^^^^^^^ - -The next step is defining the labware required for your protocol. You must tell the protocol context about what should be present on the deck, and where. You tell the protocol context about labware by calling the method ``protocol.load_labware(name, slot)`` and saving the result. - -The name of a labware is a string that is different for each kind of labware. You can look up labware to add to your protocol on the Opentrons `Labware Library `_. - -The slot is the labelled location on the deck in which you've placed the labware. The available slots are numbered from 1-11. - -Our example protocol above loads - -* a `Corning 96 Well Plate `_ in slot 2: - -.. code-block:: python - - plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 2) - -* an `Opentrons 300µL Tiprack `_ in slot 1: - -.. code-block:: python - - tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', 1) - -These labware can be referenced later in the protocol as ``plate`` and ``tiprack`` respectively. Check out `the Python docs `_ for further clarification on using variables effectively in your code. - -You can find more information about handling labware in the :ref:`new-labware` section. - - -Pipettes -^^^^^^^^ - -After defining labware, you define the instruments required for your protocol. You tell the protocol context about which pipettes should be attached, and which slot they should be attached to, by calling the method ``protocol.load_instrument(model, mount, tip_racks)`` and saving the result. - -The ``model`` of the pipette is the kind of pipette that should be attached; the ``mount`` is either ``"left"`` or ``"right"``; and ``tip_racks`` is a list of the objects representing tip racks that this instrument should use. Specifying ``tip_racks`` is optional, but if you don't then you'll have to manually specify where the instrument should pick up tips from every time you try and pick up a tip. - -See :ref:`new-pipette` for more information on creating and working with pipettes. - -Our example protocol above loads a P300 Single-channel pipette (``'p300_single'``) in the left mount (``'left'``), and uses the Opentrons 300 µL tiprack we loaded previously as a source of tips (``tip_racks=[tiprack]``). - - -Commands -^^^^^^^^ - -Once the instruments and labware required for the protocol are defined, the next step is to define the commands that make up the protocol. The most common commands are ``aspirate()``, ``dispense()``, ``pick_up_tip()``, and ``drop_tip()``. These and many others are described in the :ref:`v2-atomic-commands` and :ref:`v2-complex-commands` sections, which go into more detail about the commands and how they work. These commands typically specify which wells of which labware to interact with, using the labware you defined earlier, and are methods of the instruments you created in the pipette section. For instance, in our example protocol, you use the pipette you defined to: - -1) Pick up a tip (implicitly from the tiprack you specified in slot 1 and assigned to the pipette): ``pipette.pick_up_tip()`` -2) Aspirate 100 µL from well A1 of the 96 well plate you specified in slot 2: ``pipette.aspirate(100, plate['A1'])`` -3) Dispense 100 µL into well A2 of the 96 well plate you specified in slot 2: ``pipette.dispense(100, plate['A2'])`` -4) Drop the tip (implicitly into the trash at the back right of the robot's deck): ``pipette.drop_tip()`` - - -.. _simulate-block: - -*************************** -Simulating Python Protocols -*************************** - -In general, the best way to simulate a protocol is to simply upload it to your OT-2 through the Opentrons App. When you upload a protocol via the app, the OT-2 simulates the protocol and the app displays any errors. However, if you want to simulate protocols without being connected to an OT-2, you can download the Opentrons Python package. - -Installing -========== - -To install the Opentrons package, you must install it from Python’s package manager, `pip`. The exact method of installation is slightly different depending on whether you use Jupyter on your computer or not. You do not need to do this if you want to use :ref:`writing-robot-jupyter`, *only* for your locally installed notebook. - -Non-Jupyter Installation -^^^^^^^^^^^^^^^^^^^^^^^^ - -First, install Python 3.7.6 (`Windows x64 `_, `Windows x86 `_, `OS X `_) or higher on your local computer. - -Once the installer is done, make sure that Python is properly installed by opening a terminal and doing ``python --version``. If this is not higher than 3.7.6, you have another version of Python installed; this happens frequently on OS X and sometimes on Windows. We recommend using a tool like `pyenv `_ to manage multiple Python versions. This is particularly useful on OS X, which has a built-in install of Python 2.7 that should not be removed. - -Once Python is installed, install the `opentrons package `_ using ``pip``: - -.. prompt:: bash - - pip install opentrons - -You should see some output that ends with :substitution-code:`Successfully installed opentrons-|release|`. - -Jupyter Installation -^^^^^^^^^^^^^^^^^^^^ - -You must make sure that you install the ``opentrons`` package for whichever kernel and virtual environment the notebook is using. A generally good way to do this is - -.. prompt:: python >>> - - import sys - !{sys.executable} -m pip install opentrons - -Simulating Your Scripts -======================= - -From the Command Line -^^^^^^^^^^^^^^^^^^^^^ - -Once the Opentrons Python package is installed, you can simulate protocols in your terminal using the ``opentrons_simulate`` command: - -.. prompt:: bash - - opentrons_simulate.exe my_protocol.py - -or, on OS X or Linux, - -.. prompt:: bash - - opentrons_simulate my_protocol.py - -The simulator will print out a log of the actions the protocol will cause, similar to the Opentrons App; it will also print out any log messages caused by a given command next to that list of actions. If there is a problem with the protocol, the simulation will stop and the error will be printed. - -The simulation script can also be invoked through python: - -.. prompt:: bash - - python -m opentrons.simulate /path/to/protocol - -``opentrons_simulate`` has several command line options that might be useful. -Most options are explained below, but to see all options you can run - -.. prompt:: bash - - opentrons_simulate --help - - -Using Custom Labware -^^^^^^^^^^^^^^^^^^^^ - -By default, ``opentrons_simulate`` will load custom labware definitions from the -directory in which you run it. You can change the directory -``opentrons_simulate`` searches for custom labware with the -``--custom-labware-path`` option: - -.. code-block:: shell - - python.exe -m opentrons.simulate --custom-labware-path="C:\Custom Labware" - - -In the Python Shell -^^^^^^^^^^^^^^^^^^^ - -The Opentrons Python package also provides an entrypoint to use the Opentrons simulation package from other Python contexts such as an interactive prompt or Jupyter. To simulate a protocol in Python, open a file containing a protocol and pass it to :py:meth:`opentrons.simulate.simulate`: - -.. code-block:: python - - - from opentrons.simulate import simulate, format_runlog - # read the file - protocol_file = open('/path/to/protocol.py') - # simulate() the protocol, keeping the runlog - runlog, _bundle = simulate(protocol_file) - # print the runlog - print(format_runlog(runlog)) - -The :py:meth:`opentrons.simulate.simulate` method does the work of simulating the protocol and returns the run log, which is a list of structured dictionaries. :py:meth:`opentrons.simulate.format_runlog` turns that list of dictionaries into a human readable string, which is then printed out. For more information on the protocol simulator, see :ref:`simulate-block`. - - -.. _writing-robot-jupyter: - -The Robot’s Jupyter Notebook -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Your OT-2 also has a Jupyter notebook, which you can use to develop and execute protocols. In the Jupyter notebook, you can use the Python Protocol API simulator by writing: - -.. code-block:: python - :substitutions: - - from opentrons import simulate - protocol = simulate.get_protocol_api('|apiLevel|') - p300 = protocol.load_instrument('p300_single', 'right') - # ... - -The ``protocol`` object, which is an instance of :py:class:`.ProtocolContext`, is the same thing that gets passed to your protocol's ``run`` function, but set to simulate rather than control an OT-2. You can call all your protocol's functions on that object. - -If you have a full protocol, wrapped inside a ``run`` function, defined in a Jupyter cell you can also use :py:meth:`opentrons.simulate.simulate` as described above to simulate the protocol. - -For more information on how to execute protocols using the OT-2's Jupyter notebook, please see :ref:`advanced-control`. - - -Configuration and Local Storage -=============================== - -The Opentrons Python package uses a folder in your user directory as a place to store and read configuration and changes to its internal data. This location is ``~/.opentrons`` on Linux or OSX and ``C:\Users\%USERNAME%\.opentrons`` on Windows. - diff --git a/api/src/opentrons/hardware_control/__init__.py b/api/src/opentrons/hardware_control/__init__.py index b8e1c015bbb..356923f1aff 100644 --- a/api/src/opentrons/hardware_control/__init__.py +++ b/api/src/opentrons/hardware_control/__init__.py @@ -14,7 +14,6 @@ from .pause_manager import PauseManager from .backends import Controller, Simulator from .types import CriticalPoint, ExecutionState -from .errors import ExecutionCancelledError, NoTipAttachedError, TipAttachedError from .constants import DROP_TIP_RELEASE_DISTANCE from .thread_manager import ThreadManager from .execution_manager import ExecutionManager @@ -48,13 +47,10 @@ "SynchronousAdapter", "HardwareControlAPI", "CriticalPoint", - "NoTipAttachedError", - "TipAttachedError", "DROP_TIP_RELEASE_DISTANCE", "ThreadManager", "ExecutionManager", "ExecutionState", - "ExecutionCancelledError", "ThreadedAsyncLock", "ThreadedAsyncForbidden", "ThreadManagedHardware", diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index b0765e590c0..cce958663c4 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -20,6 +20,10 @@ cast, ) +from opentrons_shared_data.errors.exceptions import ( + PositionUnknownError, + UnsupportedHardwareCommand, +) from opentrons_shared_data.pipette import ( pipette_load_name_conversions as pipette_load_name, ) @@ -54,10 +58,6 @@ SubSystem, SubSystemState, ) -from .errors import ( - MustHomeError, - NotSupportedByHardware, -) from . import modules from .robot_calibration import ( RobotCalibrationProvider, @@ -601,10 +601,13 @@ async def home(self, axes: Optional[List[Axis]] = None) -> None: # No internal code passes OT3 axes as arguments on an OT2. But a user/ client # can still explicitly specify an OT3 axis even when working on an OT2. # Adding this check in order to prevent misuse of axes types. - if axes and any(axis not in Axis.ot2_axes() for axis in axes): - raise NotSupportedByHardware( - f"At least one axis in {axes} is not supported on the OT2." - ) + if axes: + unsupported = list(axis not in Axis.ot2_axes() for axis in axes) + if any(unsupported): + raise UnsupportedHardwareCommand( + message=f"At least one axis in {axes} is not supported on the OT2.", + detail={"unsupported_axes": unsupported}, + ) self._reset_last_mount() # Initialize/update current_position checked_axes = axes or [ax for ax in Axis.ot2_axes()] @@ -641,16 +644,24 @@ async def current_position( plunger_ax = Axis.of_plunger(mount) position_axes = [Axis.X, Axis.Y, z_ax, plunger_ax] - if fail_on_not_homed and ( - not self._backend.is_homed([ot2_axis_to_string(a) for a in position_axes]) - or not self._current_position - ): - raise MustHomeError( - f"Current position of {str(mount)} pipette is unknown, please home." - ) - + if fail_on_not_homed: + if not self._current_position: + raise PositionUnknownError( + message=f"Current position of {str(mount)} pipette is unknown," + " please home.", + detail={"mount": str(mount), "missing_axes": position_axes}, + ) + axes_str = [ot2_axis_to_string(a) for a in position_axes] + if not self._backend.is_homed(axes_str): + unhomed = self._backend._unhomed_axes(axes_str) + raise PositionUnknownError( + message=f"{str(mount)} pipette axes ({unhomed}) must be homed.", + detail={"mount": str(mount), "unhomed_axes": unhomed}, + ) elif not self._current_position and not refresh: - raise MustHomeError("Current position is unknown; please home motors.") + raise PositionUnknownError( + message="Current position is unknown; please home motors." + ) async with self._motion_lock: if refresh: smoothie_pos = await self._backend.update_position() @@ -730,7 +741,10 @@ async def move_axes( The effector of the x,y axis is the center of the carriage. The effector of the pipette mount axis are the mount critical points but only in z. """ - raise NotSupportedByHardware("move_axes is not supported on the OT-2.") + raise UnsupportedHardwareCommand( + message="move_axes is not supported on the OT-2.", + detail={"axes_commanded": list(position.keys())}, + ) async def move_rel( self, @@ -748,23 +762,32 @@ async def move_rel( # TODO: Remove the fail_on_not_homed and make this the behavior all the time. # Having the optional arg makes the bug stick around in existing code and we # really want to fix it when we're not gearing up for a release. - mhe = MustHomeError( - "Cannot make a relative move because absolute position is unknown" - ) if not self._current_position: if fail_on_not_homed: - raise mhe + raise PositionUnknownError( + message="Cannot make a relative move because absolute position" + " is unknown.", + detail={ + "mount": str(mount), + "fail_on_not_homed": fail_on_not_homed, + }, + ) else: await self.home() target_position = target_position_from_relative( mount, delta, self._current_position ) + axes_moving = [Axis.X, Axis.Y, Axis.by_mount(mount)] - if fail_on_not_homed and not self._backend.is_homed( - [ot2_axis_to_string(axis) for axis in axes_moving if axis is not None] - ): - raise mhe + axes_str = [ot2_axis_to_string(a) for a in axes_moving] + if fail_on_not_homed and not self._backend.is_homed(axes_str): + unhomed = self._backend._unhomed_axes(axes_str) + raise PositionUnknownError( + message=f"{str(mount)} pipette axes ({unhomed}) must be homed.", + detail={"mount": str(mount), "unhomed_axes": unhomed}, + ) + await self._cache_and_maybe_retract_mount(mount) await self._move( target_position, @@ -937,7 +960,7 @@ async def prepare_for_aspirate( Prepare the pipette for aspiration. """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.PREPARE_ASPIRATE) + self.ready_for_tip_action(instrument, HardwareAction.PREPARE_ASPIRATE, mount) if instrument.current_volume == 0: speed = self.plunger_speed( diff --git a/api/src/opentrons/hardware_control/backends/controller.py b/api/src/opentrons/hardware_control/backends/controller.py index f3e73ecbea7..b029e1a15c4 100644 --- a/api/src/opentrons/hardware_control/backends/controller.py +++ b/api/src/opentrons/hardware_control/backends/controller.py @@ -145,11 +145,15 @@ async def update_position(self) -> Dict[str, float]: await self._smoothie_driver.update_position() return self._smoothie_driver.position + def _unhomed_axes(self, axes: Sequence[str]) -> List[str]: + return list( + axis + for axis in axes + if not self._smoothie_driver.homed_flags.get(axis, False) + ) + def is_homed(self, axes: Sequence[str]) -> bool: - for axis in axes: - if not self._smoothie_driver.homed_flags.get(axis, False): - return False - return True + return not any(self._unhomed_axes(axes)) async def move( self, diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 1e62e1a8b08..b3ac52f4308 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -40,7 +40,7 @@ create_gripper_jaw_home_group, create_gripper_jaw_hold_group, create_tip_action_group, - create_gear_motor_home_group, + create_tip_motor_home_group, motor_nodes, LIMIT_SWITCH_OVERTRAVEL_DISTANCE, map_pipette_type_to_sensor_id, @@ -93,7 +93,6 @@ from opentrons_hardware.firmware_bindings.constants import ( NodeId, PipetteName as FirmwarePipetteName, - SensorId, ErrorCode, ) from opentrons_hardware.firmware_bindings.messages.message_definitions import ( @@ -133,14 +132,16 @@ from opentrons.hardware_control.errors import ( InvalidPipetteName, InvalidPipetteModel, - FirmwareUpdateRequired, - OverPressureDetected, ) from opentrons_hardware.hardware_control.motion import ( MoveStopCondition, MoveGroup, ) -from opentrons_hardware.hardware_control.types import NodeMap +from opentrons_hardware.hardware_control.types import ( + NodeMap, + MotorPositionStatus, + MoveCompleteAck, +) from opentrons_hardware.hardware_control.tools import types as ohc_tool_types from opentrons_hardware.hardware_control.tool_sensors import ( @@ -169,6 +170,9 @@ from opentrons_shared_data.errors.exceptions import ( EStopActivatedError, EStopNotPresentError, + UnmatchedTipPresenceStates, + PipetteOverpressureError, + FirmwareUpdateRequiredError, ) from .subsystem_manager import SubsystemManager @@ -187,12 +191,15 @@ def requires_update(func: Wrapped) -> Wrapped: - """Decorator that raises FirmwareUpdateRequired if the update_required flag is set.""" + """Decorator that raises FirmwareUpdateRequiredError if the update_required flag is set.""" @wraps(func) async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: if self.update_required and self.initialized: - raise FirmwareUpdateRequired() + raise FirmwareUpdateRequiredError( + func.__name__, + self.subsystems_to_update, + ) return await func(self, *args, **kwargs) return cast(Wrapped, wrapper) @@ -343,6 +350,10 @@ def eeprom_data(self) -> EEPROMData: def update_required(self) -> bool: return self._subsystem_manager.update_required and self._check_updates + @property + def subsystems_to_update(self) -> List[SubSystem]: + return self._subsystem_manager.subsystems_to_update + @staticmethod def _build_system_hardware( can_messenger: CanMessenger, @@ -382,10 +393,13 @@ async def update_firmware( async for update in self._subsystem_manager.update_firmware(subsystems, force): yield update + def get_current_settings( + self, gantry_load: GantryLoad + ) -> OT3AxisMap[CurrentConfig]: + return get_current_settings(self._configuration.current_settings, gantry_load) + async def update_to_default_current_settings(self, gantry_load: GantryLoad) -> None: - self._current_settings = get_current_settings( - self._configuration.current_settings, gantry_load - ) + self._current_settings = self.get_current_settings(gantry_load) await self.set_default_currents() async def update_motor_status(self) -> None: @@ -484,11 +498,11 @@ async def update_encoder_position(self) -> OT3AxisMap[float]: def _handle_motor_status_response( self, - response: NodeMap[Tuple[float, float, bool, bool]], + response: NodeMap[MotorPositionStatus], ) -> None: for axis, pos in response.items(): - self._position.update({axis: pos[0]}) - self._encoder_position.update({axis: pos[1]}) + self._position.update({axis: pos.motor_position}) + self._encoder_position.update({axis: pos.encoder_position}) # TODO (FPS 6-01-2023): Remove this once the Feature Flag to ignore stall detection is removed. # This check will latch the motor status for an axis at "true" if it was ever set to true. # To account for the case where a motor axis has its power reset, we also depend on the @@ -502,7 +516,8 @@ def _handle_motor_status_response( self._motor_status.update( { axis: MotorStatus( - motor_ok=(pos[2] or motor_ok_latch), encoder_ok=pos[3] + motor_ok=(pos.motor_ok or motor_ok_latch), + encoder_ok=pos.encoder_ok, ) } ) @@ -647,12 +662,11 @@ async def home( positions = await asyncio.gather(*coros) # TODO(CM): default gear motor homing routine to have some acceleration if Axis.Q in checked_axes: - await self.tip_action( + await self.home_tip_motors( distance=self.axis_bounds[Axis.Q][1] - self.axis_bounds[Axis.Q][0], velocity=self._configuration.motion_settings.max_speed_discontinuity.high_throughput[ Axis.to_kind(Axis.Q) ], - tip_action="home", ) for position in positions: self._handle_motor_status_response(position) @@ -670,26 +684,31 @@ def _filter_move_group(self, move_group: MoveGroup) -> MoveGroup: ) return new_group + async def home_tip_motors( + self, + distance: float, + velocity: float, + back_off: bool = True, + ) -> None: + move_group = create_tip_motor_home_group(distance, velocity, back_off) + + runner = MoveGroupRunner( + move_groups=[move_group], + ignore_stalls=True if not ff.stall_detection_enabled() else False, + ) + positions = await runner.run(can_messenger=self._messenger) + if NodeId.pipette_left in positions: + self._gear_motor_position = { + NodeId.pipette_left: positions[NodeId.pipette_left].motor_position + } + else: + log.debug("no position returned from NodeId.pipette_left") + async def tip_action( self, - moves: Optional[List[Move[Axis]]] = None, - distance: Optional[float] = None, - velocity: Optional[float] = None, - tip_action: str = "home", - back_off: Optional[bool] = False, + moves: List[Move[Axis]], ) -> None: - # TODO: split this into two functions for homing and 'clamp' - move_group = [] - # make sure either moves or distance and velocity is not None - assert bool(moves) ^ (bool(distance) and bool(velocity)) - if moves is not None: - move_group = create_tip_action_group( - moves, [NodeId.pipette_left], tip_action - ) - elif distance is not None and velocity is not None: - move_group = create_gear_motor_home_group( - float(distance), float(velocity), back_off - ) + move_group = create_tip_action_group(moves, [NodeId.pipette_left], "clamp") runner = MoveGroupRunner( move_groups=[move_group], @@ -698,7 +717,7 @@ async def tip_action( positions = await runner.run(can_messenger=self._messenger) if NodeId.pipette_left in positions: self._gear_motor_position = { - NodeId.pipette_left: positions[NodeId.pipette_left][0] + NodeId.pipette_left: positions[NodeId.pipette_left].motor_position } else: log.debug("no position returned from NodeId.pipette_left") @@ -764,7 +783,7 @@ def _build_attached_pip( attached: ohc_tool_types.PipetteInformation, mount: OT3Mount ) -> AttachedPipette: if attached.name == FirmwarePipetteName.unknown: - raise InvalidPipetteName(name=attached.name_int, mount=mount) + raise InvalidPipetteName(name=attached.name_int, mount=mount.name) try: # TODO (lc 12-8-2022) We should return model as an int rather than # a string. @@ -785,7 +804,7 @@ def _build_attached_pip( } except KeyError: raise InvalidPipetteModel( - name=attached.name.name, model=attached.model, mount=mount + name=attached.name.name, model=attached.model, mount=mount.name ) @staticmethod @@ -841,18 +860,33 @@ async def get_limit_switches(self) -> OT3AxisMap[bool]: res = await get_limit_switches(self._messenger, motor_nodes) return {node_to_axis(node): bool(val) for node, val in res.items()} - async def get_tip_present(self, mount: OT3Mount, tip_state: TipStateType) -> None: + async def check_for_tip_presence( + self, + mount: OT3Mount, + tip_state: TipStateType, + expect_multiple_responses: bool = False, + ) -> None: """Raise an error if the expected tip state does not match the current state.""" - res = await self.get_tip_present_state(mount) + res = await self.get_tip_present_state(mount, expect_multiple_responses) if res != tip_state.value: raise FailedTipStateCheck(tip_state, res) - async def get_tip_present_state(self, mount: OT3Mount) -> int: + async def get_tip_present_state( + self, + mount: OT3Mount, + expect_multiple_responses: bool = False, + ) -> bool: """Get the state of the tip ejector flag for a given mount.""" - res = await get_tip_ejector_state( - self._messenger, sensor_node_for_mount(OT3Mount(mount.value)) # type: ignore - ) - return res + expected_responses = 2 if expect_multiple_responses else 1 + node = sensor_node_for_mount(OT3Mount(mount.value)) + assert node != NodeId.gripper + res = await get_tip_ejector_state(self._messenger, node, expected_responses) # type: ignore[arg-type] + vals = list(res.values()) + if not all([r == vals[0] for r in vals]): + states = {int(sensor): res[sensor] for sensor in res} + raise UnmatchedTipPresenceStates(states) + tip_present_state = bool(vals[0]) + return tip_present_state @staticmethod def _tip_motor_nodes(axis_current_keys: KeysView[Axis]) -> List[NodeId]: @@ -919,6 +953,25 @@ async def restore_current(self) -> AsyncIterator[None]: self._current_settings = old_current_settings await self.set_default_currents() + @asynccontextmanager + async def restore_z_r_run_current(self) -> AsyncIterator[None]: + """ + Temporarily restore the active current ONLY when homing or + retracting the Z_R axis while the 96-channel is attached. + """ + assert self._current_settings + high_throughput_settings = deepcopy(self._current_settings) + conf = self.get_current_settings(GantryLoad.LOW_THROUGHPUT)[Axis.Z_R] + # outside of homing and retracting, Z_R run current should + # be reduced to its hold current + await self.set_active_current({Axis.Z_R: conf.run_current}) + try: + yield + finally: + await self.set_active_current( + {Axis.Z_R: high_throughput_settings[Axis.Z_R].run_current} + ) + @staticmethod def _build_event_watcher() -> aionotify.Watcher: watcher = aionotify.Watcher() @@ -1069,6 +1122,7 @@ def _axis_map_to_present_nodes( @asynccontextmanager async def _monitor_overpressure(self, mounts: List[NodeId]) -> AsyncIterator[None]: + msg = "The pressure sensor on the {} mount has exceeded operational limits." if ff.overpressure_detection_enabled() and mounts: tools_with_id = map_pipette_type_to_sensor_id( mounts, self._subsystem_manager.device_info @@ -1094,8 +1148,10 @@ def _pop_queue() -> Optional[Tuple[NodeId, ErrorCode]]: q_msg = _pop_queue() if q_msg: mount = Axis.to_ot3_mount(node_to_axis(q_msg[0])) - raise OverPressureDetected(mount.name) - + raise PipetteOverpressureError( + message=msg.format(str(mount)), + detail={"mount": mount}, + ) else: yield @@ -1109,7 +1165,7 @@ async def liquid_probe( log_pressure: bool = True, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, - sensor_id: SensorId = SensorId.S0, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Dict[NodeId, float]: head_node = axis_to_node(Axis.by_mount(mount)) tool = sensor_node_for_pipette(OT3Mount(mount.value)) @@ -1124,11 +1180,11 @@ async def liquid_probe( log_pressure, auto_zero_sensor, num_baseline_reads, - sensor_id, + sensor_id_for_instrument(probe), ) for node, point in positions.items(): - self._position.update({node: point[0]}) - self._encoder_position.update({node: point[1]}) + self._position.update({node: point.motor_position}) + self._encoder_position.update({node: point.encoder_position}) return self._position async def capacitive_probe( @@ -1139,8 +1195,8 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType, - ) -> None: - pos, _ = await capacitive_probe( + ) -> bool: + status = await capacitive_probe( self._messenger, sensor_node_for_mount(mount), axis_to_node(moving), @@ -1150,7 +1206,8 @@ async def capacitive_probe( relative_threshold_pf=sensor_threshold_pf, ) - self._position[axis_to_node(moving)] = pos + self._position[axis_to_node(moving)] = status.motor_position + return status.move_ack == MoveCompleteAck.stopped_by_condition async def capacitive_pass( self, diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 093342952c4..42e1a3fe545 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -308,7 +308,7 @@ async def liquid_probe( log_pressure: bool = True, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, - sensor_id: SensorId = SensorId.S0, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Dict[NodeId, float]: head_node = axis_to_node(Axis.by_mount(mount)) @@ -388,11 +388,18 @@ async def gripper_hold_jaw( self._encoder_position[NodeId.gripper_g] = encoder_position_um / 1000.0 self._sim_jaw_state = GripperJawState.HOLDING - async def get_tip_present(self, mount: OT3Mount, tip_state: TipStateType) -> None: + async def check_for_tip_presence( + self, + mount: OT3Mount, + tip_state: TipStateType, + expect_multiple_responses: bool = False, + ) -> None: """Raise an error if the given state doesn't match the physical state.""" pass - async def get_tip_present_state(self, mount: OT3Mount) -> int: + async def get_tip_present_state( + self, mount: OT3Mount, expect_multiple_responses: bool = False + ) -> bool: """Get the state of the tip ejector flag for a given mount.""" pass @@ -402,11 +409,15 @@ async def get_jaw_state(self) -> GripperJawState: async def tip_action( self, - moves: Optional[List[Move[Axis]]] = None, - distance: Optional[float] = None, - velocity: Optional[float] = None, - tip_action: str = "home", - back_off: Optional[bool] = False, + moves: List[Move[Axis]], + ) -> None: + pass + + async def home_tip_motors( + self, + distance: float, + velocity: float, + back_off: bool = True, ) -> None: pass @@ -533,6 +544,14 @@ async def restore_current(self) -> AsyncIterator[None]: """Save the current.""" yield + @asynccontextmanager + async def restore_z_r_run_current(self) -> AsyncIterator[None]: + """ + Temporarily restore the active current ONLY when homing or + retracting the Z_R axis while the 96-channel is attached. + """ + yield + @ensure_yield async def watch(self, loop: asyncio.AbstractEventLoop) -> None: new_mods_at_ports = [ @@ -678,8 +697,9 @@ async def capacitive_probe( speed_mm_per_s: float, sensor_threshold_pf: float, probe: InstrumentProbeType, - ) -> None: + ) -> bool: self._position[axis_to_node(moving)] += distance_mm + return True @ensure_yield async def capacitive_pass( diff --git a/api/src/opentrons/hardware_control/backends/ot3utils.py b/api/src/opentrons/hardware_control/backends/ot3utils.py index 61f2699ccb5..2b1d50f5ade 100644 --- a/api/src/opentrons/hardware_control/backends/ot3utils.py +++ b/api/src/opentrons/hardware_control/backends/ot3utils.py @@ -437,7 +437,7 @@ def create_tip_action_group( return move_group -def create_gear_motor_home_group( +def create_tip_motor_home_group( distance: float, velocity: float, backoff: Optional[bool] = False, diff --git a/api/src/opentrons/hardware_control/backends/simulator.py b/api/src/opentrons/hardware_control/backends/simulator.py index 7766260ab11..38b27778e86 100644 --- a/api/src/opentrons/hardware_control/backends/simulator.py +++ b/api/src/opentrons/hardware_control/backends/simulator.py @@ -187,11 +187,15 @@ def module_controls(self, module_controls: AttachedModulesControl) -> None: async def update_position(self) -> Dict[str, float]: return self._position + def _unhomed_axes(self, axes: Sequence[str]) -> List[str]: + return list( + axis + for axis in axes + if not self._smoothie_driver.homed_flags.get(axis, False) + ) + def is_homed(self, axes: Sequence[str]) -> bool: - for axis in axes: - if not self._smoothie_driver.homed_flags.get(axis, False): - return False - return True + return not any(self._unhomed_axes(axes)) @ensure_yield async def move( diff --git a/api/src/opentrons/hardware_control/backends/subsystem_manager.py b/api/src/opentrons/hardware_control/backends/subsystem_manager.py index 130f265b0f9..1cc663ae99f 100644 --- a/api/src/opentrons/hardware_control/backends/subsystem_manager.py +++ b/api/src/opentrons/hardware_control/backends/subsystem_manager.py @@ -13,6 +13,7 @@ Callable, AsyncIterator, Union, + List, ) from opentrons_hardware.hardware_control import network, tools @@ -144,6 +145,10 @@ def _next_version(target: FirmwareTarget, current: int) -> int: def update_required(self) -> bool: return bool(self._updates_required) + @property + def subsystems_to_update(self) -> List[SubSystem]: + return [target_to_subsystem(t) for t in self._updates_required.keys()] + async def start(self) -> None: await self._probe_network_and_cache_fw_updates( self._expected_core_targets, True diff --git a/api/src/opentrons/hardware_control/errors.py b/api/src/opentrons/hardware_control/errors.py index 2a45464c745..f678167cf28 100644 --- a/api/src/opentrons/hardware_control/errors.py +++ b/api/src/opentrons/hardware_control/errors.py @@ -1,114 +1,43 @@ -from opentrons_shared_data.errors.exceptions import PipetteOverpressureError -from .types import OT3Mount +from typing import Optional, Dict, Any +from opentrons_shared_data.errors.exceptions import ( + MotionPlanningFailureError, + InvalidInstrumentData, + RobotInUseError, +) -class OutOfBoundsMove(RuntimeError): - def __init__(self, message: str): - self.message = message - super().__init__() +class OutOfBoundsMove(MotionPlanningFailureError): + def __init__(self, message: str, detail: Dict[str, Any]): + super().__init__(message=message, detail=detail) - def __str__(self) -> str: - return f"OutOfBoundsMove: {self.message}" - def __repr__(self) -> str: - return f"<{str(self.__class__)}: {self.message}>" - - -class ExecutionCancelledError(RuntimeError): - pass - - -class MustHomeError(RuntimeError): - pass - - -class NoTipAttachedError(RuntimeError): - pass - - -class TipAttachedError(RuntimeError): - pass - - -class InvalidMoveError(ValueError): - pass - - -class NotSupportedByHardware(ValueError): - """Error raised when attempting to use arguments and values not supported by the specific hardware.""" - - -class GripperNotAttachedError(Exception): - """An error raised if a gripper is accessed that is not attached.""" - - pass - - -class AxisNotPresentError(Exception): - """An error raised if an axis that is not present.""" - - pass - - -class FirmwareUpdateRequired(RuntimeError): - """An error raised when the firmware of the submodules needs to be updated.""" - - pass - - -class FirmwareUpdateFailed(RuntimeError): - """An error raised when a firmware update fails.""" - - pass - - -class OverPressureDetected(PipetteOverpressureError): - """An error raised when the pressure sensor max value is exceeded.""" - - def __init__(self, mount: str) -> None: - return super().__init__( - message=f"The pressure sensor on the {mount} mount has exceeded operational limits.", - detail={"mount": mount}, +class InvalidCriticalPoint(MotionPlanningFailureError): + def __init__(self, cp_name: str, instr: str, message: Optional[str] = None): + super().__init__( + message=(message or f"Critical point {cp_name} is invalid for a {instr}."), + detail={"instrument": instr, "critical point": cp_name}, ) -class InvalidPipetteName(KeyError): +class InvalidPipetteName(InvalidInstrumentData): """Raised for an invalid pipette.""" - def __init__(self, name: int, mount: OT3Mount) -> None: - self.name = name - self.mount = mount - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}: name={self.name} mount={self.mount}>" - - def __str__(self) -> str: - return f"{self.__class__.__name__}: Pipette name key {self.name} on mount {self.mount.name} is not valid" + def __init__(self, name: int, mount: str) -> None: + super().__init__( + message=f"Invalid pipette name key {name} on mount {mount}", + detail={"mount": mount, "name": name}, + ) -class InvalidPipetteModel(KeyError): +class InvalidPipetteModel(InvalidInstrumentData): """Raised for a pipette with an unknown model.""" - def __init__(self, name: str, model: str, mount: OT3Mount) -> None: - self.name = name - self.model = model - self.mount = mount + def __init__(self, name: str, model: str, mount: str) -> None: + super().__init__(detail={"mount": mount, "name": name, "model": model}) - def __repr__(self) -> str: - return f"<{self.__class__.__name__}: name={self.name}, model={self.model}, mount={self.mount}>" - def __str__(self) -> str: - return f"{self.__class__.__name__}: {self.name} on {self.mount.name} has an unknown model {self.model}" - - -class UpdateOngoingError(RuntimeError): +class UpdateOngoingError(RobotInUseError): """Error when an update is already happening.""" - def __init__(self, msg: str) -> None: - self.msg = msg - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}: msg={self.msg}>" - - def __str__(self) -> str: - return self.msg + def __init__(self, message: str) -> None: + super().__init__(message=message) diff --git a/api/src/opentrons/hardware_control/execution_manager.py b/api/src/opentrons/hardware_control/execution_manager.py index 02cdaf9b8e1..0e051799fbc 100644 --- a/api/src/opentrons/hardware_control/execution_manager.py +++ b/api/src/opentrons/hardware_control/execution_manager.py @@ -2,7 +2,7 @@ import functools from typing import Set, TypeVar, Type, cast, Callable, Any, Awaitable, overload from .types import ExecutionState -from .errors import ExecutionCancelledError +from opentrons_shared_data.errors.exceptions import ExecutionCancelledError TaskContents = TypeVar("TaskContents") diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py index e94f4c68f06..5185fce9f3e 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py @@ -45,7 +45,7 @@ CriticalPoint, BoardRevision, ) -from opentrons.hardware_control.errors import InvalidMoveError +from opentrons.hardware_control.errors import InvalidCriticalPoint from opentrons_shared_data.pipette.dev_types import ( @@ -331,9 +331,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: CriticalPoint.GRIPPER_FRONT_CALIBRATION_PIN, CriticalPoint.GRIPPER_REAR_CALIBRATION_PIN, ]: - raise InvalidMoveError( - f"Critical point {cp_override.name} is not valid for a pipette" - ) + raise InvalidCriticalPoint(cp_override.name, "pipette") if not self.has_tip or cp_override == CriticalPoint.NOZZLE: cp_type = CriticalPoint.NOZZLE diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index a73c0cdf5e2..193b3ae0737 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -17,6 +17,10 @@ ) import numpy +from opentrons_shared_data.errors.exceptions import ( + UnexpectedTipRemovalError, + UnexpectedTipAttachError, +) from opentrons_shared_data.pipette.dev_types import UlPerMmAction from opentrons_shared_data.pipette.types import Quirks from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated @@ -28,10 +32,6 @@ Axis, OT3Mount, ) -from opentrons.hardware_control.errors import ( - TipAttachedError, - NoTipAttachedError, -) from opentrons.hardware_control.constants import ( SHAKE_OFF_TIPS_SPEED, SHAKE_OFF_TIPS_PICKUP_DISTANCE, @@ -432,9 +432,11 @@ def critical_point_for( # configured such that the end of a p300 single gen1's tip is 0. return top_types.Point(0, 0, 30) - def ready_for_tip_action(self, target: Pipette, action: HardwareAction) -> None: + def ready_for_tip_action( + self, target: Pipette, action: HardwareAction, mount: MountType + ) -> None: if not target.has_tip: - raise NoTipAttachedError(f"Cannot perform {action} without a tip attached") + raise UnexpectedTipRemovalError(action.name, target.name, mount.name) if ( action == HardwareAction.ASPIRATE and target.current_volume == 0 @@ -497,7 +499,7 @@ def plan_check_aspirate( # type: ignore[no-untyped-def] - Plunger distances (possibly calling an overridden plunger_volume) """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE) + self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE, mount) if volume is None: self._ihp_log.debug( "No aspirate volume defined. Aspirating up to " @@ -579,7 +581,7 @@ def plan_check_dispense( # type: ignore[no-untyped-def] """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DISPENSE) + self.ready_for_tip_action(instrument, HardwareAction.DISPENSE, mount) if volume is None: disp_vol = instrument.current_volume @@ -660,7 +662,7 @@ def plan_check_blow_out(self, mount: OT3Mount) -> LiquidActionSpec: def plan_check_blow_out(self, mount): # type: ignore[no-untyped-def] """Check preconditions and calculate values for blowout.""" instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT) + self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT, mount) speed = self.plunger_speed( instrument, instrument.blow_out_flow_rate, "dispense" ) @@ -736,7 +738,7 @@ def plan_check_pick_up_tip( # type: ignore[no-untyped-def] # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) if instrument.has_tip: - raise TipAttachedError("Cannot pick up tip with a tip attached") + raise UnexpectedTipAttachError("pick_up_tip", instrument.name, mount.name) self._ihp_log.debug(f"Picking up tip on {mount.name}") if presses is None or presses < 0: @@ -894,7 +896,7 @@ def plan_check_drop_tip( # type: ignore[no-untyped-def] home_after, ): instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DROPTIP) + self.ready_for_tip_action(instrument, HardwareAction.DROPTIP, mount) bottom = instrument.plunger_positions.bottom droptip = instrument.plunger_positions.drop_tip diff --git a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py index 11d715b4aee..7eb757fd333 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py @@ -12,7 +12,9 @@ CriticalPoint, GripperJawState, ) -from opentrons.hardware_control.errors import InvalidMoveError +from opentrons.hardware_control.errors import ( + InvalidCriticalPoint, +) from .instrument_calibration import ( GripperCalibrationOffset, load_gripper_calibration_offset, @@ -20,6 +22,7 @@ ) from ..instrument_abc import AbstractInstrument from opentrons.hardware_control.dev_types import AttachedGripper, GripperDict +from opentrons_shared_data.errors.exceptions import CommandPreconditionViolated from opentrons_shared_data.gripper import ( GripperDefinition, @@ -177,9 +180,21 @@ def save_offset(self, delta: Point) -> GripperCalibrationOffset: def check_calibration_pin_location_is_accurate(self) -> None: if not self.attached_probe: - raise RuntimeError("must attach a probe before starting calibration") + raise CommandPreconditionViolated( + "Cannot calibrate gripper without attaching a calibration probe", + detail={ + "probe": self._attached_probe, + "jaw_state": self.state, + }, + ) if self.state != GripperJawState.GRIPPING: - raise RuntimeError("must grip the jaws before starting calibration") + raise CommandPreconditionViolated( + "Cannot calibrate gripper if jaw is not in gripping state", + detail={ + "probe": self._attached_probe, + "jaw_state": self.state, + }, + ) def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: """ @@ -187,9 +202,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: between the center of the gripper engagement volume and the calibration pins. """ if cp_override in [CriticalPoint.NOZZLE, CriticalPoint.TIP]: - raise InvalidMoveError( - f"Critical point {cp_override.name} is not valid for a gripper" - ) + raise InvalidCriticalPoint(cp_override.name, "gripper") if not self._attached_probe: cp = cp_override or CriticalPoint.GRIPPER_JAW_CENTER @@ -216,7 +229,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: - Point(y=self.current_jaw_displacement) ) else: - raise InvalidMoveError(f"Critical point {cp_override} is not valid") + raise InvalidCriticalPoint(cp.name, "gripper") def duty_cycle_by_force(self, newton: float) -> float: return gripper_config.duty_cycle_by_force(newton, self.grip_force_profile) diff --git a/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py index 8de02635b7d..51778b08b92 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py @@ -13,9 +13,10 @@ GripperJawState, GripperProbe, ) -from opentrons.hardware_control.errors import ( - InvalidMoveError, - GripperNotAttachedError, +from opentrons.hardware_control.errors import InvalidCriticalPoint +from opentrons_shared_data.errors.exceptions import ( + GripperNotPresentError, + CommandPreconditionViolated, ) from .gripper import Gripper @@ -23,18 +24,6 @@ MOD_LOG = logging.getLogger(__name__) -class GripError(Exception): - """An error raised if a gripper action is blocked""" - - pass - - -class CalibrationError(Exception): - """An error raised if a gripper calibration is blocked""" - - pass - - class GripperHandler: GH_LOG = MOD_LOG.getChild("GripperHandler") @@ -48,8 +37,8 @@ def has_gripper(self) -> bool: def get_gripper(self) -> Gripper: gripper = self._gripper if not gripper: - raise GripperNotAttachedError( - "Cannot perform action without gripper attached" + raise GripperNotPresentError( + message="Cannot perform action without gripper attached" ) return gripper @@ -94,9 +83,13 @@ def save_instrument_offset(self, delta: Point) -> GripperCalibrationOffset: def get_critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: if not self._gripper: - raise GripperNotAttachedError() + raise GripperNotPresentError() if cp_override == CriticalPoint.MOUNT: - raise InvalidMoveError("The gripper mount may not be moved directly.") + raise InvalidCriticalPoint( + cp_override.name, + "gripper", + "The gripper mount may not be moved directly.", + ) return self._gripper.critical_point(cp_override) def get_gripper_dict(self) -> Optional[GripperDict]: @@ -132,11 +125,17 @@ def check_ready_for_calibration(self) -> None: gripper = self.get_gripper() gripper.check_calibration_pin_location_is_accurate() - def check_ready_for_jaw_move(self) -> None: + def check_ready_for_jaw_move(self, command: str) -> None: """Raise an exception if it is not currently valid to move the jaw.""" gripper = self.get_gripper() if gripper.state == GripperJawState.UNHOMED: - raise GripError("Gripper jaw must be homed before moving") + raise CommandPreconditionViolated( + message=f"Cannot {command} gripper jaw before homing", + detail={ + "command": command, + "jaw_state": gripper.state, + }, + ) def is_ready_for_idle(self) -> bool: """Gripper can idle when the jaw is not currently gripping.""" diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py index 4039e623b27..57b131ecc41 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py @@ -13,6 +13,7 @@ MotorConfigurations, SupportedTipsDefinition, TipHandlingConfigurations, + PlungerHomingConfigurations, PipetteNameType, PipetteModelVersionType, PipetteLiquidPropertiesDefinition, @@ -44,7 +45,7 @@ types as pip_types, ) from opentrons.hardware_control.types import CriticalPoint, OT3Mount -from opentrons.hardware_control.errors import InvalidMoveError +from opentrons.hardware_control.errors import InvalidCriticalPoint mod_log = logging.getLogger(__name__) @@ -72,6 +73,7 @@ def __init__( self._config_as_dict = config.dict() self._plunger_motor_current = config.plunger_motor_configurations self._pick_up_configurations = config.pick_up_tip_configurations + self._plunger_homing_configurations = config.plunger_homing_configurations self._drop_configurations = config.drop_tip_configurations self._pipette_offset = pipette_offset_cal self._pipette_type = self._config.pipette_type @@ -185,6 +187,10 @@ def pick_up_configurations( ) -> None: self._pick_up_configurations = pick_up_configs + @property + def plunger_homing_configurations(self) -> PlungerHomingConfigurations: + return self._plunger_homing_configurations + @property def drop_configurations(self) -> TipHandlingConfigurations: return self._drop_configurations @@ -315,9 +321,7 @@ def critical_point(self, cp_override: Optional[CriticalPoint] = None) -> Point: CriticalPoint.GRIPPER_FRONT_CALIBRATION_PIN, CriticalPoint.GRIPPER_REAR_CALIBRATION_PIN, ]: - raise InvalidMoveError( - f"Critical point {cp_override.name} is not valid for a pipette" - ) + raise InvalidCriticalPoint(cp_override.name, "pipette") if not self.has_tip or cp_override == CriticalPoint.NOZZLE: cp_type = CriticalPoint.NOZZLE tip_length = 0.0 diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index 1f1b70ac4fa..d5d7a607b2f 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -20,6 +20,8 @@ from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, + UnexpectedTipRemovalError, + UnexpectedTipAttachError, ) from opentrons_shared_data.pipette.pipette_definition import ( liquid_class_for_volume_between_default_and_defaultlowvolume, @@ -32,10 +34,6 @@ Axis, OT3Mount, ) -from opentrons.hardware_control.errors import ( - TipAttachedError, - NoTipAttachedError, -) from opentrons.hardware_control.constants import ( SHAKE_OFF_TIPS_SPEED, SHAKE_OFF_TIPS_PICKUP_DISTANCE, @@ -487,9 +485,11 @@ def critical_point_for( else: return top_types.Point(0, 0, 0) - def ready_for_tip_action(self, target: Pipette, action: HardwareAction) -> None: + def ready_for_tip_action( + self, target: Pipette, action: HardwareAction, mount: OT3Mount + ) -> None: if not target.has_tip: - raise NoTipAttachedError(f"Cannot perform {action} without a tip attached") + raise UnexpectedTipRemovalError(str(action), target.name, mount.name) if ( action == HardwareAction.ASPIRATE and target.current_volume == 0 @@ -545,7 +545,7 @@ def plan_check_aspirate( - Plunger distances (possibly calling an overridden plunger_volume) """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE) + self.ready_for_tip_action(instrument, HardwareAction.ASPIRATE, mount) if volume is None: self._ihp_log.debug( "No aspirate volume defined. Aspirating up to " @@ -606,7 +606,7 @@ def plan_check_dispense( """ instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DISPENSE) + self.ready_for_tip_action(instrument, HardwareAction.DISPENSE, mount) if volume is None: disp_vol = instrument.current_volume @@ -674,7 +674,7 @@ def plan_check_blow_out( ) -> LiquidActionSpec: """Check preconditions and calculate values for blowout.""" instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT) + self.ready_for_tip_action(instrument, HardwareAction.BLOWOUT, mount) speed = self.plunger_speed(instrument, instrument.blow_out_flow_rate, "blowout") acceleration = self.plunger_acceleration( instrument, instrument.flow_acceleration @@ -734,7 +734,7 @@ def plan_check_pick_up_tip( # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) if instrument.has_tip: - raise TipAttachedError("Cannot pick up tip with a tip attached") + raise UnexpectedTipAttachError("pick_up_tip", instrument.name, mount.name) self._ihp_log.debug(f"Picking up tip on {mount.name}") def add_tip_to_instr() -> None: @@ -880,7 +880,7 @@ def plan_check_drop_tip( home_after: bool, ) -> Tuple[DropTipSpec, Callable[[], None]]: instrument = self.get_pipette(mount) - self.ready_for_tip_action(instrument, HardwareAction.DROPTIP) + self.ready_for_tip_action(instrument, HardwareAction.DROPTIP, mount) is_96_chan = instrument.channels == 96 diff --git a/api/src/opentrons/hardware_control/ot3_calibration.py b/api/src/opentrons/hardware_control/ot3_calibration.py index 8a6c04ff477..6bca60bfed3 100644 --- a/api/src/opentrons/hardware_control/ot3_calibration.py +++ b/api/src/opentrons/hardware_control/ot3_calibration.py @@ -7,13 +7,14 @@ import datetime import numpy as np from enum import Enum -from math import floor, copysign, isclose +from math import floor, copysign from logging import getLogger from opentrons.util.linal import solve_attitude, SolvePoints from .types import OT3Mount, Axis, GripperProbe from opentrons.types import Point from opentrons.config.types import CapacitivePassSettings, EdgeSenseSettings, OT3Config +from opentrons.hardware_control.types import InstrumentProbeType import json from opentrons_shared_data.deck import ( @@ -66,6 +67,10 @@ "top": Point(*SQUARE_EDGES["top"]), "bottom": Point(*SQUARE_EDGES["bottom"]), } +OFFSET_SECONDARY_PROBE = { + 8: Point(x=0, y=9 * 7, z=0), + 96: Point(x=9 * -11, y=9 * 7, z=0), +} class CalibrationMethod(Enum): @@ -107,18 +112,14 @@ class AlignmentShift(Enum): } -def _deck_hit( +def _verify_height( found_pos: float, expected_pos: float, settings: EdgeSenseSettings -) -> bool: +) -> None: """ - Evaluate the height found by capacitive probe against search settings - to determine whether or not it had hit the deck. + Evaluate the height found by capacitive probe against search settings. """ if found_pos > expected_pos + settings.early_sense_tolerance_mm: raise EarlyCapacitiveSenseTrigger(found_pos, expected_pos) - return ( - True if found_pos >= (expected_pos - settings.overrun_tolerance_mm) else False - ) async def _verify_edge_pos( @@ -128,6 +129,7 @@ async def _verify_edge_pos( found_edge: Point, last_stride: float, search_direction: Literal[1, -1], + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> None: """ Probe both sides of the found edge in the search axis and compare the results. @@ -147,10 +149,10 @@ async def _verify_edge_pos( LOG.info( f"Checking {edge_name_str} in {dir} direction at {checking_pos}, stride_size: {check_stride}" ) - height = await _probe_deck_at( - hcapi, mount, checking_pos, edge_settings.pass_settings + height, hit_deck = await _probe_deck_at( + hcapi, mount, checking_pos, edge_settings.pass_settings, probe=probe ) - hit_deck = _deck_hit(height, found_edge.z, edge_settings) + _verify_height(height, found_edge.z, edge_settings) LOG.info(f"Deck {'hit' if hit_deck else 'miss'} at check pos: {checking_pos}") if last_result is not None and hit_deck != last_result: LOG.info( @@ -181,6 +183,7 @@ async def find_edge_binary( search_axis: Union[Literal[Axis.X, Axis.Y]], direction_if_hit: Literal[1, -1], raise_verify_error: bool = True, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Point: """ Find the true position of one edge of the calibration slot in the deck. @@ -215,10 +218,10 @@ async def find_edge_binary( final_z_height_found = slot_edge_nominal.z for _ in range(edge_settings.search_iteration_limit): LOG.info(f"Checking position {checking_pos}") - interaction_pos = await _probe_deck_at( - hcapi, mount, checking_pos, edge_settings.pass_settings + interaction_pos, hit_deck = await _probe_deck_at( + hcapi, mount, checking_pos, edge_settings.pass_settings, probe=probe ) - hit_deck = _deck_hit(interaction_pos, checking_pos.z, edge_settings) + _verify_height(interaction_pos, checking_pos.z, edge_settings) if hit_deck: # In this block, we've hit the deck LOG.info(f"hit at {interaction_pos}, stride size: {stride}") @@ -248,7 +251,13 @@ async def find_edge_binary( try: await _verify_edge_pos( - hcapi, mount, search_axis, checking_pos, abs(stride * 2), direction_if_hit + hcapi, + mount, + search_axis, + checking_pos, + abs(stride * 2), + direction_if_hit, + probe=probe, ) except EdgeNotFoundError as e: if raise_verify_error: @@ -267,6 +276,7 @@ async def find_slot_center_binary( mount: OT3Mount, estimated_center: Point, raise_verify_error: bool = True, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Point: """Find the center of the calibration slot by binary-searching its edges. Returns the XY-center of the slot. @@ -279,18 +289,31 @@ async def find_slot_center_binary( Axis.X, -1, raise_verify_error, + probe=probe, ) LOG.info(f"Found +x edge at {plus_x_edge.x}mm") estimated_center = estimated_center._replace(x=plus_x_edge.x - EDGES["right"].x) plus_y_edge = await find_edge_binary( - hcapi, mount, estimated_center + EDGES["top"], Axis.Y, -1, raise_verify_error + hcapi, + mount, + estimated_center + EDGES["top"], + Axis.Y, + -1, + raise_verify_error, + probe=probe, ) LOG.info(f"Found +y edge at {plus_y_edge.y}mm") estimated_center = estimated_center._replace(y=plus_y_edge.y - EDGES["top"].y) minus_x_edge = await find_edge_binary( - hcapi, mount, estimated_center + EDGES["left"], Axis.X, 1, raise_verify_error + hcapi, + mount, + estimated_center + EDGES["left"], + Axis.X, + 1, + raise_verify_error, + probe=probe, ) LOG.info(f"Found -x edge at {minus_x_edge.x}mm") estimated_center = estimated_center._replace(x=(plus_x_edge.x + minus_x_edge.x) / 2) @@ -302,6 +325,7 @@ async def find_slot_center_binary( Axis.Y, 1, raise_verify_error, + probe=probe, ) LOG.info(f"Found -y edge at {minus_y_edge.y}mm") estimated_center = estimated_center._replace(y=(plus_y_edge.y + minus_y_edge.y) / 2) @@ -313,7 +337,10 @@ async def find_slot_center_binary( async def find_calibration_structure_height( - hcapi: OT3API, mount: OT3Mount, nominal_center: Point + hcapi: OT3API, + mount: OT3Mount, + nominal_center: Point, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> float: """ Find the height of the calibration structure in this mount's frame of reference. @@ -327,9 +354,11 @@ async def find_calibration_structure_height( """ z_pass_settings = hcapi.config.calibration.z_offset.pass_settings z_prep_point = nominal_center + PREP_OFFSET_DEPTH - structure_z = await _probe_deck_at(hcapi, mount, z_prep_point, z_pass_settings) z_limit = nominal_center.z - z_pass_settings.max_overrun_distance_mm - if (structure_z < z_limit) or isclose(z_limit, structure_z, abs_tol=0.001): + structure_z, hit_deck = await _probe_deck_at( + hcapi, mount, z_prep_point, z_pass_settings, probe=probe + ) + if not hit_deck: raise CalibrationStructureNotFoundError(structure_z, z_limit) LOG.info(f"autocalibration: found structure at {structure_z}") return structure_z @@ -341,7 +370,8 @@ async def _probe_deck_at( target: Point, settings: CapacitivePassSettings, speed: float = 50, -) -> float: + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, +) -> Tuple[float, bool]: here = await api.gantry_position(mount) abs_transit_height = max( target.z + LINEAR_TRANSIT_HEIGHT, target.z + settings.prep_distance_mm @@ -350,13 +380,13 @@ async def _probe_deck_at( await api.move_to(mount, here._replace(z=safe_height)) await api.move_to(mount, target._replace(z=safe_height), speed=speed) await api.move_to(mount, target._replace(z=abs_transit_height)) - _found_pos = await api.capacitive_probe( - mount, Axis.by_mount(mount), target.z, settings + _found_pos, contact = await api.capacitive_probe( + mount, Axis.by_mount(mount), target.z, settings, probe=probe ) # don't use found Z position to calculate an updated transit height # because the probe may have gone through the hole await api.move_to(mount, target._replace(z=abs_transit_height)) - return _found_pos + return _found_pos, contact async def find_axis_center( @@ -527,12 +557,13 @@ async def find_calibration_structure_center( nominal_center: Point, method: CalibrationMethod = CalibrationMethod.BINARY_SEARCH, raise_verify_error: bool = True, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Point: # Perform xy offset search if method == CalibrationMethod.BINARY_SEARCH: found_center = await find_slot_center_binary( - hcapi, mount, nominal_center, raise_verify_error + hcapi, mount, nominal_center, raise_verify_error, probe=probe ) elif method == CalibrationMethod.NONCONTACT_PASS: # FIXME: use slot to find ideal position @@ -548,6 +579,7 @@ async def _calibrate_mount( slot: int = SLOT_CENTER, method: CalibrationMethod = CalibrationMethod.BINARY_SEARCH, raise_verify_error: bool = True, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Point: """ Run automatic calibration for the tool attached to the specified mount. @@ -574,6 +606,10 @@ async def _calibrate_mount( from the current instrument offset to set a new instrument offset. """ nominal_center = Point(*get_calibration_square_position_in_slot(slot)) + if probe == InstrumentProbeType.SECONDARY and mount != OT3Mount.GRIPPER: + pip = hcapi.hardware_instruments[mount.to_mount()] + num_channels = int(pip.channels) # type: ignore[union-attr] + nominal_center += OFFSET_SECONDARY_PROBE.get(num_channels, Point()) async with hcapi.restore_system_constrants(): await hcapi.set_system_constraints_for_calibration() try: @@ -584,6 +620,7 @@ async def _calibrate_mount( nominal_center, method=method, raise_verify_error=raise_verify_error, + probe=probe, ) # update center with values obtained during calibration LOG.info(f"Found calibration value {offset} for mount {mount.name}") @@ -610,16 +647,19 @@ async def find_calibration_structure_position( method: CalibrationMethod = CalibrationMethod.BINARY_SEARCH, target: CalibrationTarget = CalibrationTarget.GANTRY_INSTRUMENT, raise_verify_error: bool = True, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Point: """Find the calibration square offset given an arbitry postition on the deck.""" # Find the estimated structure plate height. This will be used to baseline the edge detection points. - z_height = await find_calibration_structure_height(hcapi, mount, nominal_center) + z_height = await find_calibration_structure_height( + hcapi, mount, nominal_center, probe=probe + ) initial_center = nominal_center._replace(z=z_height) LOG.info(f"Found structure plate at {z_height}mm") # Find the calibration square center using the given method found_center = await find_calibration_structure_center( - hcapi, mount, initial_center, method, raise_verify_error + hcapi, mount, initial_center, method, raise_verify_error, probe=probe ) offset = nominal_center - found_center @@ -734,7 +774,12 @@ async def calibrate_gripper_jaw( hcapi.add_gripper_probe(probe) await hcapi.grip(GRIPPER_GRIP_FORCE) offset = await _calibrate_mount( - hcapi, OT3Mount.GRIPPER, slot, method, raise_verify_error + hcapi, + OT3Mount.GRIPPER, + slot, + method, + raise_verify_error, + probe=probe.to_type(probe), ) LOG.info(f"Gripper {probe.name} probe offset: {offset}") return offset @@ -759,6 +804,7 @@ async def find_pipette_offset( method: CalibrationMethod = CalibrationMethod.BINARY_SEARCH, raise_verify_error: bool = True, reset_instrument_offset: bool = True, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Point: """ Run automatic calibration for pipette and only return the calibration point. @@ -774,7 +820,9 @@ async def find_pipette_offset( if reset_instrument_offset: await hcapi.reset_instrument_offset(mount) await hcapi.add_tip(mount, hcapi.config.calibration.probe_length) - offset = await _calibrate_mount(hcapi, mount, slot, method, raise_verify_error) + offset = await _calibrate_mount( + hcapi, mount, slot, method, raise_verify_error, probe=probe + ) return offset finally: await hcapi.remove_tip(mount) @@ -786,6 +834,7 @@ async def calibrate_pipette( slot: int = 5, method: CalibrationMethod = CalibrationMethod.BINARY_SEARCH, raise_verify_error: bool = True, + probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, ) -> Point: """ Run automatic calibration for pipette and save the offset. @@ -795,7 +844,9 @@ async def calibrate_pipette( tip has been attached, or the conductive probe has been attached, or the probe has been lowered). """ - offset = await find_pipette_offset(hcapi, mount, slot, method, raise_verify_error) + offset = await find_pipette_offset( + hcapi, mount, slot, method, raise_verify_error, probe=probe + ) await hcapi.save_instrument_offset(mount, offset) return offset diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 6d48e3d7d61..dab277c8c29 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -1,7 +1,7 @@ import asyncio from concurrent.futures import Future import contextlib -from functools import partial, lru_cache +from functools import partial, lru_cache, wraps from dataclasses import replace import logging from copy import deepcopy @@ -20,6 +20,7 @@ TypeVar, Tuple, Mapping, + Awaitable, ) from opentrons.hardware_control.modules.module_calibration import ( ModuleCalibrationOffset, @@ -53,6 +54,14 @@ ) from opentrons_hardware.hardware_control.motion import MoveStopCondition +from opentrons_shared_data.errors.exceptions import ( + EnumeratedError, + PythonException, + PositionUnknownError, + GripperNotPresentError, + InvalidActuator, + FirmwareUpdateFailedError, +) from .util import use_or_initialize_loop, check_motion_bounds @@ -103,11 +112,7 @@ EstopState, ) from .errors import ( - MustHomeError, - GripperNotAttachedError, - AxisNotPresentError, UpdateOngoingError, - FirmwareUpdateFailed, ) from . import modules from .ot3_calibration import OT3Transforms, OT3RobotCalibrationProvider @@ -161,6 +166,24 @@ Axis.Q, ) +Wrapped = TypeVar("Wrapped", bound=Callable[..., Awaitable[Any]]) + + +def _adjust_high_throughput_z_current(func: Wrapped) -> Wrapped: + """ + A decorator that temproarily and conditionally changes the active current (based on the axis input) + before a function is executed and the cleans up afterwards + """ + # only home and retract should be wrappeed by this decorator + @wraps(func) + async def wrapper(self: Any, axis: Axis, *args: Any, **kwargs: Any) -> Any: + async with contextlib.AsyncExitStack() as stack: + if axis == Axis.Z_R and self.gantry_load == GantryLoad.HIGH_THROUGHPUT: + await stack.enter_async_context(self._backend.restore_z_r_run_current()) + return await func(self, axis, *args, **kwargs) + + return cast(Wrapped, wrapper) + class OT3API( ExecutionManagerProvider, @@ -475,9 +498,14 @@ async def update_firmware( yield update_status except SubsystemUpdating as e: raise UpdateOngoingError(e.msg) from e - except Exception as e: + except EnumeratedError: + raise + except BaseException as e: mod_log.exception("Firmware update failed") - raise FirmwareUpdateFailed() from e + raise FirmwareUpdateFailedError( + message="Update failed because of uncaught error", + wrapping=[PythonException(e)], + ) from e # Incidentals (i.e. not motion) API @@ -799,7 +827,7 @@ async def home_gripper_jaw(self) -> None: gripper.default_home_force ) await self._ungrip(duty_cycle=dc) - except GripperNotAttachedError: + except GripperNotPresentError: pass async def home_plunger(self, mount: Union[top_types.Mount, OT3Mount]) -> None: @@ -817,6 +845,41 @@ async def home_plunger(self, mount: Union[top_types.Mount, OT3Mount]) -> None: checked_mount, rate=1.0, acquire_lock=False ) + async def home_gear_motors(self) -> None: + homing_velocity = self._config.motion_settings.max_speed_discontinuity[ + GantryLoad.HIGH_THROUGHPUT + ][OT3AxisKind.Q] + + # if position is not known, move toward limit switch at a constant velocity + if not any(self._backend.gear_motor_position): + await self._backend.home_tip_motors( + distance=self._backend.axis_bounds[Axis.Q][1], + velocity=homing_velocity, + ) + return + + current_pos_float = axis_convert(self._backend.gear_motor_position, 0.0)[ + Axis.P_L + ] + + if current_pos_float > self._config.safe_home_distance: + fast_home_moves = self._build_moves( + {Axis.Q: current_pos_float}, {Axis.Q: self._config.safe_home_distance} + ) + # move toward home until a safe distance + await self._backend.tip_action(moves=fast_home_moves[0]) + + # update current position + current_pos_float = axis_convert(self._backend.gear_motor_position, 0.0)[ + Axis.P_L + ] + + # move until the limit switch is triggered, with no acceleration + await self._backend.home_tip_motors( + distance=(current_pos_float + self._config.safe_home_distance), + velocity=homing_velocity, + ) + @lru_cache(1) def _carriage_offset(self) -> top_types.Point: return top_types.Point(*self._config.carriage_offset) @@ -842,17 +905,20 @@ async def current_position_ot3( specified mount. """ if mount == OT3Mount.GRIPPER and not self._gripper_handler.has_gripper(): - raise GripperNotAttachedError( - f"Cannot return position for {mount} if no gripper is attached" + raise GripperNotPresentError( + message=f"Cannot return position for {mount} if no gripper is attached", + detail={"mount": str(mount)}, ) - + mount_axes = [Axis.X, Axis.Y, Axis.by_mount(mount)] if refresh: await self.refresh_positions() elif not self._current_position: - raise MustHomeError( - f"Motor positions for {str(mount)} are missing; must first home motors." + raise PositionUnknownError( + message=f"Motor positions for {str(mount)} mount are missing (" + f"{mount_axes}); must first home motors.", + detail={"mount": str(mount), "missing_axes": mount_axes}, ) - self._assert_motor_ok([Axis.X, Axis.Y, Axis.by_mount(mount)]) + self._assert_motor_ok(mount_axes) return self._effector_pos_from_carriage_pos( OT3Mount.from_mount(mount), self._current_position, critical_point @@ -870,7 +936,7 @@ async def _refresh_jaw_state(self) -> None: try: gripper = self._gripper_handler.get_gripper() gripper.state = await self._backend.get_jaw_state() - except GripperNotAttachedError: + except GripperNotPresentError: pass async def _cache_current_position(self) -> Dict[Axis, float]: @@ -893,16 +959,18 @@ def _assert_motor_ok(self, axes: Sequence[Axis]) -> None: invalid_axes = self._backend.get_invalid_motor_axes(axes) if invalid_axes: axes_str = ",".join([ax.name for ax in invalid_axes]) - raise MustHomeError( - f"Motor position of axes ({axes_str}) is invalid; please home motors." + raise PositionUnknownError( + message=f"Motor position of axes ({axes_str}) is invalid; please home motors.", + detail={"axes": axes_str}, ) def _assert_encoder_ok(self, axes: Sequence[Axis]) -> None: invalid_axes = self._backend.get_invalid_motor_axes(axes) if invalid_axes: axes_str = ",".join([ax.name for ax in invalid_axes]) - raise MustHomeError( - f"Encoder position of axes ({axes_str}) is invalid; please home motors." + raise PositionUnknownError( + message=f"Encoder position of axes ({axes_str}) is invalid; please home motors.", + detail={"axes": axes_str}, ) async def encoder_current_position( @@ -928,13 +996,15 @@ async def encoder_current_position_ot3( if refresh: await self.refresh_positions() elif not self._encoder_position: - raise MustHomeError( - f"Encoder positions for {str(mount)} are missing; must first home motors." + raise PositionUnknownError( + message=f"Encoder positions for {str(mount)} are missing; must first home motors.", + detail={"mount": str(mount)}, ) if mount == OT3Mount.GRIPPER and not self._gripper_handler.has_gripper(): - raise GripperNotAttachedError( - f"Cannot return encoder position for {mount} if no gripper is attached" + raise GripperNotPresentError( + message=f"Cannot return encoder position for {mount} if no gripper is attached", + detail={"mount": str(mount)}, ) self._assert_encoder_ok([Axis.X, Axis.Y, Axis.by_mount(mount)]) @@ -1009,6 +1079,15 @@ async def move_to( realmount = OT3Mount.from_mount(mount) axes_moving = [Axis.X, Axis.Y, Axis.by_mount(mount)] + if ( + self.gantry_load == GantryLoad.HIGH_THROUGHPUT + and realmount == OT3Mount.RIGHT + ): + raise RuntimeError( + f"unable to move {realmount.name} " + f"with {self.gantry_load.name} gantry load" + ) + # Cache current position from backend if not self._current_position: await self.refresh_positions() @@ -1055,7 +1134,9 @@ async def move_axes( # noqa: C901 for axis in position.keys(): if not self._backend.axis_is_present(axis): - raise AxisNotPresentError(f"{axis} is not present") + raise InvalidActuator( + message=f"{axis} is not present", detail={"axis": str(axis)} + ) if not self._backend.check_encoder_status(list(position.keys())): await self.home() @@ -1116,6 +1197,15 @@ async def move_rel( realmount = OT3Mount.from_mount(mount) axes_moving = [Axis.X, Axis.Y, Axis.by_mount(mount)] + if ( + self.gantry_load == GantryLoad.HIGH_THROUGHPUT + and realmount == OT3Mount.RIGHT + ): + raise RuntimeError( + f"unable to move {realmount.name} " + f"with {self.gantry_load.name} gantry load" + ) + if not self._backend.check_encoder_status(axes_moving): await self.home() @@ -1164,7 +1254,7 @@ async def idle_gripper(self) -> None: force_newtons=gripper.default_idle_force, stay_engaged=False, ) - except GripperNotAttachedError: + except GripperNotPresentError: pass def _build_moves( @@ -1237,6 +1327,52 @@ async def _move( await self._cache_current_position() await self._cache_encoder_position() + async def _set_plunger_current_and_home( + self, + axis: Axis, + motor_ok: bool, + encoder_ok: bool, + ) -> None: + mount = Axis.to_ot3_mount(axis) + instr = self._pipette_handler.hardware_instruments[mount] + if instr is None: + self._log.warning("no pipette found") + return + + origin, target_pos = await self._retrieve_home_position(axis) + + if encoder_ok and motor_ok: + if origin[axis] - target_pos[axis] > self._config.safe_home_distance: + target_pos[axis] += self._config.safe_home_distance + moves = self._build_moves( + origin, target_pos, instr.config.plunger_homing_configurations.speed + ) + async with self._backend.restore_current(): + await self._backend.set_active_current( + {axis: instr.config.plunger_homing_configurations.current} + ) + await self._backend.move( + origin, + moves[0], + MoveStopCondition.none, + ) + await self._backend.home([axis], self.gantry_load) + else: + async with self._backend.restore_current(): + await self._backend.set_active_current( + {axis: instr.config.plunger_homing_configurations.current} + ) + await self._backend.home([axis], self.gantry_load) + + async def _retrieve_home_position( + self, axis: Axis + ) -> Tuple[OT3AxisMap[float], OT3AxisMap[float]]: + origin = await self._backend.update_position() + target_pos = {ax: pos for ax, pos in origin.items()} + target_pos.update({axis: self._backend.home_position()[axis]}) + return origin, target_pos + + @_adjust_high_throughput_z_current async def _home_axis(self, axis: Axis) -> None: """ Perform home; base on axis motor/encoder statuses, shorten homing time @@ -1253,14 +1389,6 @@ async def _home_axis(self, axis: Axis) -> None: switch will not be triggered. """ - async def _retrieve_home_position() -> Tuple[ - OT3AxisMap[float], OT3AxisMap[float] - ]: - origin = await self._backend.update_position() - target_pos = {ax: pos for ax, pos in origin.items()} - target_pos.update({axis: self._backend.home_position()[axis]}) - return origin, target_pos - # G, Q should be handled in the backend through `self._home()` assert axis not in [Axis.G, Axis.Q] @@ -1275,10 +1403,14 @@ async def _retrieve_home_position() -> Tuple[ motor_ok = self._backend.check_motor_status([axis]) encoder_ok = self._backend.check_encoder_status([axis]) + if Axis.to_kind(axis) == OT3AxisKind.P: + await self._set_plunger_current_and_home(axis, motor_ok, encoder_ok) + return + # we can move to safe home distance! if encoder_ok and motor_ok: - origin, target_pos = await _retrieve_home_position() - if Axis.to_kind(axis) in [OT3AxisKind.Z, OT3AxisKind.P]: + origin, target_pos = await self._retrieve_home_position(axis) + if Axis.to_kind(axis) == OT3AxisKind.Z: axis_home_dist = self._config.safe_home_distance else: # FIXME: (AA 2/15/23) This is a temporary workaround because of @@ -1346,7 +1478,6 @@ async def home( checked_axes = [ax for ax in Axis if ax != Axis.Q] if self.gantry_load == GantryLoad.HIGH_THROUGHPUT: checked_axes.append(Axis.Q) - if skip: checked_axes = [ax for ax in checked_axes if ax not in skip] self._log.info(f"Homing {axes}") @@ -1388,6 +1519,7 @@ async def retract( await self.retract_axis(Axis.by_mount(mount)) @ExecutionManagerProvider.wait_for_running + @_adjust_high_throughput_z_current async def retract_axis(self, axis: Axis) -> None: """ Move an axis to its home position, without engaging the limit switch, @@ -1496,7 +1628,7 @@ async def _hold_jaw_width(self, jaw_width_mm: float) -> None: async def grip( self, force_newtons: Optional[float] = None, stay_engaged: bool = True ) -> None: - self._gripper_handler.check_ready_for_jaw_move() + self._gripper_handler.check_ready_for_jaw_move("grip") dc = self._gripper_handler.get_duty_cycle_by_grip_force( force_newtons or self._gripper_handler.get_gripper().default_grip_force ) @@ -1509,7 +1641,7 @@ async def ungrip(self, force_newtons: Optional[float] = None) -> None: To simply open the jaw, use `home_gripper_jaw` instead. """ # get default grip force for release if not provided - self._gripper_handler.check_ready_for_jaw_move() + self._gripper_handler.check_ready_for_jaw_move("ungrip") # TODO: check jaw width to make sure it is actually gripping something dc = self._gripper_handler.get_duty_cycle_by_grip_force( force_newtons or self._gripper_handler.get_gripper().default_home_force @@ -1517,7 +1649,7 @@ async def ungrip(self, force_newtons: Optional[float] = None) -> None: await self._ungrip(duty_cycle=dc) async def hold_jaw_width(self, jaw_width_mm: int) -> None: - self._gripper_handler.check_ready_for_jaw_move() + self._gripper_handler.check_ready_for_jaw_move("hold_jaw_width") await self._hold_jaw_width(jaw_width_mm) async def _move_to_plunger_bottom( @@ -1530,7 +1662,8 @@ async def _move_to_plunger_bottom( Possible events where this occurs: 1. After homing the plunger - 2. Between a blow-out and an aspiration (eg: re-using tips) + 2. After picking up a new tip + 3. Between a blow-out and an aspiration (eg: re-using tips) Three possible physical tip states when this happens: @@ -1551,45 +1684,54 @@ async def _move_to_plunger_bottom( instrument = self._pipette_handler.get_pipette(checked_mount) if instrument.current_volume > 0: raise RuntimeError("cannot position plunger while holding liquid") + # target position is plunger BOTTOM target_pos = target_position_from_plunger( OT3Mount.from_mount(mount), instrument.plunger_positions.bottom, self._current_position, ) - pip_ax = Axis.of_main_tool_actuator(mount) - current_pos = self._current_position[pip_ax] + pip_ax = Axis.of_main_tool_actuator(checked_mount) + # speed depends on if there is a tip, and which direction to move if instrument.has_tip: - if current_pos > target_pos[pip_ax]: - # using slower aspirate flow-rate, to avoid pulling droplets up - speed = self._pipette_handler.plunger_speed( - instrument, instrument.aspirate_flow_rate, "aspirate" - ) - else: - # use blow-out flow-rate, so we can push droplets out - speed = self._pipette_handler.plunger_speed( - instrument, instrument.blow_out_flow_rate, "dispense" - ) + # using slower aspirate flow-rate, to avoid pulling droplets up + speed_up = self._pipette_handler.plunger_speed( + instrument, instrument.aspirate_flow_rate, "aspirate" + ) + # use blow-out flow-rate, so we can push droplets out + speed_down = self._pipette_handler.plunger_speed( + instrument, instrument.blow_out_flow_rate, "dispense" + ) else: # save time by using max speed max_speeds = self.config.motion_settings.default_max_speed - speed = max_speeds[self.gantry_load][OT3AxisKind.P] + speed_up = max_speeds[self.gantry_load][OT3AxisKind.P] + speed_down = speed_up # IMPORTANT: Here is our backlash compensation. # The plunger is pre-loaded in the "aspirate" direction + backlash_pos = target_pos.copy() + backlash_pos[pip_ax] += instrument.backlash_distance # NOTE: plunger position (mm) decreases up towards homing switch - if current_pos < target_pos[pip_ax]: - # move down below "bottom", before moving back up to "bottom" - backlash_pos = target_pos.copy() - backlash_pos[pip_ax] += instrument.backlash_distance + # NOTE: if already at BOTTOM, we still need to run backlash-compensation movement, + # because we do not know if we arrived at BOTTOM from above or below. + async with self._backend.restore_current(): + await self._backend.set_active_current( + {pip_ax: instrument.config.plunger_homing_configurations.current} + ) + if self._current_position[pip_ax] < backlash_pos[pip_ax]: + await self._move( + backlash_pos, + speed=(speed_down * rate), + acquire_lock=acquire_lock, + ) + # NOTE: This should ALWAYS be moving UP. + # There should never be a time that this function is called and + # the plunger doesn't physically move UP into it's BOTTOM position. + # This is to make sure we are always engaged at the beginning of aspirate. await self._move( - backlash_pos, - speed=(speed * rate), + target_pos, + speed=(speed_up * rate), acquire_lock=acquire_lock, ) - await self._move( - target_pos, - speed=(speed * rate), - acquire_lock=acquire_lock, - ) async def configure_for_volume( self, mount: Union[top_types.Mount, OT3Mount], volume: float @@ -1611,7 +1753,7 @@ async def prepare_for_aspirate( checked_mount = OT3Mount.from_mount(mount) instrument = self._pipette_handler.get_pipette(checked_mount) self._pipette_handler.ready_for_tip_action( - instrument, HardwareAction.PREPARE_ASPIRATE + instrument, HardwareAction.PREPARE_ASPIRATE, checked_mount ) if instrument.current_volume == 0: await self._move_to_plunger_bottom(checked_mount, rate) @@ -1786,17 +1928,10 @@ async def _motor_pick_up_tip( self._current_position, ) await self._move(target_down) - homing_velocity = self._config.motion_settings.max_speed_discontinuity[ - GantryLoad.HIGH_THROUGHPUT - ][OT3AxisKind.Q] # check if position is known before pick up tip if not any(self._backend.gear_motor_position): # home gear motor if position not known - await self._backend.tip_action( - distance=self._backend.axis_bounds[Axis.Q][1], - velocity=homing_velocity, - tip_action="home", - ) + await self.home_gear_motors() pipette_axis = Axis.of_main_tool_actuator(mount) gear_origin_float = axis_convert(self._backend.gear_motor_position, 0.0)[ pipette_axis @@ -1805,23 +1940,9 @@ async def _motor_pick_up_tip( clamp_moves = self._build_moves( {Axis.Q: gear_origin_float}, {Axis.Q: clamp_move_target} ) - await self._backend.tip_action(moves=clamp_moves[0], tip_action="clamp") + await self._backend.tip_action(moves=clamp_moves[0]) - gear_pos_float = axis_convert(self._backend.gear_motor_position, 0.0)[ - Axis.P_L - ] - - fast_home_moves = self._build_moves( - {Axis.Q: gear_pos_float}, {Axis.Q: self._config.safe_home_distance} - ) - # move toward home until a safe distance - await self._backend.tip_action(moves=fast_home_moves[0], tip_action="clamp") - # move the rest of the way home with no acceleration - await self._backend.tip_action( - distance=(self._config.safe_home_distance + pipette_spec.home_buffer), - velocity=homing_velocity, - tip_action="home", - ) + await self.home_gear_motors() async def pick_up_tip( self, @@ -1860,7 +1981,7 @@ async def pick_up_tip( self.gantry_load != GantryLoad.HIGH_THROUGHPUT and ff.tip_presence_detection_enabled() ): - await self._backend.get_tip_present(realmount, TipStateType.PRESENT) + await self._backend.check_for_tip_presence(realmount, TipStateType.PRESENT) _add_tip_to_instrs() @@ -1894,9 +2015,6 @@ async def drop_tip( realmount = OT3Mount.from_mount(mount) spec, _remove = self._pipette_handler.plan_check_drop_tip(realmount, home_after) - homing_velocity = self._config.motion_settings.max_speed_discontinuity[ - GantryLoad.HIGH_THROUGHPUT - ][OT3AxisKind.Q] for move in spec.drop_moves: await self._backend.set_active_current(move.current) @@ -1905,11 +2023,7 @@ async def drop_tip( # Not sure why if not any(self._backend.gear_motor_position): # home gear motor if position not known - await self._backend.tip_action( - distance=self._backend.axis_bounds[Axis.Q][1], - velocity=homing_velocity, - tip_action="home", - ) + await self.home_gear_motors() gear_start_position = axis_convert( self._backend.gear_motor_position, 0.0 @@ -1917,25 +2031,9 @@ async def drop_tip( drop_moves = self._build_moves( {Axis.Q: gear_start_position}, {Axis.Q: move.target_position} ) - await self._backend.tip_action(moves=drop_moves[0], tip_action="clamp") + await self._backend.tip_action(moves=drop_moves[0]) - gear_pos_float = axis_convert(self._backend.gear_motor_position, 0.0)[ - Axis.P_L - ] - - fast_home_moves = self._build_moves( - {Axis.Q: gear_pos_float}, {Axis.Q: self._config.safe_home_distance} - ) - # move toward home until a safe distance - await self._backend.tip_action( - moves=fast_home_moves[0], tip_action="clamp" - ) - # move the rest of the way home with no acceleration - await self._backend.tip_action( - distance=(self._config.safe_home_distance + move.home_buffer), - velocity=homing_velocity, - tip_action="home", - ) + await self.home_gear_motors() else: target_pos = target_position_from_plunger( @@ -1958,7 +2056,7 @@ async def drop_tip( self.gantry_load != GantryLoad.HIGH_THROUGHPUT and ff.tip_presence_detection_enabled() ): - await self._backend.get_tip_present(realmount, TipStateType.ABSENT) + await self._backend.check_for_tip_presence(realmount, TipStateType.ABSENT) # home mount axis if home_after: @@ -2023,7 +2121,7 @@ async def get_instrument_state( # this function with additional state (such as critical points) realmount = OT3Mount.from_mount(mount) res = await self._backend.get_tip_present_state(realmount) - pipette_state_for_mount: PipetteStateDict = {"tip_detected": bool(res)} + pipette_state_for_mount: PipetteStateDict = {"tip_detected": res} return pipette_state_for_mount def reset_instrument( @@ -2207,6 +2305,7 @@ async def liquid_probe( self, mount: OT3Mount, probe_settings: Optional[LiquidProbeSettings] = None, + probe: Optional[InstrumentProbeType] = None, ) -> float: """Search for and return liquid level height. @@ -2228,7 +2327,7 @@ async def liquid_probe( checked_mount = OT3Mount.from_mount(mount) instrument = self._pipette_handler.get_pipette(checked_mount) self._pipette_handler.ready_for_tip_action( - instrument, HardwareAction.LIQUID_PROBE + instrument, HardwareAction.LIQUID_PROBE, checked_mount ) if not probe_settings: @@ -2262,6 +2361,7 @@ async def liquid_probe( probe_settings.log_pressure, probe_settings.auto_zero_sensor, probe_settings.num_baseline_reads, + probe=probe if probe else InstrumentProbeType.PRIMARY, ) end_pos = await self.gantry_position(mount, refresh=True) await self.move_to(mount, probe_start_pos) @@ -2274,7 +2374,8 @@ async def capacitive_probe( target_pos: float, pass_settings: CapacitivePassSettings, retract_after: bool = True, - ) -> float: + probe: Optional[InstrumentProbeType] = None, + ) -> Tuple[float, bool]: """Determine the position of something using the capacitive sensor. This function orchestrates detecting the position of a collision between the @@ -2327,30 +2428,26 @@ async def capacitive_probe( ) pass_start_pos = moving_axis.set_in_point(here, pass_start) await self.move_to(mount, pass_start_pos) - if mount == OT3Mount.GRIPPER: - probe = self._gripper_handler.get_attached_probe() - assert probe - await self._backend.capacitive_probe( - mount, - moving_axis, - machine_pass_distance, - pass_settings.speed_mm_per_s, - pass_settings.sensor_threshold_pf, - GripperProbe.to_type(probe), - ) - else: - await self._backend.capacitive_probe( - mount, - moving_axis, - machine_pass_distance, - pass_settings.speed_mm_per_s, - pass_settings.sensor_threshold_pf, - probe=InstrumentProbeType.PRIMARY, - ) + if probe is None: + if mount == OT3Mount.GRIPPER: + gripper_probe = self._gripper_handler.get_attached_probe() + assert gripper_probe + probe = GripperProbe.to_type(gripper_probe) + else: + # default to primary (rear) probe + probe = InstrumentProbeType.PRIMARY + contact = await self._backend.capacitive_probe( + mount, + moving_axis, + machine_pass_distance, + pass_settings.speed_mm_per_s, + pass_settings.sensor_threshold_pf, + probe=probe, + ) end_pos = await self.gantry_position(mount, refresh=True) if retract_after: await self.move_to(mount, pass_start_pos) - return moving_axis.of_point(end_pos) + return moving_axis.of_point(end_pos), contact async def capacitive_sweep( self, diff --git a/api/src/opentrons/hardware_control/protocols/motion_controller.py b/api/src/opentrons/hardware_control/protocols/motion_controller.py index 8fbae1b9a1b..ba2e4913f60 100644 --- a/api/src/opentrons/hardware_control/protocols/motion_controller.py +++ b/api/src/opentrons/hardware_control/protocols/motion_controller.py @@ -90,7 +90,7 @@ async def current_position( specified mount but `CriticalPoint.TIP` was specified, the position of the nozzle will be returned. - If `fail_on_not_homed` is `True`, this method will raise a `MustHomeError` + If `fail_on_not_homed` is `True`, this method will raise a `PositionUnknownError` if any of the relavent axes are not homed, regardless of `refresh`. """ ... @@ -186,7 +186,7 @@ async def move_rel( axes are to be moved, they will do so at the same speed. If fail_on_not_homed is True (default False), if an axis that is not - homed moves it will raise a MustHomeError. Otherwise, it will home the axis. + homed moves it will raise a PositionUnknownError. Otherwise, it will home the axis. """ ... diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 94e156b9802..b99f6a17e6c 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -634,7 +634,7 @@ class FailedTipStateCheck(RuntimeError): """Error raised if the tip ejector state does not match the expected value.""" def __init__(self, tip_state_type: TipStateType, actual_state: int) -> None: - """Iniitialize FailedTipStateCheck error.""" + """Initialize FailedTipStateCheck error.""" super().__init__( f"Failed to correctly determine tip state for tip {str(tip_state_type)} " f"received {bool(actual_state)} but expected {bool(tip_state_type.value)}" diff --git a/api/src/opentrons/hardware_control/util.py b/api/src/opentrons/hardware_control/util.py index d2d5a1f16d2..a7965853212 100644 --- a/api/src/opentrons/hardware_control/util.py +++ b/api/src/opentrons/hardware_control/util.py @@ -78,27 +78,29 @@ def check_motion_bounds( ) for ax in target_smoothie.keys(): if target_smoothie[ax] < bounds[ax][0]: - bounds_message = bounds_message_format.format( - axis=ax, - tsp=target_smoothie[ax], - tdp=target_deck.get(ax, "unknown"), - dir="low", - limsp=bounds.get(ax, ("unknown",))[0], - ) + format_detail = { + "axis": ax, + "tsp": target_smoothie[ax], + "tdp": target_deck.get(ax, "unknown"), + "dir": "low", + "limsp": bounds.get(ax, ("unknown",))[0], + } + bounds_message = bounds_message_format.format(**format_detail) mod_log.warning(bounds_message) if checks.value & MotionChecks.LOW.value: - raise OutOfBoundsMove(bounds_message) + raise OutOfBoundsMove(bounds_message, format_detail) elif target_smoothie[ax] > bounds[ax][1]: - bounds_message = bounds_message_format.format( - axis=ax, - tsp=target_smoothie[ax], - tdp=target_deck.get(ax, "unknown"), - dir="high", - limsp=bounds.get(ax, (None, "unknown"))[1], - ) + format_detail = { + "axis": ax, + "tsp": target_smoothie[ax], + "tdp": target_deck.get(ax, "unknown"), + "dir": "high", + "limsp": bounds.get(ax, (None, "unknown"))[1], + } + bounds_message = bounds_message_format.format(**format_detail) mod_log.warning(bounds_message) if checks.value & MotionChecks.HIGH.value: - raise OutOfBoundsMove(bounds_message) + raise OutOfBoundsMove(bounds_message, format_detail) def ot2_axis_to_string(axis: Axis) -> str: diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 5d2da84b5df..9326fa3a744 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -1,5 +1,4 @@ """ProtocolEngine-based Protocol API core implementation.""" -from typing_extensions import Literal from typing import Dict, Optional, Type, Union, List, Tuple from opentrons.protocol_engine.commands import LoadModuleResult @@ -404,10 +403,8 @@ def _get_module_core( load_module_result=load_module_result, model=model ) - # TODO (tz, 11-23-22): remove Union when refactoring load_pipette for 96 channels. - # https://opentrons.atlassian.net/browse/RLIQ-255 def load_instrument( - self, instrument_name: Union[PipetteNameType, Literal["p1000_96"]], mount: Mount + self, instrument_name: PipetteNameType, mount: Mount ) -> InstrumentCore: """Load an instrument into the protocol. diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index e852564f9eb..1fef95066d8 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Optional from opentrons import types -from opentrons.hardware_control import NoTipAttachedError, TipAttachedError from opentrons.hardware_control.dev_types import PipetteDict from opentrons.hardware_control.types import HardwareAction from opentrons.protocols.api_support import instrument as instrument_support @@ -17,6 +16,10 @@ APIVersionError, ) from opentrons.protocols.geometry import planning +from opentrons_shared_data.errors.exceptions import ( + UnexpectedTipRemovalError, + UnexpectedTipAttachError, +) from ..instrument import AbstractInstrument @@ -409,14 +412,18 @@ def _update_flow_rate(self) -> None: self._pipette_dict["blow_out_speed"] = p["blow_out_speed"] def _raise_if_no_tip(self, action: str) -> None: - """Raise NoTipAttachedError if no tip.""" + """Raise UnexpectedTipRemovalError if no tip.""" if not self.has_tip(): - raise NoTipAttachedError(f"Cannot perform {action} without a tip attached") + raise UnexpectedTipRemovalError( + action, self._instrument_name, self._mount.name + ) def _raise_if_tip(self, action: str) -> None: - """Raise TipAttachedError if tip.""" + """Raise UnexpectedTipAttachError if tip.""" if self.has_tip(): - raise TipAttachedError(f"Cannot {action} with a tip attached") + raise UnexpectedTipAttachError( + action, self._instrument_name, self._mount.name + ) def configure_for_volume(self, volume: float) -> None: """This will never be called because it was added in API 2.15.""" diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 08f9c29b9b0..cc215d23386 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -9,7 +9,7 @@ ) from opentrons.legacy_broker import LegacyBroker from opentrons.hardware_control.dev_types import PipetteDict -from opentrons import types, hardware_control as hc +from opentrons import types from opentrons.commands import commands as cmds from opentrons.commands import publisher @@ -25,6 +25,7 @@ requires_version, APIVersionError, ) +from opentrons_shared_data.errors.exceptions import UnexpectedTipRemovalError from .core.common import InstrumentCore, ProtocolCore from .core.engine import ENGINE_CORE_API_VERSION @@ -393,7 +394,7 @@ def mix( `rate` * :py:attr:`flow_rate.aspirate `, and when dispensing, it will be `rate` * :py:attr:`flow_rate.dispense `. - :raises: ``NoTipAttachedError`` -- if no tip is attached to the pipette. + :raises: ``UnexpectedTipRemovalError`` -- if no tip is attached to the pipette. :returns: This instance .. note:: @@ -417,7 +418,7 @@ def mix( ) ) if not self._core.has_tip(): - raise hc.NoTipAttachedError("Pipette has no tip. Aborting mix()") + raise UnexpectedTipRemovalError("mix", self.name, self.mount) c_vol = self._core.get_available_volume() if not volume else volume @@ -538,7 +539,7 @@ def touch_tip( :param speed: The speed for touch tip motion, in mm/s. Default: 60.0 mm/s, Max: 80.0 mm/s, Min: 20.0 mm/s :type speed: float - :raises: ``NoTipAttachedError`` -- if no tip is attached to the pipette + :raises: ``UnexpectedTipRemovalError`` -- if no tip is attached to the pipette :raises RuntimeError: If no location is specified and location cache is None. This should happen if `touch_tip` is called without first calling a method that takes a @@ -553,7 +554,7 @@ def touch_tip( """ if not self._core.has_tip(): - raise hc.NoTipAttachedError("Pipette has no tip to touch_tip()") + raise UnexpectedTipRemovalError("touch_tip", self.name, self.mount) checked_speed = self._determine_speed(speed) @@ -611,7 +612,7 @@ def air_gap( to air-gap aspirate. (Default: 5mm above current Well) :type height: float - :raises: ``NoTipAttachedError`` -- if no tip is attached to the pipette + :raises: ``UnexpectedTipRemovalError`` -- if no tip is attached to the pipette :raises RuntimeError: If location cache is None. This should happen if `touch_tip` is called @@ -632,7 +633,7 @@ def air_gap( """ if not self._core.has_tip(): - raise hc.NoTipAttachedError("Pipette has no tip. Aborting air_gap") + raise UnexpectedTipRemovalError("air_gap", self.name, self.mount) if height is None: height = 5 @@ -1015,14 +1016,17 @@ def distribute( """ Move a volume of liquid from one source to multiple destinations. - :param volume: The amount of volume to distribute to each destination - well. - :param source: A single well from where liquid will be aspirated. - :param dest: List of Wells where liquid will be dispensed to. - :param kwargs: See :py:meth:`transfer`. Some arguments are changed. - Specifically, ``mix_after``, if specified, is ignored - and ``disposal_volume``, if not specified, is set to the - minimum volume of the pipette + :param volume: The amount, in µL, to dispense into each destination well. + :param source: A single well to aspirate liquid from. + :param dest: A list of wells to dispense liquid into. + :param kwargs: See :py:meth:`transfer` and the :ref:`complex_params` page. + Some parameters behave differently than when transferring. + + - ``disposal_volume`` aspirates additional liquid to improve the accuracy of each dispense. Defaults to the minimum volume of the pipette. See :ref:`param-disposal-volume` for details. + + - ``mix_after`` is ignored. + + :returns: This instance """ _log.debug("Distributing {} from {} to {}".format(volume, source, dest)) @@ -1047,15 +1051,14 @@ def consolidate( **kwargs: Any, ) -> InstrumentContext: """ - Move liquid from multiple wells (sources) to a single well(destination) - - :param volume: The amount of volume to consolidate from each source - well. - :param source: List of wells from where liquid will be aspirated. - :param dest: The single well into which liquid will be dispensed. - :param kwargs: See :py:meth:`transfer`. Some arguments are changed. - Specifically, ``mix_before``, if specified, is ignored - and ``disposal_volume`` is ignored and set to 0. + Move liquid from multiple source wells to a single destination well. + + :param volume: The amount, in µL, to aspirate from each source well. + :param source: A list of wells to aspirate liquid from. + :param dest: A single well to dispense liquid into. + :param kwargs: See :py:meth:`transfer` and the :ref:`complex_params` page. + Some parameters behave differently than when transferring. + ``disposal_volume`` and ``mix_before`` are ignored. :returns: This instance """ _log.debug("Consolidate {} from {} to {}".format(volume, source, dest)) @@ -1088,91 +1091,87 @@ def transfer( # TODO: What should happen if the user passes a non-first-row well # TODO: ..as src/dest *while using multichannel pipette? """ - Transfer will move a volume of liquid from a source location(s) - to a dest location(s). It is a higher-level command, incorporating - other :py:class:`InstrumentContext` commands, like :py:meth:`aspirate` - and :py:meth:`dispense`, designed to make protocol writing easier at - the cost of specificity. + Transfer moves liquid from one well or group of wells to another. It is a + higher-level command, incorporating other :py:class:`InstrumentContext` + commands, like :py:meth:`aspirate` and :py:meth:`dispense`. It makes writing a + protocol easier at the cost of specificity. See :ref:`v2-complex-commands` for + details on how transfer and other complex commands perform their component steps. - :param volume: The amount of volume to aspirate from each source and + :param volume: The amount, in µL, to aspirate from each source and dispense to each destination. - If volume is a list, each volume will be used for the - sources/targets at the matching index. If volumes is a - tuple with two elements, like `(20, 100)`, then a list - of volumes will be generated with a linear gradient - between the two volumes in the tuple. - :param source: A single well or a list of wells from where liquid - will be aspirated. - :param dest: A single well or a list of wells where liquid - will be dispensed to. - :param \\**kwargs: See below - - :Keyword Arguments: - - * *new_tip* (``string``) -- - - - 'never': no tips will be picked up or dropped during transfer - - 'once': (default) a single tip will be used for all commands. - - 'always': use a new tip for each transfer. - - * *trash* (``boolean``) -- - If `True` (default behavior), tips will be - dropped in the trash container attached this `Pipette`. - If `False` tips will be returned to tiprack. - - * *touch_tip* (``boolean``) -- - If `True`, a :py:meth:`touch_tip` will occur following each - :py:meth:`aspirate` and :py:meth:`dispense`. If set to `False` - (default behavior), no :py:meth:`touch_tip` will occur. - - * *blow_out* (``boolean``) -- - If `True`, a :py:meth:`blow_out` will occur following each + If ``volume`` is a list, each amount will be used for the source + and destination at the matching index. A list item of ``0`` will + skip the corresponding wells entirely. See + :ref:`complex-list-volumes` for details and examples. + :param source: A single well or a list of wells to aspirate liquid from. + :param dest: A single well or a list of wells to dispense liquid into. + + :Keyword Arguments: Transfer accepts a number of optional parameters that give + you greater control over the exact steps it performs. See :ref:`complex_params` + or the links under each argument's entry below for additional details and + examples. + + * **new_tip** (*string*) -- + When to pick up and drop tips during the command. Defaults to ``"once"``. + + - ``"once"``: Use one tip for the entire command. + - ``"always"``: Use a new tip for each set of aspirate and dispense steps. + - ``"never"``: Do not pick up or drop tips at all. + + See :ref:`param-tip-handling` for details. + + * **trash** (*boolean*) -- + If ``True`` (default), the pipette will drop tips in its + :py:meth:`~.InstrumentContext.trash_container`. + If ``False``, the pipette will return tips to their tip rack. + + See :ref:`param-trash` for details. + + * **touch_tip** (*boolean*) -- + If ``True``, perform a :py:meth:`touch_tip` following each + :py:meth:`aspirate` and :py:meth:`dispense`. Defaults to ``False``. + + See :ref:`param-touch-tip` for details. + + * **blow_out** (*boolean*) -- + If ``True``, a :py:meth:`blow_out` will occur following each :py:meth:`dispense`, but only if the pipette has no liquid left - in it. If set to `False` (default), no :py:meth:`blow_out` will - occur. - - * *blowout_location* (``string``) -- - - 'source well': blowout excess liquid into source well - - 'destination well': blowout excess liquid into destination - well - - 'trash': blowout excess liquid into the trash - - If no ``blowout_location`` specified, no ``disposal_volume`` - specified, and the pipette contains liquid, - a :py:meth:`blow_out` will occur into the source well. - - If no ``blowout_location`` specified and either - ``disposal_volume`` is specified or the pipette is empty, - a :py:meth:`blow_out` will occur into the trash. - - If ``blow_out`` is set to ``False``, this parameter will be ignored. - - * *mix_before* (``tuple``) -- - The tuple, if specified, gives the amount of volume to - :py:meth:`mix` preceding each :py:meth:`aspirate` during the - transfer. The tuple is interpreted as (repetitions, volume). - - * *mix_after* (``tuple``) -- - The tuple, if specified, gives the amount of volume to - :py:meth:`mix` after each :py:meth:`dispense` during the - transfer. The tuple is interpreted as (repetitions, volume). - - * *disposal_volume* (``float``) -- - (:py:meth:`distribute` only) Volume of liquid to be disposed off - after distributing. When dispensing multiple times from the same - tip, it is recommended to aspirate an extra amount of liquid to - be disposed off after distributing. - - * *carryover* (``boolean``) -- - If `True` (default), any `volume` that exceeds the maximum volume - of this Pipette will be split into multiple smaller volumes. - - * *gradient* (``lambda``) -- - Function for calculating the curve used for gradient volumes. - When `volume` is a tuple of length 2, its values are used to - create a list of gradient volumes. The default curve for this - gradient is linear (lambda x: x), however a method can be passed - with the `gradient` keyword argument to create a custom curve. + in it. If ``False`` (default), the pipette will not blow out liquid. + + See :ref:`param-blow-out` for details. + + * **blowout_location** (*string*) -- + Accepts one of three string values: ``"trash"``, ``"source well"``, or + ``"destination well"``. + + If ``blow_out`` is ``False`` (its default), this parameter is ignored. + + If ``blow_out`` is ``True`` and this parameter is not set: + + - Blow out into the trash, if the pipette is empty or only contains the disposal volume. + + - Blow out into the source well, if the pipette otherwise contains liquid. + + * **mix_before** (*tuple*) -- + Perform a :py:meth:`mix` before each :py:meth:`aspirate` during the + transfer. The first value of the tuple is the number of repetitions, and + the second value is the amount of liquid to mix in µL. + + See :ref:`param-mix-before` for details. + + * **mix_after** (*tuple*) -- + Perform a :py:meth:`mix` after each :py:meth:`dispense` during the + transfer. The first value of the tuple is the number of repetitions, and + the second value is the amount of liquid to mix in µL. + + See :ref:`param-mix-after` for details. + + * **disposal_volume** (*float*) -- + Transfer ignores the numeric value of this parameter. If set, the pipette + will not aspirate additional liquid, but it will perform a very small blow + out after each dispense. + + See :ref:`param-disposal-volume` for details. :returns: This instance """ diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 86f121f6183..449b3dec402 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -232,6 +232,14 @@ def run(protocol): @requires_version(2, 0) def commands(self) -> List[str]: + """Return the run log. + + This is a list of human-readable strings representing what's been done in the protocol so + far. For example, "Aspirating 123 µL from well A1 of 96 well plate in slot 1." + + The exact format of these entries is not guaranteed. The format here may differ from other + places that show the run log, such as the Opentrons App. + """ return self._commands @requires_version(2, 0) @@ -765,6 +773,14 @@ def load_instrument( tip_racks = tip_racks or [] + # TODO (tz, 9-12-23): move validation into PE + on_right_mount = self._instruments[Mount.RIGHT] + if is_96_channel and on_right_mount is not None: + raise RuntimeError( + f"Instrument already present on right:" + f" {on_right_mount.name}. In order to load a 96 channel pipette both mounts need to be available." + ) + existing_instrument = self._instruments[checked_mount] if existing_instrument is not None and not replace: # TODO(mc, 2022-08-25): create specific exception type @@ -777,8 +793,6 @@ def load_instrument( f"Loading {checked_instrument_name} on {checked_mount.name.lower()} mount" ) - # TODO (tz, 11-22-22): was added to support 96 channel pipette. - # Should remove when working on https://opentrons.atlassian.net/browse/RLIQ-255 instrument_core = self._core.load_instrument( instrument_name=checked_instrument_name, mount=checked_mount, @@ -916,6 +930,7 @@ def location_cache(self, loc: Optional[Location]) -> None: @requires_version(2, 0) def deck(self) -> Deck: """An interface to provide information about what's currently loaded on the deck. + This object is useful for determining if a slot in the deck is free. This object behaves like a dictionary whose keys are the deck slot names. For instance, ``protocol.deck[1]``, ``protocol.deck["1"]``, and ``protocol.deck["D1"]`` @@ -925,14 +940,23 @@ def deck(self) -> Deck: labware, a :py:obj:`~opentrons.protocol_api.ModuleContext` if the slot contains a hardware module, or ``None`` if the slot doesn't contain anything. - This object is useful for determining if a slot in the deck is free. - Rather than filtering the objects in the deck map yourself, - you can also use :py:attr:`loaded_labwares` to see a dict of labwares - and :py:attr:`loaded_modules` to see a dict of modules. - - For advanced control, you can delete an item of labware from the deck - with e.g. ``del protocol.deck['1']`` to free a slot for new labware. + you can also use :py:attr:`loaded_labwares` to get a dict of labwares + and :py:attr:`loaded_modules` to get a dict of modules. + + For :ref:`advanced-control` *only*, you can delete an element of the ``deck`` dict. + This only works for deck slots that contain labware objects. For example, if slot + 1 contains a labware, ``del protocol.deck['1']`` will free the slot so you can + load another labware there. + + .. warning:: + Deleting labware from a deck slot does not pause the protocol. Subsequent + commands continue immediately. If you need to physically move the labware to + reflect the new deck state, add a :py:meth:`.pause` or use + :py:meth:`.move_labware` instead. + + .. versionchanged:: 2.15 + ``del`` sets the corresponding labware's location to ``OFF_DECK``. """ return self._deck diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index 789d342ccf3..f68183b81f1 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -1,7 +1,6 @@ """Control a `ProtocolEngine` without async/await.""" -from typing import cast, List, Optional, Union, Dict -from typing_extensions import Literal +from typing import cast, List, Optional, Dict from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons_shared_data.labware.dev_types import LabwareUri @@ -141,11 +140,9 @@ def move_labware( return cast(commands.MoveLabwareResult, result) - # TODO (tz, 11-23-22): remove Union when refactoring load_pipette for 96 channels. - # https://opentrons.atlassian.net/browse/RLIQ-255 def load_pipette( self, - pipette_name: Union[PipetteNameType, Literal["p1000_96"]], + pipette_name: PipetteNameType, mount: MountType, ) -> commands.LoadPipetteResult: """Execute a LoadPipette command and return the result.""" diff --git a/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py b/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py index 6584e8afe9e..464b177e980 100644 --- a/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py +++ b/api/src/opentrons/protocol_engine/commands/calibration/move_to_maintenance_position.py @@ -107,6 +107,13 @@ async def execute( ) if params.mount != MountType.EXTENSION: + + # disengage the gripper z to enable the e-brake, this prevents the gripper + # z from dropping when the right mount carriage gets released from the + # mount during 96-channel detach flow + if ot3_api.has_gripper(): + await ot3_api.disengage_axes([Axis.Z_G]) + if params.maintenancePosition == MaintenancePosition.ATTACH_INSTRUMENT: mount_to_axis = Axis.by_mount(params.mount.to_hw_mount()) await ot3_api.move_axes( diff --git a/api/src/opentrons/protocol_engine/commands/load_labware.py b/api/src/opentrons/protocol_engine/commands/load_labware.py index ddc470b9da2..614c702df51 100644 --- a/api/src/opentrons/protocol_engine/commands/load_labware.py +++ b/api/src/opentrons/protocol_engine/commands/load_labware.py @@ -6,7 +6,10 @@ from opentrons_shared_data.labware.labware_definition import LabwareDefinition -from ..types import LabwareLocation, OnLabwareLocation +from ..errors import LabwareIsNotAllowedInLocationError +from ..resources import labware_validation +from ..types import LabwareLocation, OnLabwareLocation, DeckSlotLocation + from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate if TYPE_CHECKING: @@ -90,6 +93,18 @@ def __init__( async def execute(self, params: LoadLabwareParams) -> LoadLabwareResult: """Load definition and calibration data necessary for a labware.""" + # TODO (tz, 8-15-2023): extend column validation to column 1 when working + # on https://opentrons.atlassian.net/browse/RSS-258 and completing + # https://opentrons.atlassian.net/browse/RSS-255 + if ( + labware_validation.is_flex_trash(params.loadName) + and isinstance(params.location, DeckSlotLocation) + and self._state_view.geometry.get_slot_column(params.location.slotName) != 3 + ): + raise LabwareIsNotAllowedInLocationError( + f"{params.loadName} is not allowed in slot {params.location.slotName}" + ) + loaded_labware = await self._equipment.load_labware( load_name=params.loadName, namespace=params.namespace, diff --git a/api/src/opentrons/protocol_engine/commands/load_pipette.py b/api/src/opentrons/protocol_engine/commands/load_pipette.py index d5c95accd08..66f32e99edc 100644 --- a/api/src/opentrons/protocol_engine/commands/load_pipette.py +++ b/api/src/opentrons/protocol_engine/commands/load_pipette.py @@ -1,7 +1,7 @@ """Load pipette command request, result, and implementation models.""" from __future__ import annotations from pydantic import BaseModel, Field -from typing import TYPE_CHECKING, Optional, Type, Union, Tuple +from typing import TYPE_CHECKING, Optional, Type, Tuple from typing_extensions import Literal from opentrons_shared_data.pipette.dev_types import PipetteNameType @@ -30,9 +30,7 @@ class LoadPipettePrivateResult(PipetteConfigUpdateResultMixin): class LoadPipetteParams(BaseModel): """Payload needed to load a pipette on to a mount.""" - # TODO (tz, 11-23-22): remove Union when refactoring load_pipette for 96 channels. - # https://opentrons.atlassian.net/browse/RLIQ-255 - pipetteName: Union[PipetteNameType, Literal["p1000_96"]] = Field( + pipetteName: PipetteNameType = Field( ..., description="The load name of the pipette to be required.", ) diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index 4e8b6524a0a..0cea7ce7943 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -51,6 +51,7 @@ CannotPerformGripperAction, HardwareNotSupportedError, LabwareMovementNotAllowedError, + LabwareIsNotAllowedInLocationError, LocationIsOccupiedError, InvalidAxisForRobotType, NotSupportedOnRobotType, @@ -111,6 +112,7 @@ "CannotPerformGripperAction", "HardwareNotSupportedError", "LabwareMovementNotAllowedError", + "LabwareIsNotAllowedInLocationError", "LocationIsOccupiedError", "InvalidAxisForRobotType", "NotSupportedOnRobotType", diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 4ae0fae33ff..27eab6a640f 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -398,7 +398,7 @@ def __init__( wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a MustHomeError.""" - super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + super().__init__(ErrorCodes.POSITION_UNKNOWN, message, details, wrapping) class SetupCommandNotAllowedError(ProtocolEngineError): @@ -702,6 +702,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class LabwareIsNotAllowedInLocationError(ProtocolEngineError): + """Raised when attempting an illegal labware load into slot.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareIsNotAllowedInLocationError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class LocationIsOccupiedError(ProtocolEngineError): """Raised when attempting to place labware in a non-empty location.""" @@ -725,7 +738,9 @@ def __init__( wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a LocationIsOccupiedError.""" - super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + super().__init__( + ErrorCodes.FIRMWARE_UPDATE_REQUIRED, message, details, wrapping + ) class PipetteNotReadyToAspirateError(ProtocolEngineError): @@ -767,6 +782,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class InvalidDispenseVolumeError(ProtocolEngineError): + """Raised when attempting to dispense a volume that was not aspirated.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidDispenseVolumeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class InvalidAxisForRobotType(ProtocolEngineError): """Raised when attempting to use an axis that is not present on the given type of robot.""" diff --git a/api/src/opentrons/protocol_engine/execution/equipment.py b/api/src/opentrons/protocol_engine/execution/equipment.py index 4e08de8a14e..d15e2ec7e17 100644 --- a/api/src/opentrons/protocol_engine/execution/equipment.py +++ b/api/src/opentrons/protocol_engine/execution/equipment.py @@ -1,7 +1,6 @@ """Equipment command side-effect logic.""" from dataclasses import dataclass -from typing import Optional, overload, Union -from typing_extensions import Literal +from typing import Optional, overload from opentrons_shared_data.pipette.dev_types import PipetteNameType @@ -175,7 +174,7 @@ async def load_labware( async def load_pipette( self, - pipette_name: Union[PipetteNameType, Literal["p1000_96"]], + pipette_name: PipetteNameType, mount: MountType, pipette_id: Optional[str], ) -> LoadedPipetteData: diff --git a/api/src/opentrons/protocol_engine/execution/gantry_mover.py b/api/src/opentrons/protocol_engine/execution/gantry_mover.py index cca93b8be63..d8e2ea8afc5 100644 --- a/api/src/opentrons/protocol_engine/execution/gantry_mover.py +++ b/api/src/opentrons/protocol_engine/execution/gantry_mover.py @@ -6,7 +6,7 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import Axis as HardwareAxis -from opentrons.hardware_control.errors import MustHomeError as HardwareMustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from opentrons.motion_planning import Waypoint @@ -95,7 +95,7 @@ async def get_position( Args: pipette_id: Pipette ID to get location data for. current_well: Optional parameter for getting pipette location data, effects critical point. - fail_on_not_homed: Raise HardwareMustHomeError if gantry position is not known. + fail_on_not_homed: Raise PositionUnknownError if gantry position is not known. """ pipette_location = self._state_view.motion.get_pipette_location( pipette_id=pipette_id, @@ -107,8 +107,8 @@ async def get_position( critical_point=pipette_location.critical_point, fail_on_not_homed=fail_on_not_homed, ) - except HardwareMustHomeError as e: - raise MustHomeError(str(e)) from e + except PositionUnknownError as e: + raise MustHomeError(message=str(e), wrapping=[e]) def get_max_travel_z(self, pipette_id: str) -> float: """Get the maximum allowed z-height for pipette movement. @@ -167,8 +167,8 @@ async def move_relative( critical_point=critical_point, fail_on_not_homed=True, ) - except HardwareMustHomeError as e: - raise MustHomeError(str(e)) from e + except PositionUnknownError as e: + raise MustHomeError(message=str(e), wrapping=[e]) return point diff --git a/api/src/opentrons/protocol_engine/execution/movement.py b/api/src/opentrons/protocol_engine/execution/movement.py index b24e6eece64..d0caac1f55a 100644 --- a/api/src/opentrons/protocol_engine/execution/movement.py +++ b/api/src/opentrons/protocol_engine/execution/movement.py @@ -6,7 +6,7 @@ from opentrons.types import Point, MountType from opentrons.hardware_control import HardwareControlAPI -from opentrons.hardware_control.errors import MustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from ..types import ( WellLocation, @@ -217,6 +217,6 @@ async def check_for_valid_position(self, mount: MountType) -> bool: await self._hardware_api.gantry_position( mount=mount.to_hw_mount(), fail_on_not_homed=True ) - except MustHomeError: + except PositionUnknownError: return False return True diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index 5cd59bcde46..7b552de316e 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -10,6 +10,7 @@ TipNotAttachedError, InvalidPipettingVolumeError, InvalidPushOutVolumeError, + InvalidDispenseVolumeError, ) @@ -216,6 +217,7 @@ async def dispense_in_place( "push out value cannot have a negative value." ) self._validate_tip_attached(pipette_id=pipette_id, command_name="dispense") + self._validate_dispense_volume(pipette_id=pipette_id, dispense_volume=volume) return volume async def blow_out_in_place( @@ -234,6 +236,20 @@ def _validate_tip_attached(self, pipette_id: str, command_name: str) -> None: f"Cannot perform {command_name} without a tip attached" ) + def _validate_dispense_volume( + self, pipette_id: str, dispense_volume: float + ) -> None: + """Validate dispense volume.""" + aspirate_volume = self._state_view.pipettes.get_aspirated_volume(pipette_id) + if aspirate_volume is None: + raise InvalidDispenseVolumeError( + "Cannot perform a dispense if there is no volume in attached tip." + ) + elif dispense_volume > aspirate_volume: + raise InvalidDispenseVolumeError( + f"Cannot dispense {dispense_volume} µL when only {aspirate_volume} µL has been aspirated." + ) + def create_pipetting_handler( state_view: StateView, hardware_api: HardwareControlAPI diff --git a/api/src/opentrons/protocol_engine/resources/labware_validation.py b/api/src/opentrons/protocol_engine/resources/labware_validation.py index 33f74247125..75cb46e4cdd 100644 --- a/api/src/opentrons/protocol_engine/resources/labware_validation.py +++ b/api/src/opentrons/protocol_engine/resources/labware_validation.py @@ -4,6 +4,11 @@ from opentrons.protocols.models import LabwareDefinition +def is_flex_trash(load_name: str) -> bool: + """Check if a labware is a large trash.""" + return load_name == "opentrons_1_trash_3200ml_fixed" + + def validate_definition_is_labware(definition: LabwareDefinition) -> bool: """Validate that one of the definition's allowed roles is `labware`. diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 288e644fb16..746e40f6949 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -177,9 +177,7 @@ class LoadedPipette(BaseModel): """A pipette that has been loaded.""" id: str - # TODO (tz, 11-23-22): remove Union when refactoring load_pipette for 96 channels. - # https://opentrons.atlassian.net/browse/RLIQ-255 - pipetteName: Union[PipetteNameType, Literal["p1000_96"]] + pipetteName: PipetteNameType mount: MountType diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index 1d01a7120cd..894af6dd6e2 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -9,7 +9,7 @@ from opentrons.protocol_api import ProtocolContext from opentrons.protocols.execution.errors import ExceptionInProtocolError from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError -from opentrons.hardware_control import ExecutionCancelledError +from opentrons_shared_data.errors.exceptions import ExecutionCancelledError MODULE_LOG = logging.getLogger(__name__) diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py index d7858894b7c..dfdaa9a8072 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -54,7 +54,6 @@ EstopState, ) from opentrons.hardware_control.errors import ( - FirmwareUpdateRequired, InvalidPipetteName, InvalidPipetteModel, ) @@ -66,7 +65,11 @@ MoveStopCondition, MoveGroupSingleAxisStep, ) -from opentrons_hardware.hardware_control.types import PCBARevision +from opentrons_hardware.hardware_control.types import ( + PCBARevision, + MotorPositionStatus, + MoveCompleteAck, +) from opentrons_hardware.hardware_control import current_settings from opentrons_hardware.hardware_control.network import DeviceInfoCache from opentrons_hardware.hardware_control.tools.types import ( @@ -80,6 +83,7 @@ from opentrons_shared_data.errors.exceptions import ( EStopActivatedError, EStopNotPresentError, + FirmwareUpdateRequiredError, ) from opentrons_hardware.hardware_control.move_group_runner import MoveGroupRunner @@ -310,11 +314,11 @@ def fw_node_info() -> Dict[NodeId, DeviceInfoCache]: def move_group_run_side_effect( controller: OT3Controller, axes_to_home: List[Axis] -) -> Iterator[Dict[NodeId, Tuple[float, float, bool, bool]]]: +) -> Iterator[Dict[NodeId, MotorPositionStatus]]: """Return homed position for axis that is present and was commanded to home.""" motor_nodes = controller._motor_nodes() gantry_homes = { - axis_to_node(ax): (0.0, 0.0, True, True) + axis_to_node(ax): MotorPositionStatus(0.0, 0.0, True, True, MoveCompleteAck(1)) for ax in Axis.gantry_axes() if ax in axes_to_home and axis_to_node(ax) in motor_nodes } @@ -322,7 +326,7 @@ def move_group_run_side_effect( yield gantry_homes pipette_homes = { - axis_to_node(ax): (0.0, 0.0, True, True) + axis_to_node(ax): MotorPositionStatus(0.0, 0.0, True, True, MoveCompleteAck(1)) for ax in Axis.pipette_axes() if ax in axes_to_home and axis_to_node(ax) in motor_nodes } @@ -700,7 +704,7 @@ async def test_tip_action( controller: OT3Controller, mock_move_group_run: mock.AsyncMock, ) -> None: - await controller.tip_action(distance=33, velocity=-5.5, tip_action="home") + await controller.home_tip_motors(distance=33, velocity=-5.5, back_off=False) for call in mock_move_group_run.call_args_list: move_group_runner = call[0][0] for move_group in move_group_runner._move_groups: @@ -713,9 +717,7 @@ async def test_tip_action( mock_move_group_run.reset_mock() - await controller.tip_action( - distance=33, velocity=-5.5, tip_action="home", back_off=True - ) + await controller.home_tip_motors(distance=33, velocity=-5.5, back_off=True) for call in mock_move_group_run.call_args_list: move_group_runner = call[0][0] move_groups = move_group_runner._move_groups @@ -736,8 +738,10 @@ async def test_update_motor_status( ) -> None: async def fake_gmp( can_messenger: CanMessenger, nodes: Set[NodeId], timeout: float = 1.0 - ) -> Dict[NodeId, Tuple[float, float, bool, bool]]: - return {node: (0.223, 0.323, False, True) for node in nodes} + ) -> Dict[NodeId, MotorPositionStatus]: + return { + node: MotorPositionStatus(0.223, 0.323, False, True, None) for node in nodes + } with mock.patch( "opentrons.hardware_control.backends.ot3controller.get_motor_position", fake_gmp @@ -759,8 +763,10 @@ async def test_update_motor_estimation( ) -> None: async def fake_umpe( can_messenger: CanMessenger, nodes: Set[NodeId], timeout: float = 1.0 - ) -> Dict[NodeId, Tuple[float, float, bool, bool]]: - return {node: (0.223, 0.323, False, True) for node in nodes} + ) -> Dict[NodeId, MotorPositionStatus]: + return { + node: MotorPositionStatus(0.223, 0.323, False, True, None) for node in nodes + } with mock.patch( "opentrons.hardware_control.backends.ot3controller.update_motor_position_estimation", @@ -909,7 +915,7 @@ async def test_update_required_flag( axes = [Axis.X, Axis.Y] decoy.when(mock_subsystem_manager.update_required).then_return(True) controller._initialized = True - with pytest.raises(FirmwareUpdateRequired): + with pytest.raises(FirmwareUpdateRequiredError): await controller.home(axes, gantry_load=GantryLoad.LOW_THROUGHPUT) @@ -932,7 +938,7 @@ async def _mock_update() -> AsyncIterator[UpdateStatus]: pass # raise FirmwareUpdateRequired if the _update_required flag is set controller._initialized = True - with pytest.raises(FirmwareUpdateRequired): + with pytest.raises(FirmwareUpdateRequiredError): await controller.home([Axis.X], gantry_load=GantryLoad.LOW_THROUGHPUT) @@ -1070,16 +1076,16 @@ async def test_monitor_pressure( @pytest.mark.parametrize( "tip_state_type, mocked_ejector_response, expectation", [ - [TipStateType.PRESENT, 1, does_not_raise()], - [TipStateType.ABSENT, 0, does_not_raise()], - [TipStateType.PRESENT, 0, pytest.raises(FailedTipStateCheck)], - [TipStateType.ABSENT, 1, pytest.raises(FailedTipStateCheck)], + [TipStateType.PRESENT, {0: 1, 1: 1}, does_not_raise()], + [TipStateType.ABSENT, {0: 0, 1: 0}, does_not_raise()], + [TipStateType.PRESENT, {0: 0, 1: 0}, pytest.raises(FailedTipStateCheck)], + [TipStateType.ABSENT, {0: 1, 1: 1}, pytest.raises(FailedTipStateCheck)], ], ) async def test_get_tip_present( controller: OT3Controller, tip_state_type: TipStateType, - mocked_ejector_response: int, + mocked_ejector_response: Dict[int, int], expectation: ContextManager[None], ) -> None: mount = OT3Mount.LEFT @@ -1088,7 +1094,7 @@ async def test_get_tip_present( return_value=mocked_ejector_response, ): with expectation: - await controller.get_tip_present(mount, tip_state_type) + await controller.check_for_tip_presence(mount, tip_state_type) @pytest.mark.parametrize( diff --git a/api/tests/opentrons/hardware_control/test_execution_manager.py b/api/tests/opentrons/hardware_control/test_execution_manager.py index de106c7ac8e..9775fa895c4 100644 --- a/api/tests/opentrons/hardware_control/test_execution_manager.py +++ b/api/tests/opentrons/hardware_control/test_execution_manager.py @@ -3,8 +3,8 @@ from opentrons.hardware_control import ( ExecutionManager, ExecutionState, - ExecutionCancelledError, ) +from opentrons_shared_data.errors.exceptions import ExecutionCancelledError async def test_state_machine(): diff --git a/api/tests/opentrons/hardware_control/test_moves.py b/api/tests/opentrons/hardware_control/test_moves.py index 47b25859cf5..b438ecee12c 100644 --- a/api/tests/opentrons/hardware_control/test_moves.py +++ b/api/tests/opentrons/hardware_control/test_moves.py @@ -17,8 +17,7 @@ MotionChecks, ) from opentrons.hardware_control.errors import ( - MustHomeError, - InvalidMoveError, + InvalidCriticalPoint, OutOfBoundsMove, ) from opentrons.hardware_control.robot_calibration import ( @@ -26,7 +25,10 @@ DeckCalibration, ) -from opentrons_shared_data.errors.exceptions import MoveConditionNotMetError +from opentrons_shared_data.errors.exceptions import ( + MoveConditionNotMetError, + PositionUnknownError, +) async def test_controller_must_home(hardware_api): @@ -196,7 +198,7 @@ async def test_gripper_critical_points_fail_on_pipettes( types.Mount.RIGHT: {"model": "p10_single_v1", "id": "testyness"}, } await hardware_api.cache_instruments() - with pytest.raises(InvalidMoveError): + with pytest.raises(InvalidCriticalPoint): await hardware_api.move_to( types.Mount.RIGHT, types.Point(0, 0, 0), critical_point=critical_point ) @@ -494,7 +496,7 @@ async def test_move_rel_homing_failures(hardware_api): "C": False, } # If one axis being used isn't homed, we must get an exception - with pytest.raises(MustHomeError): + with pytest.raises(PositionUnknownError): await hardware_api.move_rel( types.Mount.LEFT, types.Point(0, 0, 2000), fail_on_not_homed=True ) @@ -517,13 +519,13 @@ async def test_current_position_homing_failures(hardware_api): } # If one axis being used isn't homed, we must get an exception - with pytest.raises(MustHomeError): + with pytest.raises(PositionUnknownError): await hardware_api.current_position( mount=types.Mount.LEFT, fail_on_not_homed=True, ) - with pytest.raises(MustHomeError): + with pytest.raises(PositionUnknownError): await hardware_api.gantry_position( mount=types.Mount.LEFT, fail_on_not_homed=True, diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 498d534abab..4d8ca82cb4e 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -20,10 +20,7 @@ GripperDict, ) from opentrons.hardware_control.motion_utilities import target_position_from_plunger -from opentrons.hardware_control.instruments.ot3.gripper_handler import ( - GripError, - GripperHandler, -) +from opentrons.hardware_control.instruments.ot3.gripper_handler import GripperHandler from opentrons.hardware_control.instruments.ot3.instrument_calibration import ( GripperCalibrationOffset, PipetteOffsetByPipetteMount, @@ -48,10 +45,7 @@ EstopState, EstopStateNotification, ) -from opentrons.hardware_control.errors import ( - GripperNotAttachedError, - InvalidMoveError, -) +from opentrons.hardware_control.errors import InvalidCriticalPoint from opentrons.hardware_control.ot3api import OT3API from opentrons.hardware_control import ThreadManager from opentrons.hardware_control.backends.ot3utils import ( @@ -64,6 +58,11 @@ from opentrons_hardware.hardware_control.motion_planning.types import Move from opentrons.config import gripper_config as gc +from opentrons_shared_data.errors.exceptions import ( + GripperNotPresentError, + CommandPreconditionViolated, + CommandParameterLimitViolated, +) from opentrons_shared_data.gripper.gripper_definition import GripperModel from opentrons_shared_data.pipette.types import ( PipetteModelType, @@ -74,7 +73,6 @@ from opentrons_shared_data.pipette import ( load_data as load_pipette_data, ) -from opentrons_shared_data.errors.exceptions import CommandParameterLimitViolated from opentrons.hardware_control.modules import ( Thermocycler, TempDeck, @@ -219,6 +217,19 @@ def mock_ungrip(ot3_hardware: ThreadManager[OT3API]) -> Iterator[AsyncMock]: yield mock_move +@pytest.fixture +def mock_home_gear_motors(ot3_hardware: ThreadManager[OT3API]) -> Iterator[AsyncMock]: + with patch.object( + ot3_hardware.managed_obj, + "home_gear_motors", + AsyncMock( + spec=ot3_hardware.managed_obj.home_gear_motors, + wraps=ot3_hardware.managed_obj.home_gear_motors, + ), + ) as mock_home_gear: + yield mock_home_gear + + @pytest.fixture def mock_hold_jaw_width(ot3_hardware: ThreadManager[OT3API]) -> Iterator[AsyncMock]: with patch.object( @@ -658,6 +669,7 @@ async def test_liquid_probe( fake_settings_aspirate.log_pressure, fake_settings_aspirate.auto_zero_sensor, fake_settings_aspirate.num_baseline_reads, + probe=InstrumentProbeType.PRIMARY, ) return_dict[head_node], return_dict[pipette_node] = 142, 142 @@ -688,7 +700,7 @@ async def test_capacitive_probe( ) -> None: await ot3_hardware.home() here = await ot3_hardware.gantry_position(mount) - res = await ot3_hardware.capacitive_probe(mount, moving, 2, fake_settings) + res, _ = await ot3_hardware.capacitive_probe(mount, moving, 2, fake_settings) # in reality, this value would be the previous position + the value # updated in ot3controller.capacitive_probe, and it kind of is here, but that # previous position is always 0. This is a test of ot3api though and checking @@ -910,13 +922,13 @@ async def test_gripper_action_fails_with_no_gripper( mock_ungrip: AsyncMock, ) -> None: with pytest.raises( - GripperNotAttachedError, match="Cannot perform action without gripper attached" + GripperNotPresentError, match="Cannot perform action without gripper attached" ): await ot3_hardware.grip(5.0) mock_grip.assert_not_called() with pytest.raises( - GripperNotAttachedError, match="Cannot perform action without gripper attached" + GripperNotPresentError, match="Cannot perform action without gripper attached" ): await ot3_hardware.ungrip() mock_ungrip.assert_not_called() @@ -938,7 +950,9 @@ async def test_gripper_action_works_with_gripper( } await ot3_hardware.cache_gripper(instr_data) - with pytest.raises(GripError, match="Gripper jaw must be homed before moving"): + with pytest.raises( + CommandPreconditionViolated, match="Cannot grip gripper jaw before homing" + ): await ot3_hardware.grip(5.0) await ot3_hardware.home_gripper_jaw() mock_ungrip.assert_called_once() @@ -967,7 +981,7 @@ async def test_gripper_move_fails_with_no_gripper( ot3_hardware: ThreadManager[OT3API], ) -> None: assert not ot3_hardware._gripper_handler.gripper - with pytest.raises(GripperNotAttachedError): + with pytest.raises(GripperNotPresentError): await ot3_hardware.move_to(OT3Mount.GRIPPER, Point(0, 0, 0)) @@ -978,7 +992,7 @@ async def test_gripper_mount_not_movable( instr_data = AttachedGripper(config=gripper_config, id="g12345") await ot3_hardware.cache_gripper(instr_data) assert ot3_hardware._gripper_handler.gripper - with pytest.raises(InvalidMoveError): + with pytest.raises(InvalidCriticalPoint): await ot3_hardware.move_to( OT3Mount.GRIPPER, Point(0, 0, 0), critical_point=CriticalPoint.MOUNT ) @@ -999,7 +1013,7 @@ async def test_gripper_fails_for_pipette_cps( instr_data = AttachedGripper(config=gripper_config, id="g12345") await ot3_hardware.cache_gripper(instr_data) assert ot3_hardware._gripper_handler.gripper - with pytest.raises(InvalidMoveError): + with pytest.raises(InvalidCriticalPoint): await ot3_hardware.move_to( OT3Mount.GRIPPER, Point(0, 0, 0), critical_point=critical_point ) @@ -1331,6 +1345,7 @@ async def test_pick_up_tip_full_tiprack( mock_instrument_handlers: Tuple[Mock], mock_ungrip: AsyncMock, mock_move_to_plunger_bottom: AsyncMock, + mock_home_gear_motors: AsyncMock, ) -> None: mock_ungrip.return_value = None await ot3_hardware.home() @@ -1368,8 +1383,6 @@ def _fake_function(): def _update_gear_motor_pos( moves: Optional[List[Move[Axis]]] = None, distance: Optional[float] = None, - velocity: Optional[float] = None, - tip_action: str = "home", ) -> None: if NodeId.pipette_left not in backend._gear_motor_position: backend._gear_motor_position = {NodeId.pipette_left: 0.0} @@ -1389,19 +1402,18 @@ def _update_gear_motor_pos( OT3Mount.LEFT, 40.0, None, None ) # first call should be "clamp", moving down - assert tip_action.call_args_list[0][-1]["tip_action"] == "clamp" assert tip_action.call_args_list[0][-1]["moves"][0].unit_vector == {Axis.Q: 1} # next call should be "clamp", moving back up - assert tip_action.call_args_list[1][-1]["tip_action"] == "clamp" assert tip_action.call_args_list[1][-1]["moves"][0].unit_vector == {Axis.Q: -1} - # last call should be "home" - assert tip_action.call_args_list[2][-1]["tip_action"] == "home" - assert len(tip_action.call_args_list) == 3 + assert len(tip_action.call_args_list) == 2 + # home should be called after tip_action is done + assert len(mock_home_gear_motors.call_args_list) == 1 async def test_drop_tip_full_tiprack( ot3_hardware: ThreadManager[OT3API], mock_instrument_handlers: Tuple[Mock], + mock_home_gear_motors: AsyncMock, ) -> None: _, pipette_handler = mock_instrument_handlers backend = ot3_hardware.managed_obj._backend @@ -1452,14 +1464,12 @@ def _update_gear_motor_pos( await ot3_hardware.drop_tip(Mount.LEFT, home_after=True) pipette_handler.plan_check_drop_tip.assert_called_once_with(OT3Mount.LEFT, True) # first call should be "clamp", moving down - assert tip_action.call_args_list[0][-1]["tip_action"] == "clamp" assert tip_action.call_args_list[0][-1]["moves"][0].unit_vector == {Axis.Q: 1} # next call should be "clamp", moving back up - assert tip_action.call_args_list[1][-1]["tip_action"] == "clamp" assert tip_action.call_args_list[1][-1]["moves"][0].unit_vector == {Axis.Q: -1} - # last call should be "home" - assert tip_action.call_args_list[2][-1]["tip_action"] == "home" - assert len(tip_action.call_args_list) == 3 + assert len(tip_action.call_args_list) == 2 + # home should be called after tip_action is done + assert len(mock_home_gear_motors.call_args_list) == 1 @pytest.mark.parametrize( @@ -1532,6 +1542,14 @@ async def test_home_axis( stepper_ok: bool, encoder_ok: bool, ) -> None: + if axis in Axis.pipette_axes(): + pipette_config = load_pipette_data.load_definition( + PipetteModelType("p1000"), + PipetteChannelType(1), + PipetteVersionType(3, 3), + ) + instr_data = AttachedPipette(config=pipette_config, id="fakepip") + await ot3_hardware.cache_pipette(Axis.to_ot3_mount(axis), instr_data, None) backend = ot3_hardware.managed_obj._backend origin_pos = {ax: 100 for ax in Axis} @@ -1688,8 +1706,8 @@ async def test_tip_presence_disabled_ninety_six_channel( # TODO remove this check once we enable tip presence for 96 chan. with patch.object( ot3_hardware.managed_obj._backend, - "get_tip_present", - AsyncMock(spec=ot3_hardware.managed_obj._backend.get_tip_present), + "check_for_tip_presence", + AsyncMock(spec=ot3_hardware.managed_obj._backend.check_for_tip_presence), ) as tip_present: pipette_config = load_pipette_data.load_definition( PipetteModelType("p1000"), diff --git a/api/tests/opentrons/hardware_control/test_ot3_calibration.py b/api/tests/opentrons/hardware_control/test_ot3_calibration.py index 86acd62dd96..6ecd5f360c1 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_calibration.py +++ b/api/tests/opentrons/hardware_control/test_ot3_calibration.py @@ -9,7 +9,7 @@ from mock import patch, AsyncMock, Mock, call as mock_call from opentrons.hardware_control import ThreadManager from opentrons.hardware_control.ot3api import OT3API -from opentrons.hardware_control.types import OT3Mount, Axis +from opentrons.hardware_control.types import OT3Mount, Axis, InstrumentProbeType from opentrons.config.types import OT3CalibrationSettings from opentrons.hardware_control.ot3_calibration import ( find_edge_binary, @@ -142,6 +142,11 @@ def _other_axis_val(point: Tuple[float, float, float], main_axis: Axis) -> float raise KeyError(main_axis) +# Mock Capacitive Probe Result (found_position[float], hit_deck[bool]) +_HIT = (1, True) +_MISS = (-1, False) + + @pytest.mark.parametrize( "search_axis,direction_if_hit,probe_results,search_result", [ @@ -149,12 +154,12 @@ def _other_axis_val(point: Tuple[float, float, float], main_axis: Axis) -> float # 1. hit-miss-miss # 2. miss-hit-hit # 3. miss-hit-miss - (Axis.X, -1, (1, -1, -1), -1), - (Axis.X, -1, (-1, 1, 1), 1), - (Axis.X, -1, (-1, 1, -1), 3), - (Axis.X, 1, (1, -1, -1), 1), - (Axis.X, 1, (-1, 1, 1), -1), - (Axis.X, 1, (-1, 1, -1), -3), + (Axis.X, -1, (_HIT, _MISS, _MISS), -1), + (Axis.X, -1, (_MISS, _HIT, _HIT), 1), + (Axis.X, -1, (_MISS, _HIT, _MISS), 3), + (Axis.X, 1, (_HIT, _MISS, _MISS), 1), + (Axis.X, 1, (_MISS, _HIT, _HIT), -1), + (Axis.X, 1, (_MISS, _HIT, _MISS), -3), ], ) async def test_find_edge( @@ -192,8 +197,8 @@ async def test_find_edge( @pytest.mark.parametrize( "search_axis,direction_if_hit,probe_results", [ - (Axis.X, -1, (1, 1)), - (Axis.Y, -1, (-1, -1)), + (Axis.X, -1, (_HIT, _HIT)), + (Axis.Y, -1, (_MISS, _MISS)), ], ) async def test_edge_not_found( @@ -224,7 +229,7 @@ async def test_find_edge_early_trigger( override_cal_config: None, ) -> None: await ot3_hardware.home() - mock_capacitive_probe.side_effect = (3,) + mock_capacitive_probe.side_effect = ((3, True), ()) with pytest.raises(EarlyCapacitiveSenseTrigger): await find_edge_binary( ot3_hardware, @@ -241,7 +246,7 @@ async def test_deck_not_found( override_cal_config: None, ) -> None: await ot3_hardware.home() - mock_capacitive_probe.side_effect = (-25,) + mock_capacitive_probe.side_effect = ((-25, False), ()) with pytest.raises(CalibrationStructureNotFoundError): await find_calibration_structure_height( ot3_hardware, @@ -263,7 +268,7 @@ async def test_find_deck_checks_z_only( ) -> None: await ot3_hardware.home() here = await ot3_hardware.gantry_position(mount) - mock_capacitive_probe.side_effect = (-1.8,) + mock_capacitive_probe.side_effect = ((-1.8, True),) await find_calibration_structure_height(ot3_hardware, mount, target) z_prep_loc = target + PREP_OFFSET_DEPTH @@ -273,6 +278,7 @@ async def test_find_deck_checks_z_only( mount, z_prep_loc, ot3_hardware.config.calibration.z_offset.pass_settings, + probe=InstrumentProbeType.PRIMARY, ) # first we move only to safe height from current position first_move_point = mock_move_to.call_args_list[0][0][1] diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index 1cabbc3fe20..29776d62c95 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -233,6 +233,51 @@ def test_96_channel_pipette_always_loads_on_the_left_mount( assert result == subject.loaded_instruments["left"] +def test_96_channel_pipette_raises_if_another_pipette_attached( + decoy: Decoy, + mock_core: ProtocolCore, + subject: ProtocolContext, +) -> None: + """It should always raise when loading a 96-channel pipette when another pipette is attached.""" + mock_instrument_core = decoy.mock(cls=InstrumentCore) + + decoy.when(mock_validation.ensure_lowercase_name("ada")).then_return("ada") + decoy.when(mock_validation.ensure_pipette_name("ada")).then_return( + PipetteNameType.P300_SINGLE + ) + decoy.when(mock_validation.ensure_mount(matchers.IsA(Mount))).then_return( + Mount.RIGHT + ) + + decoy.when( + mock_core.load_instrument( + instrument_name=PipetteNameType.P300_SINGLE, + mount=Mount.RIGHT, + ) + ).then_return(mock_instrument_core) + + decoy.when(mock_instrument_core.get_pipette_name()).then_return("ada") + + pipette_1 = subject.load_instrument(instrument_name="ada", mount=Mount.RIGHT) + assert subject.loaded_instruments["right"] is pipette_1 + + decoy.when(mock_validation.ensure_lowercase_name("A 96 Channel Name")).then_return( + "a 96 channel name" + ) + decoy.when(mock_validation.ensure_pipette_name("a 96 channel name")).then_return( + PipetteNameType.P1000_96 + ) + decoy.when( + mock_core.load_instrument( + instrument_name=PipetteNameType.P1000_96, + mount=Mount.LEFT, + ) + ).then_return(mock_instrument_core) + + with pytest.raises(RuntimeError): + subject.load_instrument(instrument_name="A 96 Channel Name", mount="shadowfax") + + def test_load_labware( decoy: Decoy, mock_core: ProtocolCore, diff --git a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py index 35a152839c8..4932edb855b 100644 --- a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py @@ -4,12 +4,12 @@ import pytest from pytest_lazyfixture import lazy_fixture # type: ignore[import] -from opentrons.hardware_control import ( - NoTipAttachedError, - TipAttachedError, -) from opentrons.protocol_api.core.common import InstrumentCore, LabwareCore from opentrons.types import Location, Point +from opentrons_shared_data.errors.exceptions import ( + UnexpectedTipRemovalError, + UnexpectedTipAttachError, +) # TODO (lc 12-8-2022) Not sure if we plan to keep these tests, but if we do # we should re-write them to be agnostic to the underlying hardware. Otherwise @@ -40,7 +40,9 @@ def test_same_pipette( def test_prepare_to_aspirate_no_tip(subject: InstrumentCore) -> None: """It should raise an error if a tip is not attached.""" - with pytest.raises(NoTipAttachedError, match="Cannot perform PREPARE_ASPIRATE"): + with pytest.raises( + UnexpectedTipRemovalError, match="Cannot perform PREPARE_ASPIRATE" + ): subject.prepare_for_aspirate() # type: ignore[attr-defined] @@ -48,7 +50,7 @@ def test_dispense_no_tip(subject: InstrumentCore) -> None: """It should raise an error if a tip is not attached.""" subject.home() location = Location(point=Point(1, 2, 3), labware=None) - with pytest.raises(NoTipAttachedError, match="Cannot perform DISPENSE"): + with pytest.raises(UnexpectedTipRemovalError, match="Cannot perform DISPENSE"): subject.dispense( volume=1, rate=1, @@ -65,13 +67,13 @@ def test_drop_tip_no_tip(subject: InstrumentCore, tip_rack: LabwareCore) -> None tip_core = tip_rack.get_well_core("A1") subject.home() - with pytest.raises(NoTipAttachedError, match="Cannot perform DROPTIP"): + with pytest.raises(UnexpectedTipRemovalError, match="Cannot perform DROPTIP"): subject.drop_tip(location=None, well_core=tip_core, home_after=False) def test_blow_out_no_tip(subject: InstrumentCore, labware: LabwareCore) -> None: """It should raise an error if a tip is not attached.""" - with pytest.raises(NoTipAttachedError, match="Cannot perform BLOWOUT"): + with pytest.raises(UnexpectedTipRemovalError, match="Cannot perform BLOWOUT"): subject.blow_out( location=Location(point=Point(1, 2, 3), labware=None), well_core=labware.get_well_core("A1"), @@ -91,7 +93,7 @@ def test_pick_up_tip_no_tip(subject: InstrumentCore, tip_rack: LabwareCore) -> N increment=None, prep_after=False, ) - with pytest.raises(TipAttachedError): + with pytest.raises(UnexpectedTipAttachError): subject.pick_up_tip( location=Location(point=tip_core.get_top(z_offset=0), labware=None), well_core=tip_core, diff --git a/api/tests/opentrons/protocol_api_old/test_context.py b/api/tests/opentrons/protocol_api_old/test_context.py index 3d5faed74fb..abcdd06a6d8 100644 --- a/api/tests/opentrons/protocol_api_old/test_context.py +++ b/api/tests/opentrons/protocol_api_old/test_context.py @@ -6,6 +6,7 @@ from opentrons_shared_data import load_shared_data from opentrons_shared_data.pipette.dev_types import LabwareUri +from opentrons_shared_data.errors.exceptions import UnexpectedTipRemovalError import opentrons.protocol_api as papi import opentrons.protocols.api_support as papi_support @@ -17,7 +18,7 @@ HeaterShakerContext, ) from opentrons.types import Mount, Point, Location, TransferTipPolicy -from opentrons.hardware_control import API, NoTipAttachedError, ThreadManagedHardware +from opentrons.hardware_control import API, ThreadManagedHardware from opentrons.hardware_control.instruments.ot2.pipette import Pipette from opentrons.hardware_control.types import Axis from opentrons.protocols.advanced_control import transfers as tf @@ -581,7 +582,7 @@ def test_prevent_liquid_handling_without_tip(ctx): plate = ctx.load_labware("corning_384_wellplate_112ul_flat", "2") pipR = ctx.load_instrument("p300_single", Mount.RIGHT, tip_racks=[tr]) - with pytest.raises(NoTipAttachedError): + with pytest.raises(UnexpectedTipRemovalError): pipR.aspirate(100, plate.wells()[0]) pipR.pick_up_tip() @@ -589,7 +590,7 @@ def test_prevent_liquid_handling_without_tip(ctx): pipR.aspirate(100, plate.wells()[0]) pipR.drop_tip() - with pytest.raises(NoTipAttachedError): + with pytest.raises(UnexpectedTipRemovalError): pipR.dispense(100, plate.wells()[1]) diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py index dd45563d2eb..9444a3df5ec 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_labware.py @@ -6,6 +6,11 @@ from opentrons.types import DeckSlotName from opentrons.protocols.models import LabwareDefinition + +from opentrons.protocol_engine.errors import ( + LabwareIsNotAllowedInLocationError, +) + from opentrons.protocol_engine.types import ( DeckSlotLocation, OnLabwareLocation, @@ -76,6 +81,28 @@ async def test_load_labware_implementation( ) +async def test_load_labware_raises_location_not_allowed( + decoy: Decoy, + equipment: EquipmentHandler, + state_view: StateView, +) -> None: + """A LoadLabware command should raise if the flex trash definition is not in a valid slot.""" + subject = LoadLabwareImplementation(equipment=equipment, state_view=state_view) + + data = LoadLabwareParams( + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_A2), + loadName="some-load-name", + namespace="opentrons-test", + version=1, + displayName="My custom display name", + ) + + decoy.when(labware_validation.is_flex_trash("some-load-name")).then_return(True) + + with pytest.raises(LabwareIsNotAllowedInLocationError): + await subject.execute(data) + + async def test_load_labware_on_labware( decoy: Decoy, well_plate_def: LabwareDefinition, diff --git a/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py b/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py index 90adb223647..45a935c9d08 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py +++ b/api/tests/opentrons/protocol_engine/commands/test_load_pipette.py @@ -74,7 +74,7 @@ async def test_load_pipette_implementation_96_channel( subject = LoadPipetteImplementation(equipment=equipment) data = LoadPipetteParams( - pipetteName="p1000_96", + pipetteName=PipetteNameType.P1000_96, mount=MountType.LEFT, pipetteId="some id", ) @@ -95,7 +95,7 @@ async def test_load_pipette_implementation_96_channel( decoy.when( await equipment.load_pipette( - pipette_name="p1000_96", + pipette_name=PipetteNameType.P1000_96, mount=MountType.LEFT, pipette_id="some id", ) diff --git a/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py b/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py index 710b4c72ada..17cf5d53248 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_equipment_handler.py @@ -682,7 +682,7 @@ async def test_load_pipette_96_channels( ) result = await subject.load_pipette( - pipette_name="p1000_96", + pipette_name=PipetteNameType.P1000_96, mount=MountType.LEFT, pipette_id=None, ) diff --git a/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py b/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py index bb356d7f0a1..01a3ca6e3a5 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py +++ b/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py @@ -11,7 +11,7 @@ CriticalPoint, Axis as HardwareAxis, ) -from opentrons.hardware_control.errors import MustHomeError as HardwareMustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from opentrons.motion_planning import Waypoint @@ -143,7 +143,7 @@ async def test_get_position_raises( critical_point=CriticalPoint.NOZZLE, fail_on_not_homed=False, ) - ).then_raise(HardwareMustHomeError("oh no")) + ).then_raise(PositionUnknownError("oh no")) with pytest.raises(MustHomeError, match="oh no"): await hardware_subject.get_position("pipette-id") @@ -266,7 +266,7 @@ async def test_move_relative_must_home( fail_on_not_homed=True, speed=456.7, ) - ).then_raise(HardwareMustHomeError("oh no")) + ).then_raise(PositionUnknownError("oh no")) with pytest.raises(MustHomeError, match="oh no"): await hardware_subject.move_relative( diff --git a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py index ac24a5810e1..e53242c93e7 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_movement_handler.py @@ -6,7 +6,6 @@ from opentrons.types import MountType, Point, DeckSlotName, Mount from opentrons.hardware_control import API as HardwareAPI from opentrons.hardware_control.types import CriticalPoint -from opentrons.hardware_control.errors import MustHomeError from opentrons.motion_planning import Waypoint from opentrons.protocol_engine.types import ( @@ -31,6 +30,7 @@ HeaterShakerMovementFlagger, ) from opentrons.protocol_engine.execution.gantry_mover import GantryMover +from opentrons_shared_data.errors.exceptions import PositionUnknownError @pytest.fixture @@ -445,7 +445,7 @@ async def test_check_valid_position( ).then_return(Point(0, 0, 0)) decoy.when( await hardware_api.gantry_position(mount=Mount.RIGHT, fail_on_not_homed=True) - ).then_raise(MustHomeError()) + ).then_raise(PositionUnknownError()) assert await subject.check_for_valid_position(MountType.LEFT) assert not await subject.check_for_valid_position(MountType.RIGHT) diff --git a/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py b/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py index 63a9a052450..495d545bf78 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py @@ -1,5 +1,5 @@ """Pipetting execution handler.""" -from typing import cast +from typing import cast, Optional import pytest from decoy import Decoy @@ -19,6 +19,7 @@ TipNotAttachedError, InvalidPipettingVolumeError, InvalidPushOutVolumeError, + InvalidDispenseVolumeError, ) @@ -362,6 +363,10 @@ async def test_dispense_in_place_virtual( TipGeometry(length=1, diameter=2, volume=3) ) + decoy.when(mock_state_view.pipettes.get_aspirated_volume("pipette-id")).then_return( + 3 + ) + result = await subject.dispense_in_place( pipette_id="pipette-id", volume=3, flow_rate=5, push_out=None ) @@ -378,12 +383,37 @@ async def test_dispense_in_place_virtual_raises_invalid_push_out( TipGeometry(length=1, diameter=2, volume=3) ) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=1, diameter=2, volume=3) + ) + with pytest.raises(InvalidPushOutVolumeError): await subject.dispense_in_place( pipette_id="pipette-id", volume=3, flow_rate=5, push_out=-7 ) +@pytest.mark.parametrize("aspirated_volume", [(None), (1)]) +async def test_dispense_in_place_virtual_raises_invalid_dispense( + decoy: Decoy, mock_state_view: StateView, aspirated_volume: Optional[float] +) -> None: + """Should raise an InvalidDispenseVolumeError.""" + subject = VirtualPipettingHandler(state_view=mock_state_view) + + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=1, diameter=2, volume=3) + ) + + decoy.when(mock_state_view.pipettes.get_aspirated_volume("pipette-id")).then_return( + aspirated_volume + ) + + with pytest.raises(InvalidDispenseVolumeError): + await subject.dispense_in_place( + pipette_id="pipette-id", volume=3, flow_rate=5, push_out=7 + ) + + async def test_validate_tip_attached_in_blow_out( mock_state_view: StateView, decoy: Decoy ) -> None: diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx index 293f4ea1fc3..5064cdf0672 100644 --- a/app/src/App/DesktopApp.tsx +++ b/app/src/App/DesktopApp.tsx @@ -107,28 +107,29 @@ export const DesktopApp = (): JSX.Element => { }} > - - {desktopRoutes.map(({ Component, exact, path }: RouteProps) => { - return ( - - - - - - - - ) - })} - - - - + + + {desktopRoutes.map(({ Component, exact, path }: RouteProps) => { + return ( + + + + + + + + ) + })} + + + + diff --git a/app/src/App/__tests__/DesktopApp.test.tsx b/app/src/App/__tests__/DesktopApp.test.tsx index bd33d7ead85..2ce2028d695 100644 --- a/app/src/App/__tests__/DesktopApp.test.tsx +++ b/app/src/App/__tests__/DesktopApp.test.tsx @@ -13,12 +13,11 @@ import { ProtocolsLanding } from '../../pages/Protocols/ProtocolsLanding' import { ProtocolRunDetails } from '../../pages/Devices/ProtocolRunDetails' import { RobotSettings } from '../../pages/Devices/RobotSettings' import { GeneralSettings } from '../../pages/AppSettings/GeneralSettings' -import { Alerts } from '../../organisms/Alerts' +import { AlertsModal } from '../../organisms/Alerts/AlertsModal' import { useIsOT3 } from '../../organisms/Devices/hooks' import { useSoftwareUpdatePoll } from '../hooks' import { DesktopApp } from '../DesktopApp' -jest.mock('../../organisms/Alerts') jest.mock('../../organisms/Breadcrumbs') jest.mock('../../organisms/Devices/hooks') jest.mock('../../pages/AppSettings/GeneralSettings') @@ -29,6 +28,7 @@ jest.mock('../../pages/Protocols/ProtocolsLanding') jest.mock('../../pages/Devices/ProtocolRunDetails') jest.mock('../../pages/Devices/RobotSettings') jest.mock('../hooks') +jest.mock('../../organisms/Alerts/AlertsModal') const mockCalibrationDashboard = CalibrationDashboard as jest.MockedFunction< typeof CalibrationDashboard @@ -48,10 +48,10 @@ const mockProtocolRunDetails = ProtocolRunDetails as jest.MockedFunction< const mockRobotSettings = RobotSettings as jest.MockedFunction< typeof RobotSettings > -const mockAlerts = Alerts as jest.MockedFunction const mockAppSettings = GeneralSettings as jest.MockedFunction< typeof GeneralSettings > +const mockAlertsModal = AlertsModal as jest.MockedFunction const mockBreadcrumbs = Breadcrumbs as jest.MockedFunction const mockUseSoftwareUpdatePoll = useSoftwareUpdatePoll as jest.MockedFunction< typeof useSoftwareUpdatePoll @@ -77,9 +77,9 @@ describe('DesktopApp', () => { mockProtocolsLanding.mockReturnValue(
    Mock ProtocolsLanding
    ) mockProtocolRunDetails.mockReturnValue(
    Mock ProtocolRunDetails
    ) mockRobotSettings.mockReturnValue(
    Mock RobotSettings
    ) - mockAlerts.mockReturnValue(
    Mock Alerts
    ) mockAppSettings.mockReturnValue(
    Mock AppSettings
    ) mockBreadcrumbs.mockReturnValue(
    Mock Breadcrumbs
    ) + mockAlertsModal.mockReturnValue(<>) mockUseIsOT3.mockReturnValue(true) }) afterEach(() => { @@ -129,11 +129,6 @@ describe('DesktopApp', () => { getByText('Mock ProtocolRunDetails') }) - it('should render app-wide Alerts', () => { - const [{ getByText }] = render() - getByText('Mock Alerts') - }) - it('should poll for software updates', () => { render() expect(mockUseSoftwareUpdatePoll).toBeCalled() diff --git a/app/src/DesignTokens/constants.ts b/app/src/DesignTokens/constants.ts new file mode 100644 index 00000000000..baabff4bbda --- /dev/null +++ b/app/src/DesignTokens/constants.ts @@ -0,0 +1,18 @@ +// Note (kk:08/29/2023) Needed this in this ts file to avoid check-js errors on CI +const customViewports = { + onDeviceDisplay: { + name: 'Touchscreen', + type: 'tablet', + styles: { + width: '1024px', + height: '600px', + }, + }, +} + +export const touchScreenViewport = { + viewport: { + viewports: customViewports, + defaultViewport: 'onDeviceDisplay', + }, +} diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index c77e2e4012d..4b223c7f69f 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -1,4 +1,5 @@ { + "__dev_internal__enableDeckConfiguration": "Enable Deck Configuration", "__dev_internal__enableExtendedHardware": "Enable Extended Hardware", "__dev_internal__lpcWithProbe": "Golden Tip LPC", "add_folder_button": "Add labware source folder", diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index 86cca83b740..525f9d0343c 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -4,6 +4,7 @@ "about_module": "About {{name}}", "about_pipette": "About pipette", "about_pipette_name": "About {{name}} Pipette", + "add": "Add", "an_error_occurred_while_updating": "An error occurred while updating your pipette's settings.", "an_error_occurred_while_updating_module": "An error occurred while updating your {{moduleName}}. Please try again.", "an_error_occurred_while_updating_please_try_again": "An error occurred while updating your pipette's settings. Please try again.", @@ -26,6 +27,7 @@ "current_temp": "Current: {{temp}} °C", "current_version": "Current Version", "deck_cal_missing": "Pipette Offset calibration missing. Calibrate deck first.", + "deck_configuration": "deck configuration", "deck_slot": "deck slot {{slot}}", "delete_run": "Delete protocol run record", "detach_gripper": "Detach gripper", diff --git a/app/src/atoms/Chip/Chip.stories.tsx b/app/src/atoms/Chip/Chip.stories.tsx index 42a9a15fd0c..1ac28f769a4 100644 --- a/app/src/atoms/Chip/Chip.stories.tsx +++ b/app/src/atoms/Chip/Chip.stories.tsx @@ -1,11 +1,13 @@ import * as React from 'react' import { Flex, COLORS, SPACING } from '@opentrons/components' +import { touchScreenViewport } from '../../DesignTokens/constants' import { Chip } from '.' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Chip', component: Chip, + parameters: touchScreenViewport, } as Meta interface ChipStorybookProps extends React.ComponentProps { diff --git a/app/src/atoms/InlineNotification/InlineNotification.stories.tsx b/app/src/atoms/InlineNotification/InlineNotification.stories.tsx index 0466edc254e..71fca5a8277 100644 --- a/app/src/atoms/InlineNotification/InlineNotification.stories.tsx +++ b/app/src/atoms/InlineNotification/InlineNotification.stories.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { touchScreenViewport } from '../../DesignTokens/constants' import { InlineNotification } from '.' import type { Story, Meta } from '@storybook/react' @@ -25,6 +26,7 @@ export default { defaultValue: true, }, }, + parameters: touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/atoms/ListItem/ListItem.stories.tsx b/app/src/atoms/ListItem/ListItem.stories.tsx index 3f7cc3da4b3..28c6d1cca90 100644 --- a/app/src/atoms/ListItem/ListItem.stories.tsx +++ b/app/src/atoms/ListItem/ListItem.stories.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { DIRECTION_COLUMN, Flex, SPACING } from '@opentrons/components' +import { touchScreenViewport } from '../../DesignTokens/constants' import { StyledText } from '../text' import { ListItem } from '.' import type { Story, Meta } from '@storybook/react' @@ -14,6 +15,7 @@ export default { }, }, }, + parameters: touchScreenViewport, } as Meta const ListItemTemplate: Story> = args => ( diff --git a/app/src/atoms/Slideout/index.tsx b/app/src/atoms/Slideout/index.tsx index fc8e137ae66..2a9b4d63a21 100644 --- a/app/src/atoms/Slideout/index.tsx +++ b/app/src/atoms/Slideout/index.tsx @@ -181,9 +181,7 @@ export const Slideout = (props: SlideoutProps): JSX.Element => { size="1.5rem" onClick={handleClose} aria-label="exit" - data-testid={`Slideout_icon_close_${ - typeof title === 'string' ? title : '' - }`} + data-testid={`Slideout_icon_close_${title}`} > diff --git a/app/src/atoms/Snackbar/Snackbar.stories.tsx b/app/src/atoms/Snackbar/Snackbar.stories.tsx index 99592a594cb..f1bbb35048a 100644 --- a/app/src/atoms/Snackbar/Snackbar.stories.tsx +++ b/app/src/atoms/Snackbar/Snackbar.stories.tsx @@ -9,12 +9,15 @@ import { SPACING, } from '@opentrons/components' import { StyledText } from '../text' +import { touchScreenViewport } from '../../DesignTokens/constants' + import { Snackbar } from './index' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Snackbar', component: Snackbar, + parameters: touchScreenViewport, } as Meta const DefaultTemplate: Story> = args => { diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx index 87cbbcb1da9..c1531245595 100644 --- a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx @@ -5,15 +5,16 @@ import { POSITION_ABSOLUTE, SPACING, } from '@opentrons/components' +import { touchScreenViewport } from '../../../DesignTokens/constants' import { InputField } from '../../InputField' -import { CustomKeyboard } from '.' -import '../../../styles.global.css' +import { CustomKeyboard } from './' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/SoftwareKeyboard/CustomKeyboard', component: CustomKeyboard, + parameters: touchScreenViewport, } as Meta const Template: Story> = args => { diff --git a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx index d7d62d87d7c..65a236ea1c9 100644 --- a/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx @@ -5,15 +5,15 @@ import { POSITION_ABSOLUTE, SPACING, } from '@opentrons/components' +import { touchScreenViewport } from '../../../DesignTokens/constants' import { InputField } from '../../InputField' import { NormalKeyboard } from '.' -import '../../../styles.global.css' - import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/SoftwareKeyboard/NormalKeyboard', component: NormalKeyboard, + parameters: touchScreenViewport, } as Meta const Template: Story> = args => { diff --git a/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx b/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx index 576df0a66e4..49d7f8e739c 100644 --- a/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx @@ -5,15 +5,16 @@ import { POSITION_ABSOLUTE, SPACING, } from '@opentrons/components' +import { touchScreenViewport } from '../../../DesignTokens/constants' import { InputField } from '../../InputField' -import { Numpad } from '.' -import '../../../styles.global.css' +import { Numpad } from './' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/SoftwareKeyboard/Numpad', component: Numpad, + parameters: touchScreenViewport, } as Meta const Template: Story> = args => { diff --git a/app/src/atoms/Toast/ODDToast.stories.tsx b/app/src/atoms/Toast/ODDToast.stories.tsx index a523368c081..2f0e72fb0b7 100644 --- a/app/src/atoms/Toast/ODDToast.stories.tsx +++ b/app/src/atoms/Toast/ODDToast.stories.tsx @@ -8,6 +8,7 @@ import { PrimaryButton, SPACING, } from '@opentrons/components' +import { touchScreenViewport } from '../../DesignTokens/constants' import { StyledText } from '../text' import { Toast } from '.' import type { Story, Meta } from '@storybook/react' @@ -15,6 +16,7 @@ import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Toast', component: Toast, + parameters: touchScreenViewport, } as Meta const Template: Story> = args => { diff --git a/app/src/atoms/buttons/FloatingActionButton.stories.tsx b/app/src/atoms/buttons/FloatingActionButton.stories.tsx index 3004f6ccd62..9b554fb5344 100644 --- a/app/src/atoms/buttons/FloatingActionButton.stories.tsx +++ b/app/src/atoms/buttons/FloatingActionButton.stories.tsx @@ -1,7 +1,9 @@ import * as React from 'react' +import { ICON_DATA_BY_NAME } from '@opentrons/components/src/icons/icon-data' +import { touchScreenViewport } from '../../DesignTokens/constants' import { FloatingActionButton } from './' + import type { Story, Meta } from '@storybook/react' -import { ICON_DATA_BY_NAME } from '@opentrons/components/src/icons/icon-data' export default { title: 'ODD/Atoms/Buttons/FloatingActionButton', @@ -15,6 +17,7 @@ export default { }, onClick: { action: 'clicked' }, }, + parameters: touchScreenViewport, } as Meta const FloatingActionButtonTemplate: Story< diff --git a/app/src/atoms/buttons/LargeButton.stories.tsx b/app/src/atoms/buttons/LargeButton.stories.tsx index 1d153cae54e..737dada7656 100644 --- a/app/src/atoms/buttons/LargeButton.stories.tsx +++ b/app/src/atoms/buttons/LargeButton.stories.tsx @@ -1,10 +1,12 @@ import * as React from 'react' +import { touchScreenViewport } from '../../DesignTokens/constants' import { LargeButton } from './' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Buttons/LargeButton', argTypes: { onClick: { action: 'clicked' } }, + parameters: touchScreenViewport, } as Meta const LargeButtonTemplate: Story< diff --git a/app/src/atoms/buttons/MediumButton.stories.tsx b/app/src/atoms/buttons/MediumButton.stories.tsx index c073a45311a..724f7e56a19 100644 --- a/app/src/atoms/buttons/MediumButton.stories.tsx +++ b/app/src/atoms/buttons/MediumButton.stories.tsx @@ -1,7 +1,8 @@ import * as React from 'react' +import { ICON_DATA_BY_NAME } from '@opentrons/components/src/icons/icon-data' +import { touchScreenViewport } from '../../DesignTokens/constants' import { MediumButton } from './' import type { Story, Meta } from '@storybook/react' -import { ICON_DATA_BY_NAME } from '@opentrons/components/src/icons/icon-data' export default { title: 'ODD/Atoms/Buttons/MediumButton', @@ -28,6 +29,7 @@ export default { defaultValue: undefined, }, }, + parameters: touchScreenViewport, } as Meta const MediumButtonTemplate: Story< diff --git a/app/src/atoms/buttons/RadioButton.stories.tsx b/app/src/atoms/buttons/RadioButton.stories.tsx index 3a0575b28ed..7bb570ffae9 100644 --- a/app/src/atoms/buttons/RadioButton.stories.tsx +++ b/app/src/atoms/buttons/RadioButton.stories.tsx @@ -1,4 +1,6 @@ import * as React from 'react' +import { touchScreenViewport } from '../../DesignTokens/constants' + import { RadioButton } from './' import type { Story, Meta } from '@storybook/react' @@ -14,6 +16,7 @@ export default { }, onClick: { action: 'clicked' }, }, + parameters: touchScreenViewport, } as Meta const RadioButtonTemplate: Story< diff --git a/app/src/atoms/buttons/SmallButton.stories.tsx b/app/src/atoms/buttons/SmallButton.stories.tsx index 8d01b5a0873..cb1263f8a6c 100644 --- a/app/src/atoms/buttons/SmallButton.stories.tsx +++ b/app/src/atoms/buttons/SmallButton.stories.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { touchScreenViewport } from '../../DesignTokens/constants' import { SmallButton } from './' import type { Story, Meta } from '@storybook/react' @@ -7,6 +8,7 @@ export default { title: 'ODD/Atoms/Buttons/SmallButton', argTypes: { onClick: { action: 'clicked' } }, component: SmallButton, + parameters: touchScreenViewport, } as Meta const Template: Story> = args => ( diff --git a/app/src/atoms/buttons/TabbedButton.stories.tsx b/app/src/atoms/buttons/TabbedButton.stories.tsx index dfd38cf145d..27efbc36a87 100644 --- a/app/src/atoms/buttons/TabbedButton.stories.tsx +++ b/app/src/atoms/buttons/TabbedButton.stories.tsx @@ -1,10 +1,12 @@ import * as React from 'react' +import { touchScreenViewport } from '../../DesignTokens/constants' import { TabbedButton } from './' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Atoms/Buttons/TabbedButton', argTypes: { onClick: { action: 'clicked' } }, + parameters: touchScreenViewport, } as Meta const TabbedButtonTemplate: Story< diff --git a/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx b/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx index bec5c3e33db..e0b8c9bee1c 100644 --- a/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx +++ b/app/src/molecules/BackgroundOverlay/BackgroundOverlay.stories.tsx @@ -1,11 +1,13 @@ import * as React from 'react' import { Flex, PrimaryButton } from '@opentrons/components' +import { touchScreenViewport } from '../../DesignTokens/constants' import { StyledText } from '../../atoms/text' import { BackgroundOverlay } from './index' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Molecules/BackgroundOverlay', + parameters: touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/molecules/CardButton/CardButton.stories.tsx b/app/src/molecules/CardButton/CardButton.stories.tsx index 4e8ea334e02..38ce4a0f609 100644 --- a/app/src/molecules/CardButton/CardButton.stories.tsx +++ b/app/src/molecules/CardButton/CardButton.stories.tsx @@ -1,14 +1,16 @@ import * as React from 'react' import { MemoryRouter } from 'react-router-dom' import { Flex, SPACING } from '@opentrons/components' +import { touchScreenViewport } from '../../DesignTokens/constants' import { GlobalStyle } from '../../atoms/GlobalStyle' import { CardButton } from '.' import type { Story, Meta } from '@storybook/react' export default { - title: 'Odd/Molecules/CardButton', + title: 'ODD/Molecules/CardButton', component: CardButton, + parameters: touchScreenViewport, decorators: [ Story => ( <> diff --git a/app/src/molecules/Modal/Modal.stories.tsx b/app/src/molecules/Modal/Modal.stories.tsx index 45afc84fb9c..5295090666f 100644 --- a/app/src/molecules/Modal/Modal.stories.tsx +++ b/app/src/molecules/Modal/Modal.stories.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { COLORS, Flex, BORDERS, SPACING } from '@opentrons/components' +import { touchScreenViewport } from '../../DesignTokens/constants' import { Modal } from './Modal' import type { Story, Meta } from '@storybook/react' @@ -12,6 +13,7 @@ export default { }, onOutsideClick: { action: 'clicked' }, }, + parameters: touchScreenViewport, } as Meta const Template: Story> = args => ( diff --git a/app/src/molecules/Modal/ModalHeader.stories.tsx b/app/src/molecules/Modal/ModalHeader.stories.tsx index 998600c396e..63791cf089e 100644 --- a/app/src/molecules/Modal/ModalHeader.stories.tsx +++ b/app/src/molecules/Modal/ModalHeader.stories.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { COLORS } from '@opentrons/components/src/ui-style-constants' +import { touchScreenViewport } from '../../DesignTokens/constants' import { ModalHeader } from './ModalHeader' import type { Story, Meta } from '@storybook/react' @@ -23,6 +24,7 @@ export default { }, onClick: { action: 'clicked' }, }, + parameters: touchScreenViewport, } as Meta const Template: Story> = args => ( diff --git a/app/src/molecules/Modal/SmallModalChildren.stories.tsx b/app/src/molecules/Modal/SmallModalChildren.stories.tsx index 95e4f5046bc..cdea430b18f 100644 --- a/app/src/molecules/Modal/SmallModalChildren.stories.tsx +++ b/app/src/molecules/Modal/SmallModalChildren.stories.tsx @@ -1,10 +1,12 @@ import * as React from 'react' +import { touchScreenViewport } from '../../DesignTokens/constants' import { SmallModalChildren } from './SmallModalChildren' import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Molecules/Modals/SmallModalChildren', argTypes: { onClick: { action: 'clicked' } }, + parameters: touchScreenViewport, } as Meta const Template: Story< diff --git a/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx b/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx index c139db1b7ff..14a0d050ba5 100644 --- a/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx +++ b/app/src/molecules/ODDBackButton/ODDBackButton.stories.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { touchScreenViewport } from '../../DesignTokens/constants' import { ODDBackButton } from '.' import type { Story, Meta } from '@storybook/react' @@ -7,6 +8,7 @@ export default { argTypes: { onClick: { action: 'clicked' }, }, + parameters: touchScreenViewport, } as Meta const ODDBackButtonTemplate: Story< diff --git a/app/src/organisms/Alerts/index.tsx b/app/src/organisms/Alerts/AlertsModal.tsx similarity index 80% rename from app/src/organisms/Alerts/index.tsx rename to app/src/organisms/Alerts/AlertsModal.tsx index c23581cdb34..928d5762a0e 100644 --- a/app/src/organisms/Alerts/index.tsx +++ b/app/src/organisms/Alerts/AlertsModal.tsx @@ -11,16 +11,22 @@ import { useToaster } from '../ToasterOven' import { AnalyticsSettingsModal } from '../AnalyticsSettingsModal' import { UpdateAppModal } from '../UpdateAppModal' import { U2EDriverOutdatedAlert } from './U2EDriverOutdatedAlert' +import { useRemoveActiveAppUpdateToast } from '.' import type { State, Dispatch } from '../../redux/types' import type { AlertId } from '../../redux/alerts/types' +import type { MutableRefObject } from 'react' -export function Alerts(): JSX.Element { +interface AlertsModalProps { + toastIdRef: MutableRefObject +} + +export function AlertsModal({ toastIdRef }: AlertsModalProps): JSX.Element { const dispatch = useDispatch() const [showUpdateModal, setShowUpdateModal] = React.useState(false) const { t } = useTranslation('app_settings') - const { makeToast, eatToast } = useToaster() - const toastRef = React.useRef(null) + const { makeToast } = useToaster() + const { removeActiveAppUpdateToast } = useRemoveActiveAppUpdateToast() // TODO(mc, 2020-05-07): move head logic to selector with alert priorities const activeAlertId: AlertId | null = useSelector((state: State) => { @@ -42,6 +48,8 @@ export function Alerts(): JSX.Element { const hasJustUpdated = useSelector(getHasJustUpdated) const removeToast = !isAppUpdateAvailable || isAppUpdateIgnored + const createAppUpdateAvailableToast = + isAppUpdateAvailable && !isAppUpdateIgnored // Only run this hook on app startup React.useEffect(() => { @@ -55,8 +63,8 @@ export function Alerts(): JSX.Element { }, []) React.useEffect(() => { - if (isAppUpdateAvailable && !isAppUpdateIgnored) { - toastRef.current = makeToast( + if (createAppUpdateAvailableToast) { + toastIdRef.current = makeToast( t('opentrons_app_update_available_variation'), WARNING_TOAST, { @@ -66,16 +74,15 @@ export function Alerts(): JSX.Element { onLinkClick: () => setShowUpdateModal(true), } ) - } else if (removeToast && toastRef.current) { - eatToast(toastRef.current) - toastRef.current = null + } else if (removeToast && toastIdRef.current) { + removeActiveAppUpdateToast() } }, [isAppUpdateAvailable, isAppUpdateIgnored]) return ( <> {/* TODO(mc, 2020-05-07): AnalyticsSettingsModal currently controls its - own render; move its logic into `state.alerts` */} + own render; move its logic into `state.alerts` */} {activeAlertId === AppAlerts.ALERT_U2E_DRIVER_OUTDATED ? ( diff --git a/app/src/organisms/Alerts/AlertsProvider.tsx b/app/src/organisms/Alerts/AlertsProvider.tsx new file mode 100644 index 00000000000..58215bc1e73 --- /dev/null +++ b/app/src/organisms/Alerts/AlertsProvider.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { AlertsModal } from '.' +import { useToaster } from '../ToasterOven' + +export interface AlertsContextProps { + removeActiveAppUpdateToast: () => void +} + +export const AlertsContext = React.createContext({ + removeActiveAppUpdateToast: () => null, +}) + +interface AlertsProps { + children: React.ReactNode +} + +export function Alerts({ children }: AlertsProps): JSX.Element { + const toastRef = React.useRef(null) + const { eatToast } = useToaster() + + const removeActiveAppUpdateToast = (): void => { + if (toastRef.current) { + eatToast(toastRef.current) + toastRef.current = null + } + } + + return ( + + + {children} + + ) +} diff --git a/app/src/organisms/Alerts/__tests__/Alerts.test.tsx b/app/src/organisms/Alerts/__tests__/Alerts.test.tsx index 61c13605214..36742d505ea 100644 --- a/app/src/organisms/Alerts/__tests__/Alerts.test.tsx +++ b/app/src/organisms/Alerts/__tests__/Alerts.test.tsx @@ -4,11 +4,13 @@ import { when } from 'jest-when' import { mountWithStore } from '@opentrons/components' import * as AppAlerts from '../../../redux/alerts' import { getAvailableShellUpdate } from '../../../redux/shell' +import { getHasJustUpdated } from '../../../redux/config' import { TOAST_ANIMATION_DURATION } from '../../../atoms/Toast' -import { Alerts } from '..' +import { AlertsModal } from '../AlertsModal' import { AnalyticsSettingsModal } from '../../AnalyticsSettingsModal' import { U2EDriverOutdatedAlert } from '../U2EDriverOutdatedAlert' import { UpdateAppModal } from '../../UpdateAppModal' +import { useRemoveActiveAppUpdateToast } from '..' import type { State } from '../../../redux/types' import type { AlertId } from '../../../redux/alerts/types' @@ -24,6 +26,8 @@ jest.mock('../../UpdateAppModal', () => ({ })) jest.mock('../../../redux/alerts/selectors') jest.mock('../../../redux/shell') +jest.mock('../../../redux/config') +jest.mock('..') const getActiveAlerts = AppAlerts.getActiveAlerts as jest.MockedFunction< typeof AppAlerts.getActiveAlerts @@ -31,14 +35,26 @@ const getActiveAlerts = AppAlerts.getActiveAlerts as jest.MockedFunction< const mockGetAvailableShellUpdate = getAvailableShellUpdate as jest.MockedFunction< typeof getAvailableShellUpdate > +const mockGetHasJustUpdated = getHasJustUpdated as jest.MockedFunction< + typeof getHasJustUpdated +> +const mockUseRemoveActiveAppUpdateToast = useRemoveActiveAppUpdateToast as jest.MockedFunction< + typeof useRemoveActiveAppUpdateToast +> const MOCK_STATE: State = { mockState: true } as any describe('app-wide Alerts component', () => { + let props: React.ComponentProps + const mockUseRef = { current: null } + const render = () => { - return mountWithStore>(, { - initialState: MOCK_STATE, - }) + return mountWithStore>( + , + { + initialState: MOCK_STATE, + } + ) } const stubActiveAlerts = (alertIds: AlertId[]): void => { @@ -51,6 +67,13 @@ describe('app-wide Alerts component', () => { beforeEach(() => { stubActiveAlerts([]) when(mockGetAvailableShellUpdate).mockReturnValue('true') + when(mockGetHasJustUpdated).mockReturnValue(false) + when(mockUseRemoveActiveAppUpdateToast).calledWith().mockReturnValue({ + removeActiveAppUpdateToast: jest.fn(), + }) + props = { + toastIdRef: mockUseRef, + } }) afterEach(() => { @@ -116,11 +139,8 @@ describe('app-wide Alerts component', () => { }) it('should render a success toast if the software update was successful', () => { const { wrapper } = render() - const updatedState = { - hasJustUpdated: true, - } + when(mockGetHasJustUpdated).mockReturnValue(true) - wrapper.setProps({ initialState: updatedState }) setTimeout(() => { expect(wrapper.contains('successfully updated')).toBe(true) }, TOAST_ANIMATION_DURATION) @@ -130,6 +150,7 @@ describe('app-wide Alerts component', () => { const { wrapper } = render() setTimeout(() => { expect(wrapper.contains('View Update')).toBe(false) + expect(mockUseRemoveActiveAppUpdateToast).toHaveBeenCalled() }, TOAST_ANIMATION_DURATION) }) }) diff --git a/app/src/organisms/Alerts/index.ts b/app/src/organisms/Alerts/index.ts new file mode 100644 index 00000000000..2054a0949aa --- /dev/null +++ b/app/src/organisms/Alerts/index.ts @@ -0,0 +1,3 @@ +export * from './AlertsProvider' +export * from './AlertsModal' +export * from './useRemoveActiveAppUpdateToast.ts' diff --git a/app/src/organisms/Alerts/useRemoveActiveAppUpdateToast.ts.ts b/app/src/organisms/Alerts/useRemoveActiveAppUpdateToast.ts.ts new file mode 100644 index 00000000000..030639ededa --- /dev/null +++ b/app/src/organisms/Alerts/useRemoveActiveAppUpdateToast.ts.ts @@ -0,0 +1,7 @@ +import * as React from 'react' +import { AlertsContext } from '.' +import type { AlertsContextProps } from '.' + +export function useRemoveActiveAppUpdateToast(): AlertsContextProps { + return React.useContext(AlertsContext) +} diff --git a/app/src/organisms/DeviceDetailsDeckConfiguration/index.tsx b/app/src/organisms/DeviceDetailsDeckConfiguration/index.tsx new file mode 100644 index 00000000000..e45690b93ff --- /dev/null +++ b/app/src/organisms/DeviceDetailsDeckConfiguration/index.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + Flex, + ALIGN_CENTER, + ALIGN_FLEX_START, + BORDERS, + COLORS, + DIRECTION_COLUMN, + SIZE_4, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' +import { + useDeckConfigurationQuery, + useUpdateDeckConfigurationMutation, +} from '@opentrons/react-api-client' + +import { StyledText } from '../../atoms/text' +import { TertiaryButton } from '../../atoms/buttons' + +import type { FixtureLocation } from '@opentrons/api-client' + +interface DeviceDetailsDeckConfigurationProps { + robotName: string +} + +export function DeviceDetailsDeckConfiguration({ + robotName, +}: DeviceDetailsDeckConfigurationProps): JSX.Element | null { + const { t } = useTranslation('device_details') + + const { data: deckConfig } = useDeckConfigurationQuery() + const { updateDeckConfiguration } = useUpdateDeckConfigurationMutation() + + // TODO(bh, 2023-09-27): this is all temp POC of the api stubs, to be built out in follow on work + const handleClickAdd = (): void => { + updateDeckConfiguration({ + fixtureLocation: 'B3', + loadName: 'extensionSlot', + }) + } + + const handleClickRemove = (fixtureLocation: FixtureLocation): void => { + updateDeckConfiguration({ fixtureLocation, loadName: 'standardSlot' }) + } + + return ( + + + {`${robotName} ${t('deck_configuration')}`} + + + {deckConfig?.map(fixture => ( + + {`${fixture.fixtureLocation} ${fixture.loadName}`} + handleClickRemove(fixture.fixtureLocation)} + > + remove + + + ))} + {t('add')} + + + ) +} diff --git a/app/src/organisms/Devices/PipetteCard/AboutPipetteSlideout.tsx b/app/src/organisms/Devices/PipetteCard/AboutPipetteSlideout.tsx index f022c4007ec..e1981dbaa40 100644 --- a/app/src/organisms/Devices/PipetteCard/AboutPipetteSlideout.tsx +++ b/app/src/organisms/Devices/PipetteCard/AboutPipetteSlideout.tsx @@ -59,12 +59,12 @@ export const AboutPipetteSlideout = ( fontWeight={TYPOGRAPHY.fontWeightSemiBold} color={COLORS.darkGreyEnabled} > - {t('current_version')} + {i18n.format(t('current_version'), 'upperCase')} {instrumentInfo.firmwareVersion} @@ -75,9 +75,8 @@ export const AboutPipetteSlideout = ( fontWeight={TYPOGRAPHY.fontWeightSemiBold} color={COLORS.darkGreyEnabled} data-testid={`AboutPipetteSlideout_serial_number_text_${pipetteId}`} - textTransform={TYPOGRAPHY.textTransformUppercase} > - {t('serial_number')} + {i18n.format(t('serial_number'), 'upperCase')} { getByText('About Left Pipette Pipette') getByText('123') - getByText('Serial Number') + getByText('SERIAL NUMBER') const button = getByRole('button', { name: /exit/i }) fireEvent.click(button) expect(props.onCloseClick).toHaveBeenCalled() @@ -63,7 +63,7 @@ describe('AboutPipetteSlideout', () => { const { getByText } = render(props) - getByText('Current Version') + getByText('CURRENT VERSION') getByText('12') }) }) diff --git a/app/src/organisms/Devices/PipetteCard/__tests__/PipetteOverflowMenu.test.tsx b/app/src/organisms/Devices/PipetteCard/__tests__/PipetteOverflowMenu.test.tsx index 7a534b4956e..c07f307a8b7 100644 --- a/app/src/organisms/Devices/PipetteCard/__tests__/PipetteOverflowMenu.test.tsx +++ b/app/src/organisms/Devices/PipetteCard/__tests__/PipetteOverflowMenu.test.tsx @@ -81,6 +81,7 @@ describe('PipetteOverflowMenu', () => { props = { ...props, isPipetteCalibrated: true, + isRunActive: false, } const { getByRole } = render(props) const recalibrate = getByRole('button', { diff --git a/app/src/organisms/Devices/PipetteCard/index.tsx b/app/src/organisms/Devices/PipetteCard/index.tsx index ff44bcf3589..f1655f7c2a9 100644 --- a/app/src/organisms/Devices/PipetteCard/index.tsx +++ b/app/src/organisms/Devices/PipetteCard/index.tsx @@ -55,13 +55,13 @@ import type { interface PipetteCardProps { pipetteModelSpecs: PipetteModelSpecs | null + pipetteId?: AttachedPipette['id'] | null isPipetteCalibrated: boolean mount: Mount robotName: string pipetteIs96Channel: boolean pipetteIsBad: boolean updatePipette: () => void - pipetteId?: AttachedPipette['id'] | null isRunActive: boolean } const BANNER_LINK_STYLE = css` diff --git a/app/src/organisms/EmergencyStop/TouchscreenEstopMissingModal.stories.tsx b/app/src/organisms/EmergencyStop/TouchscreenEstopMissingModal.stories.tsx index ed809f730a3..4c249dcfc21 100644 --- a/app/src/organisms/EmergencyStop/TouchscreenEstopMissingModal.stories.tsx +++ b/app/src/organisms/EmergencyStop/TouchscreenEstopMissingModal.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { Provider } from 'react-redux' import { createStore } from 'redux' +import { touchScreenViewport } from '../../DesignTokens/constants' import { configReducer } from '../../redux/config/reducer' import { EstopMissingModal } from '.' @@ -11,6 +12,7 @@ import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Organisms/EstopMissingModal', component: EstopMissingModal, + parameters: touchScreenViewport, } as Meta const dummyConfig = { diff --git a/app/src/organisms/EmergencyStop/TouchscreenEstopPressedModal.stories.tsx b/app/src/organisms/EmergencyStop/TouchscreenEstopPressedModal.stories.tsx index ef987ab5fdd..b64877e2552 100644 --- a/app/src/organisms/EmergencyStop/TouchscreenEstopPressedModal.stories.tsx +++ b/app/src/organisms/EmergencyStop/TouchscreenEstopPressedModal.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { Provider } from 'react-redux' import { createStore } from 'redux' +import { touchScreenViewport } from '../../DesignTokens/constants' import { configReducer } from '../../redux/config/reducer' import { EstopPressedModal } from '.' @@ -11,6 +12,7 @@ import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Organisms/EstopPressedModal', component: EstopPressedModal, + parameters: touchScreenViewport, } as Meta const dummyConfig = { diff --git a/app/src/organisms/GripperCard/AboutGripperSlideout.tsx b/app/src/organisms/GripperCard/AboutGripperSlideout.tsx index aa325526003..17691324083 100644 --- a/app/src/organisms/GripperCard/AboutGripperSlideout.tsx +++ b/app/src/organisms/GripperCard/AboutGripperSlideout.tsx @@ -47,12 +47,12 @@ export const AboutGripperSlideout = ( fontWeight={TYPOGRAPHY.fontWeightSemiBold} color={COLORS.darkGreyEnabled} > - {t('current_version')} + {i18n.format(t('current_version'), 'upperCase')} {firmwareVersion} @@ -62,7 +62,6 @@ export const AboutGripperSlideout = ( as="h6" fontWeight={TYPOGRAPHY.fontWeightSemiBold} color={COLORS.darkGreyEnabled} - textTransform={TYPOGRAPHY.textTransformUppercase} > {i18n.format(t('serial_number'), 'upperCase')} diff --git a/app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx b/app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx index 8b457b50116..b510a68ecd0 100644 --- a/app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx +++ b/app/src/organisms/GripperCard/__tests__/AboutGripperSlideout.test.tsx @@ -34,7 +34,7 @@ describe('AboutGripperSlideout', () => { props = { ...props, firmwareVersion: '12' } const { getByText } = render(props) - getByText('Current Version') + getByText('CURRENT VERSION') getByText('12') }) }) diff --git a/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx b/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx index 05bab0ac3bd..61bb3b2c3eb 100644 --- a/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx +++ b/app/src/organisms/LabwarePositionCheck/TerseOffsetTable.stories.tsx @@ -6,17 +6,20 @@ import { JUSTIFY_SPACE_BETWEEN, SPACING, } from '@opentrons/components' -import { TerseOffsetTable } from './ResultsSummary' -import type { Story, Meta } from '@storybook/react' - import fixture_12_trough from '@opentrons/shared-data/labware/fixtures/2/fixture_12_trough.json' import fixture_tiprack_10_ul from '@opentrons/shared-data/labware/fixtures/2/fixture_tiprack_10_ul.json' import { LabwareDefinition2, getLabwareDefURI } from '@opentrons/shared-data' + +import { touchScreenViewport } from '../../DesignTokens/constants' import { SmallButton } from '../../atoms/buttons' +import { TerseOffsetTable } from './ResultsSummary' + +import type { Story, Meta } from '@storybook/react' export default { title: 'ODD/Organisms/TerseOffsetTable', component: TerseOffsetTable, + parameters: touchScreenViewport, } as Meta // Note: 59rem(944px) is the size of ODD diff --git a/app/src/organisms/ModuleCard/AboutModuleSlideout.tsx b/app/src/organisms/ModuleCard/AboutModuleSlideout.tsx index 401fcbe82e4..a5e88fede9f 100644 --- a/app/src/organisms/ModuleCard/AboutModuleSlideout.tsx +++ b/app/src/organisms/ModuleCard/AboutModuleSlideout.tsx @@ -37,7 +37,7 @@ export const AboutModuleSlideout = ( props: AboutModuleSlideoutProps ): JSX.Element | null => { const { module, isExpanded, onCloseClick, firmwareUpdateClick } = props - const { t } = useTranslation(['device_details', 'shared']) + const { i18n, t } = useTranslation(['device_details', 'shared']) const moduleName = getModuleDisplayName(module.moduleModel) const runStatus = useCurrentRunStatus() const [showBanner, setShowBanner] = React.useState(true) @@ -97,26 +97,35 @@ export const AboutModuleSlideout = ( - {t('current_version')} - - {t('version', { version: module.firmwareVersion })} + + {i18n.format(t('current_version'), 'upperCase')} + + + {module.firmwareVersion} - {t('serial_number')} + {i18n.format(t('serial_number'), 'upperCase')} diff --git a/app/src/organisms/ModuleCard/__tests__/AboutModuleSlideout.test.tsx b/app/src/organisms/ModuleCard/__tests__/AboutModuleSlideout.test.tsx index 555eacf7c98..21a83d8eafd 100644 --- a/app/src/organisms/ModuleCard/__tests__/AboutModuleSlideout.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/AboutModuleSlideout.test.tsx @@ -49,9 +49,9 @@ describe('AboutModuleSlideout', () => { getByText('About Magnetic Module GEN1') getByText('def456') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) const button = getByRole('button', { name: /exit/i }) fireEvent.click(button) expect(props.onCloseClick).toHaveBeenCalled() @@ -63,9 +63,9 @@ describe('AboutModuleSlideout', () => { getByText('About Magnetic Module GEN1') getByText('def456') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) }) it('renders no banner when run is finishing', () => { @@ -74,9 +74,9 @@ describe('AboutModuleSlideout', () => { getByText('About Magnetic Module GEN1') getByText('def456') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) }) it('renders correct info when module is a magnetic module GEN2', () => { @@ -90,9 +90,9 @@ describe('AboutModuleSlideout', () => { getByText('About Magnetic Module GEN2') getByText('def456') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) }) it('renders correct info when module is a temperature module GEN2', () => { @@ -106,9 +106,9 @@ describe('AboutModuleSlideout', () => { getByText('About Temperature Module GEN2') getByText('abc123') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) }) it('renders correct info when module is a temperature module GEN1', () => { @@ -122,9 +122,9 @@ describe('AboutModuleSlideout', () => { getByText('About Temperature Module GEN1') getByText('abc123') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) }) it('renders correct info when module is a thermocycler module with an update available', () => { @@ -138,9 +138,9 @@ describe('AboutModuleSlideout', () => { getByText('About Thermocycler Module GEN1') getByText('ghi789') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) getByText('Firmware update available.') const viewUpdate = getByRole('button', { name: 'Update now' }) fireEvent.click(viewUpdate) @@ -163,9 +163,9 @@ describe('AboutModuleSlideout', () => { getByText('About Temperature Module GEN1') getByText('abc123') - getByText('Serial Number') - getByText('Current Version') - getByText('Version v2.0.0') + getByText('SERIAL NUMBER') + getByText('CURRENT VERSION') + getByText(/v[0-9].[0-9].[0-9]$/) const button = getByRole('button', { name: 'close' }) fireEvent.click(button) expect(props.onCloseClick).toHaveBeenCalled() diff --git a/app/src/organisms/ModuleWizardFlows/DetachProbe.tsx b/app/src/organisms/ModuleWizardFlows/DetachProbe.tsx index c5bc623692a..75de79e84f4 100644 --- a/app/src/organisms/ModuleWizardFlows/DetachProbe.tsx +++ b/app/src/organisms/ModuleWizardFlows/DetachProbe.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { css } from 'styled-components' import detachProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_1.webm' import detachProbe8 from '../../assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_8.webm' -import detachProbe96 from '../../assets/videos/pipette-wizard-flows/Pipette_Detach_96.webm' +import detachProbe96 from '../../assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_96.webm' import { useTranslation } from 'react-i18next' import { Flex, diff --git a/app/src/organisms/ProtocolDetails/RobotConfigurationDetails.tsx b/app/src/organisms/ProtocolDetails/RobotConfigurationDetails.tsx index dbb0ff65a81..6b87d735279 100644 --- a/app/src/organisms/ProtocolDetails/RobotConfigurationDetails.tsx +++ b/app/src/organisms/ProtocolDetails/RobotConfigurationDetails.tsx @@ -13,6 +13,7 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { + getFixtureDisplayName, getModuleDisplayName, getModuleType, getPipetteNameSpecs, @@ -25,14 +26,19 @@ import { StyledText } from '../../atoms/text' import { getRobotTypeDisplayName } from '../ProtocolsLanding/utils' import { getSlotsForThermocycler } from './utils' -import type { LoadModuleRunTimeCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/setup' -import type { PipetteName, RobotType } from '@opentrons/shared-data' +import type { + LoadModuleRunTimeCommand, + LoadFixtureRunTimeCommand, + PipetteName, + RobotType, +} from '@opentrons/shared-data' interface RobotConfigurationDetailsProps { leftMountPipetteName: PipetteName | null rightMountPipetteName: PipetteName | null extensionInstrumentName: string | null - requiredModuleDetails: LoadModuleRunTimeCommand[] | null + requiredModuleDetails: LoadModuleRunTimeCommand[] + requiredFixtureDetails: LoadFixtureRunTimeCommand[] isLoading: boolean robotType: RobotType | null } @@ -45,6 +51,7 @@ export const RobotConfigurationDetails = ( rightMountPipetteName, extensionInstrumentName, requiredModuleDetails, + requiredFixtureDetails, isLoading, robotType, } = props @@ -138,41 +145,52 @@ export const RobotConfigurationDetails = ( /> ) : null} - {requiredModuleDetails != null - ? requiredModuleDetails.map((module, index) => { - return ( - - - - - - {getModuleDisplayName(module.params.model)} - - - } - /> - - ) - }) - : null} + {requiredModuleDetails.map((module, index) => { + return ( + + + + + + {getModuleDisplayName(module.params.model)} + + + } + /> + + ) + })} + {requiredFixtureDetails.map((fixture, index) => { + return ( + + + + {getFixtureDisplayName(fixture.params.loadName)} + + } + /> + + ) + })} ) } diff --git a/app/src/organisms/ProtocolDetails/__tests__/RobotConfigurationDetails.test.tsx b/app/src/organisms/ProtocolDetails/__tests__/RobotConfigurationDetails.test.tsx index f17cafa9e5e..a413a95c728 100644 --- a/app/src/organisms/ProtocolDetails/__tests__/RobotConfigurationDetails.test.tsx +++ b/app/src/organisms/ProtocolDetails/__tests__/RobotConfigurationDetails.test.tsx @@ -72,7 +72,8 @@ describe('RobotConfigurationDetails', () => { props = { leftMountPipetteName: 'p10_single', rightMountPipetteName: null, - requiredModuleDetails: null, + requiredModuleDetails: [], + requiredFixtureDetails: [], extensionInstrumentName: null, isLoading: false, robotType: OT2_STANDARD_MODEL, @@ -86,7 +87,8 @@ describe('RobotConfigurationDetails', () => { props = { leftMountPipetteName: 'p10_single', rightMountPipetteName: null, - requiredModuleDetails: null, + requiredModuleDetails: [], + requiredFixtureDetails: [], extensionInstrumentName: null, isLoading: false, robotType: FLEX_STANDARD_MODEL, @@ -100,7 +102,8 @@ describe('RobotConfigurationDetails', () => { props = { leftMountPipetteName: 'p10_single', rightMountPipetteName: null, - requiredModuleDetails: null, + requiredModuleDetails: [], + requiredFixtureDetails: [], extensionInstrumentName: null, isLoading: false, robotType: OT2_STANDARD_MODEL, @@ -116,7 +119,8 @@ describe('RobotConfigurationDetails', () => { props = { leftMountPipetteName: null, rightMountPipetteName: 'p10_single', - requiredModuleDetails: null, + requiredModuleDetails: [], + requiredFixtureDetails: [], extensionInstrumentName: null, isLoading: false, robotType: OT2_STANDARD_MODEL, @@ -132,7 +136,8 @@ describe('RobotConfigurationDetails', () => { props = { leftMountPipetteName: 'p10_single', rightMountPipetteName: null, - requiredModuleDetails: null, + requiredModuleDetails: [], + requiredFixtureDetails: [], extensionInstrumentName: null, isLoading: false, robotType: FLEX_STANDARD_MODEL, @@ -145,7 +150,8 @@ describe('RobotConfigurationDetails', () => { props = { leftMountPipetteName: 'p10_single', rightMountPipetteName: null, - requiredModuleDetails: null, + requiredModuleDetails: [], + requiredFixtureDetails: [], extensionInstrumentName: null, isLoading: false, robotType: OT2_STANDARD_MODEL, @@ -160,12 +166,13 @@ describe('RobotConfigurationDetails', () => { rightMountPipetteName: 'p10_single', extensionInstrumentName: null, requiredModuleDetails: mockRequiredModuleDetails, + requiredFixtureDetails: [], isLoading: false, robotType: OT2_STANDARD_MODEL, } const { getByText } = render(props) - getByText('Slot 1') + getByText('1') getByText('Magnetic Module GEN2') }) @@ -173,7 +180,8 @@ describe('RobotConfigurationDetails', () => { props = { leftMountPipetteName: 'p10_single', rightMountPipetteName: null, - requiredModuleDetails: null, + requiredModuleDetails: [], + requiredFixtureDetails: [], extensionInstrumentName: null, isLoading: true, robotType: OT2_STANDARD_MODEL, diff --git a/app/src/organisms/ProtocolDetails/index.tsx b/app/src/organisms/ProtocolDetails/index.tsx index f4a8acb7055..5750c38a64a 100644 --- a/app/src/organisms/ProtocolDetails/index.tsx +++ b/app/src/organisms/ProtocolDetails/index.tsx @@ -36,8 +36,13 @@ import { parseInitialLoadedLabwareBySlot, parseInitialLoadedLabwareByModuleId, parseInitialLoadedLabwareByAdapter, + parseInitialLoadedFixturesByCutout, } from '@opentrons/api-client' -import { getGripperDisplayName } from '@opentrons/shared-data' +import { + LoadFixtureRunTimeCommand, + WASTE_CHUTE_LOAD_NAME, + getGripperDisplayName, +} from '@opentrons/shared-data' import { Portal } from '../../App/portal' import { Divider } from '../../atoms/structure' @@ -68,6 +73,7 @@ import { RobotConfigurationDetails } from './RobotConfigurationDetails' import type { JsonConfig, PythonConfig } from '@opentrons/shared-data' import type { StoredProtocolData } from '../../redux/protocol-storage' import type { State, Dispatch } from '../../redux/types' +import { useFeatureFlag } from '../../redux/config' const GRID_STYLE = css` display: grid; @@ -185,6 +191,7 @@ export function ProtocolDetails( const dispatch = useDispatch() const { protocolKey, srcFileNames, mostRecentAnalysis, modified } = props const { t, i18n } = useTranslation(['protocol_details', 'shared']) + const enableDeckConfig = useFeatureFlag('enableDeckConfiguration') const [currentTab, setCurrentTab] = React.useState< 'robot_config' | 'labware' | 'liquids' >('robot_config') @@ -221,14 +228,32 @@ export function ProtocolDetails( : null const requiredModuleDetails = - mostRecentAnalysis != null - ? map( - parseInitialLoadedModulesBySlot( - mostRecentAnalysis.commands != null - ? mostRecentAnalysis.commands - : [] - ) - ) + mostRecentAnalysis?.commands != null + ? map(parseInitialLoadedModulesBySlot(mostRecentAnalysis.commands)) + : [] + + // TODO: IMMEDIATELY remove stubbed fixture as soon as PE supports loadFixture + const STUBBED_LOAD_FIXTURE: LoadFixtureRunTimeCommand = { + id: 'stubbed_load_fixture', + commandType: 'loadFixture', + params: { + fixtureId: 'stubbedFixtureId', + loadName: WASTE_CHUTE_LOAD_NAME, + location: { cutout: 'D3' }, + }, + createdAt: 'fakeTimestamp', + startedAt: 'fakeTimestamp', + completedAt: 'fakeTimestamp', + status: 'succeeded', + } + const requiredFixtureDetails = + enableDeckConfig && mostRecentAnalysis?.commands != null + ? [ + ...map( + parseInitialLoadedFixturesByCutout(mostRecentAnalysis.commands) + ), + STUBBED_LOAD_FIXTURE, + ] : [] const requiredLabwareDetails = @@ -283,7 +308,7 @@ export function ProtocolDetails( : t('shared:no_data') const lastAnalyzed = mostRecentAnalysis?.createdAt != null - ? format(new Date(mostRecentAnalysis.createdAt), 'MMM dd yy HH:mm') + ? format(new Date(mostRecentAnalysis.createdAt), 'M/d/yy HH:mm') : t('shared:no_data') const robotType = mostRecentAnalysis?.robotType ?? null @@ -297,6 +322,7 @@ export function ProtocolDetails( rightMountPipetteName={rightMountPipetteName} extensionInstrumentName={requiredExtensionInstrumentName} requiredModuleDetails={requiredModuleDetails} + requiredFixtureDetails={requiredFixtureDetails} isLoading={analysisStatus === 'loading'} robotType={robotType} /> @@ -426,7 +452,7 @@ export function ProtocolDetails( {analysisStatus === 'loading' ? t('shared:loading') - : format(new Date(modified), 'MMM dd yy HH:mm')} + : format(new Date(modified), 'M/d/yy HH:mm')} {`${t('updated')} ${format( new Date(modified), - 'MMM dd yy HH:mm' + 'M/d/yy HH:mm' )}`} diff --git a/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx b/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx index 2bcc5ffa162..a7d8069cf1e 100644 --- a/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx +++ b/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx @@ -1,9 +1,11 @@ import * as React from 'react' -import { i18n } from '../../../i18n' +import { when } from 'jest-when' import { fireEvent } from '@testing-library/react' -import * as Shell from '../../../redux/shell' import { renderWithProviders } from '@opentrons/components' +import { i18n } from '../../../i18n' +import * as Shell from '../../../redux/shell' import { UpdateAppModal, UpdateAppModalProps } from '..' +import { useRemoveActiveAppUpdateToast } from '../../Alerts' import type { State } from '../../../redux/types' import type { ShellUpdateState } from '../../../redux/shell/types' @@ -19,10 +21,14 @@ jest.mock('react-router-dom', () => ({ push: jest.fn(), }), })) +jest.mock('../../Alerts') const getShellUpdateState = Shell.getShellUpdateState as jest.MockedFunction< typeof Shell.getShellUpdateState > +const mockUseRemoveActiveAppUpdateToast = useRemoveActiveAppUpdateToast as jest.MockedFunction< + typeof useRemoveActiveAppUpdateToast +> const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -50,6 +56,9 @@ describe('UpdateAppModal', () => { }, } as ShellUpdateState }) + when(mockUseRemoveActiveAppUpdateToast).calledWith().mockReturnValue({ + removeActiveAppUpdateToast: jest.fn(), + }) }) afterEach(() => { diff --git a/app/src/organisms/UpdateAppModal/index.tsx b/app/src/organisms/UpdateAppModal/index.tsx index 43a46c8e63b..65b208ab36f 100644 --- a/app/src/organisms/UpdateAppModal/index.tsx +++ b/app/src/organisms/UpdateAppModal/index.tsx @@ -26,6 +26,7 @@ import { ReleaseNotes } from '../../molecules/ReleaseNotes' import { LegacyModal } from '../../molecules/LegacyModal' import { Banner } from '../../atoms/Banner' import { ProgressBar } from '../../atoms/ProgressBar' +import { useRemoveActiveAppUpdateToast } from '../Alerts' import type { Dispatch } from '../../redux/types' import { StyledText } from '../../atoms/text' @@ -79,6 +80,8 @@ const UPDATE_PROGRESS_BAR_STYLE = css` background: ${COLORS.medGreyEnabled}; ` +const RESTART_APP_AFTER_TIME = 5000 + export interface UpdateAppModalProps { closeModal: (arg0: boolean) => void } @@ -97,14 +100,18 @@ export function UpdateAppModal(props: UpdateAppModalProps): JSX.Element { const releaseNotes = updateInfo?.releaseNotes const { t } = useTranslation('app_settings') const history = useHistory() + const { removeActiveAppUpdateToast } = useRemoveActiveAppUpdateToast() - if (downloaded) setTimeout(() => dispatch(applyShellUpdate()), 5000) + if (downloaded) + setTimeout(() => dispatch(applyShellUpdate()), RESTART_APP_AFTER_TIME) const handleRemindMeLaterClick = (): void => { history.push('/app-settings/general') closeModal(true) } + removeActiveAppUpdateToast() + const appUpdateFooter = ( + {isOT3 && enableDeckConfiguration ? ( + + ) : null} ) diff --git a/app/src/redux/config/constants.ts b/app/src/redux/config/constants.ts index 36b28bf8387..57ed92c20f4 100644 --- a/app/src/redux/config/constants.ts +++ b/app/src/redux/config/constants.ts @@ -5,6 +5,7 @@ export const CONFIG_VERSION_LATEST: 1 = 1 export const DEV_INTERNAL_FLAGS: DevInternalFlag[] = [ 'enableExtendedHardware', 'lpcWithProbe', + 'enableDeckConfiguration', ] // action type constants diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index fe2230a5fcc..3871073a32c 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -7,7 +7,10 @@ export type UpdateChannel = 'latest' | 'beta' | 'alpha' export type DiscoveryCandidates = string[] -export type DevInternalFlag = 'enableExtendedHardware' | 'lpcWithProbe' +export type DevInternalFlag = + | 'enableExtendedHardware' + | 'lpcWithProbe' + | 'enableDeckConfiguration' export type FeatureFlags = Partial> diff --git a/components/src/molecules/LocationIcon/LocationIcon.stories.tsx b/components/src/molecules/LocationIcon/LocationIcon.stories.tsx index 6e2686aaad9..cf26b2a5e7f 100644 --- a/components/src/molecules/LocationIcon/LocationIcon.stories.tsx +++ b/components/src/molecules/LocationIcon/LocationIcon.stories.tsx @@ -4,6 +4,7 @@ import { Flex, SPACING } from '@opentrons/components' import { ICON_DATA_BY_NAME } from '@opentrons/components/src/icons/icon-data' import { GlobalStyle } from '../../../../app/src/atoms/GlobalStyle' +import { customViewports } from '../../../../.storybook/preview' import { LocationIcon } from '.' import type { Story, Meta } from '@storybook/react' @@ -28,7 +29,7 @@ const slots = [ ] export default { - title: 'Odd/Molecules/LocationIcon', + title: 'ODD/Molecules/LocationIcon', argTypes: { iconName: { control: { @@ -46,6 +47,13 @@ export default { }, }, component: LocationIcon, + // Note (kk:08/29/2023) this component is located in components so avoid importing const from app + parameters: { + viewport: { + viewports: customViewports, + defaultViewport: 'onDeviceDisplay', + }, + }, decorators: [ Story => ( <> diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index 376261fe9e1..21e66d93491 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -31,6 +31,7 @@ ssh_key ?= $(default_ssh_key) ssh_opts ?= $(default_ssh_opts) # Helper to safely bundle ssh options ssh_helper = $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) +ssh_helper_ot3 = $(ssh_helper) -o HostkeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa # Source discovery # For the python sources @@ -88,6 +89,14 @@ test-cov: $(pytest) $(tests) $(test_opts) $(cov_opts) -$(MAKE) remove-patches-gravimetric +.PHONY: test-photometric-single +test-photometric-single: + -$(MAKE) apply-patches-gravimetric + $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 50 --channels 1 --tip 50 + $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 50 --channels 1 --tip 50 --photoplate-col-offset 3 + $(python) -m hardware_testing.gravimetric --photometric --simulate --pipette 50 --channels 1 --tip 50 --dye-well-col-offset 3 + -$(MAKE) remove-patches-gravimetric + .PHONY: test-photometric test-photometric: -$(MAKE) apply-patches-gravimetric @@ -97,28 +106,33 @@ test-photometric: .PHONY: test-gravimetric-single test-gravimetric-single: - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --trials 1 - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --extra --no-blank - $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 1 --no-blank - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --trials 1 --increment --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --extra --no-blank --trials 1 + $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 1 --no-blank --trials 1 + $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 1 --no-blank --trials 1 --increment --tip 50 + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --trials 1 --increment --no-blank --tip 50 + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --trials 1 --increment --no-blank --tip 200 + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 1 --trials 1 --increment --no-blank --tip 1000 .PHONY: test-gravimetric-multi test-gravimetric-multi: - $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --tip 50 --trials 1 --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --trials 1 + $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --trials 1 --no-blank --extra $(python) -m hardware_testing.gravimetric --simulate --pipette 50 --channels 8 --tip 50 --trials 1 --increment --no-blank - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 1000 --trials 1 --no-blank - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 200 --trials 1 --extra --no-blank - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --tip 50 --trials 1 --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --no-blank --trials 1 + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 8 --trials 1 --extra --no-blank .PHONY: test-gravimetric-96 test-gravimetric-96: - $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 96 --tip 1000 --trials 1 --no-blank + $(python) -m hardware_testing.gravimetric --simulate --pipette 1000 --channels 96 --trials 2 --no-blank .PHONY: test-gravimetric test-gravimetric: -$(MAKE) apply-patches-gravimetric $(MAKE) test-gravimetric-single $(MAKE) test-gravimetric-multi + $(MAKE) test-gravimetric-96 + $(MAKE) test-photometric -$(MAKE) remove-patches-gravimetric .PHONY: test-production-qc @@ -161,7 +175,7 @@ format: $(python) -m black hardware_testing tests setup.py define move-plot-webpage-ot3 -ssh -i $(2) $(3) root@$(1) \ +ssh $(ssh_helper_ot3) root@$(1) \ "function cleanup () { mount -o remount,ro / ; } ;\ mount -o remount,rw / &&\ mv /data/plot/index.html /opt/opentrons-robot-server/hardware_testing/tools/plot &&\ @@ -172,13 +186,13 @@ endef .PHONY: push-plot-webpage-ot3 push-plot-webpage-ot3: - scp $(ssh_helper) -r hardware_testing/tools/plot root@$(host):/data + scp $(ssh_helper_ot3) -r hardware_testing/tools/plot root@$(host):/data $(call move-plot-webpage-ot3,$(host),$(ssh_key),$(ssh_opts)) .PHONY: push-description-ot3 push-description-ot3: $(python) -c "from hardware_testing.data import create_git_description_file; create_git_description_file()" - scp $(ssh_helper) ./.hardware-testing-description root@$(host):/data/ + scp $(ssh_helper_ot3) ./.hardware-testing-description root@$(host):/data/ .PHONY: restart restart: @@ -207,7 +221,7 @@ push-all: clean wheel push-no-restart push-plot-webpage .PHONY: term term: - ssh $(ssh_helper) root@$(host) + ssh $(ssh_helper_ot3) root@$(host) .PHONY: list-ports list-ports: @@ -223,10 +237,10 @@ push-all-and-term: push-all term .PHONY: pull-data-ot3 pull-data-ot3: mkdir -p "./.pulled-data" - scp $(ssh_helper) -r "root@$(host):/data/testing_data/$(test)" "./.pulled-data" + scp $(ssh_helper_ot3) -r "root@$(host):/data/testing_data/$(test)" "./.pulled-data" define delete-test-data-cmd -ssh -i $(2) $(3) root@$(1) \ +ssh $(ssh_helper_ot3) root@$(1) \ "rm -rf /data/testing_data/$(4)" endef @@ -235,8 +249,8 @@ delete-data-ot3: $(call delete-test-data-cmd,$(host),$(ssh_key),$(ssh_opts),$(test)) define push-and-update-fw -scp -i $(2) $(3) $(4) root@$(1):/tmp/ -ssh -i $(2) $(3) root@$(1) \ +scp $(ssh_helper_ot3) $(4) root@$(1):/tmp/ +ssh $(ssh_helper_ot3) root@$(1) \ "function cleanup () { (rm -rf /tmp/$(4) || true) && mount -o remount,ro / ; } ;\ mount -o remount,rw / &&\ (unzip -o /tmp/$(4) -d /usr/lib/firmware || cleanup) &&\ @@ -248,6 +262,22 @@ endef sync-sw-ot3: push-ot3 cd .. && $(MAKE) push-ot3 host=$(host) +.PHONY: push-ot3-fixture +push-ot3-fixture: + $(MAKE) apply-patches-fixture + -$(MAKE) sync-sw-ot3 + $(MAKE) remove-patches-fixture + + +.PHONY: apply-patches-fixture +apply-patches-fixture: + cd ../ && git apply ./hardware-testing/fixture_overrides/*.patch --allow-empty + +.PHONY: remove-patches-fixture +remove-patches-fixture: + cd ../ && git apply ./hardware-testing/fixture_overrides/*.patch --reverse --allow-empty + + .PHONY: sync-fw-ot3 sync-fw-ot3: $(call push-and-update-fw,$(host),$(ssh_key),$(ssh_opts),$(zip)) @@ -259,19 +289,16 @@ sync-ot3: sync-sw-ot3 sync-fw-ot3 push-ot3-gravimetric: $(MAKE) apply-patches-gravimetric -$(MAKE) sync-sw-ot3 + scp $(ssh_helper_ot3) -r hardware_testing/labware root@$(host):/data/labware/v2/custom_definitions/custom_beta/ $(MAKE) remove-patches-gravimetric - scp $(ssh_helper) -r -O hardware_testing/labware/opentrons_flex_96_tiprack_50ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ - scp $(ssh_helper) -r -O hardware_testing/labware/opentrons_flex_96_tiprack_200ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ - scp $(ssh_helper) -r -O hardware_testing/labware/opentrons_flex_96_tiprack_1000ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ - scp $(ssh_helper) -r -O hardware_testing/labware/radwag_pipette_calibration_vial/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ .PHONY: apply-patches-gravimetric apply-patches-gravimetric: - cd ../ && git apply ./hardware-testing/hardware_testing/gravimetric/overrides/*.patch --allow-empty + cd ../ && git apply ./hardware-testing/hardware_testing/gravimetric/overrides/*.patch --allow-empty || true .PHONY: remove-patches-gravimetric remove-patches-gravimetric: - cd ../ && git apply ./hardware-testing/hardware_testing/gravimetric/overrides/*.patch --reverse --allow-empty + cd ../ && git apply ./hardware-testing/hardware_testing/gravimetric/overrides/*.patch --reverse --allow-empty || true upstream ?= origin/edge .PHONY: update-patches-gravimetric @@ -285,13 +312,30 @@ update-patches-gravimetric: .PHONY: push-photometric-ot2 push-photometric-ot2: - scp $(ssh_helper) -r -O photometric-ot2/photometric_ot2 root@$(host):/data/user_storage + scp $(ssh_helper_ot3) -r -O photometric-ot2/photometric_ot2 root@$(host):/data/user_storage .PHONY: get-latest-tag get-latest-tag: git tag -l --sort=-v:refname "ot3@*" --merged | (head -n 1 || Select -First 1) +.PHONY: create-ssh-key +create-ssh-key: + ssh-keygen -t rsa -b 4096 -f ~/.ssh/robot_key -N "" + echo "ssh key generated" + + +.PHONY: push-ssh-key +push-ssh-key: + echo "make sure USB drive with your public key is connected to Flex" + curl --location --request POST "http://$(host):31950/server/ssh_keys/from_local" --header "opentrons-version: 3" + + +.PHONY: term-ot3 +term-ot3: + ssh $(ssh_helper_ot3) root@$(host) + + # Creates a tarball of the hardware-testing module that can be deployed to a thumb-drive .PHONY: setup-usb-module-ot3 setup-usb-module: sdist Pipfile.lock diff --git a/hardware-testing/fixture_overrides/hardware_fixtures.patch b/hardware-testing/fixture_overrides/hardware_fixtures.patch new file mode 100644 index 00000000000..c0011bfc5d7 --- /dev/null +++ b/hardware-testing/fixture_overrides/hardware_fixtures.patch @@ -0,0 +1,62 @@ +diff --git a/api/src/opentrons/config/feature_flags.py b/api/src/opentrons/config/feature_flags.py +index f46674fb5..f59f3826d 100644 +--- a/api/src/opentrons/config/feature_flags.py ++++ b/api/src/opentrons/config/feature_flags.py +@@ -76,6 +76,4 @@ def tip_presence_detection_enabled() -> bool: + + def require_estop() -> bool: + """Whether the OT3 should allow gantry movements with no Estop plugged in.""" +- return not advs.get_setting_with_env_overload( +- "estopNotRequired", RobotTypeEnum.FLEX +- ) ++ return False +diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py +index 8a82c2ad9..24b43028a 100644 +--- a/api/src/opentrons/hardware_control/backends/ot3controller.py ++++ b/api/src/opentrons/hardware_control/backends/ot3controller.py +@@ -198,19 +198,19 @@ def requires_estop(func: Wrapped) -> Wrapped: + + @wraps(func) + async def wrapper(self: OT3Controller, *args: Any, **kwargs: Any) -> Any: +- state = self._estop_state_machine.state +- if state == EstopState.NOT_PRESENT and ff.require_estop(): +- raise EStopNotPresentError( +- message="An Estop must be plugged in to move the robot." +- ) +- if state == EstopState.LOGICALLY_ENGAGED: +- raise EStopActivatedError( +- message="Estop must be acknowledged and cleared to move the robot." +- ) +- if state == EstopState.PHYSICALLY_ENGAGED: +- raise EStopActivatedError( +- message="Estop is currently engaged, robot cannot move." +- ) ++ # state = self._estop_state_machine.state ++ # if state == EstopState.NOT_PRESENT and ff.require_estop(): ++ # raise EStopNotPresentError( ++ # message="An Estop must be plugged in to move the robot." ++ # ) ++ # if state == EstopState.LOGICALLY_ENGAGED: ++ # raise EStopActivatedError( ++ # message="Estop must be acknowledged and cleared to move the robot." ++ # ) ++ # if state == EstopState.PHYSICALLY_ENGAGED: ++ # raise EStopActivatedError( ++ # message="Estop is currently engaged, robot cannot move." ++ # ) + return await func(self, *args, **kwargs) + + return cast(Wrapped, wrapper) +diff --git a/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py b/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py +index 318deea52..8d351e4a7 100644 +--- a/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py ++++ b/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py +@@ -24,7 +24,7 @@ log = logging.getLogger(__name__) + + FLOAT_THRESHOLD = 0.001 # TODO: re-evaluate this value based on system limitations + +-MINIMUM_DISPLACEMENT = 0.05 ++MINIMUM_DISPLACEMENT = 0.01 + + + def apply_constraint(constraint: np.float64, input: np.float64) -> np.float64: diff --git a/hardware-testing/hardware_testing/data/README.md b/hardware-testing/hardware_testing/data/README.md index e1311596244..2c2932038c9 100644 --- a/hardware-testing/hardware_testing/data/README.md +++ b/hardware-testing/hardware_testing/data/README.md @@ -14,10 +14,12 @@ file_name = data.create_file_name(test_name=test_name, tag=tag) # write entire file contents at once data.dump_data_to_file(test_name=test_name, + run_id=run_id, file_name=file_name, data="some,data,to,record\ncan,be,entire,file,at,once\n") # optionally, continue to append to that same file data.append_data_to_file(test_name=test_name, + run_id=run_id, file_name=file_name, data="or,you,can,continue,appending,new,data\n") ``` diff --git a/hardware-testing/hardware_testing/data/__init__.py b/hardware-testing/hardware_testing/data/__init__.py index a06d69d41b3..d0199e9ce34 100644 --- a/hardware-testing/hardware_testing/data/__init__.py +++ b/hardware-testing/hardware_testing/data/__init__.py @@ -4,7 +4,7 @@ from pathlib import Path from subprocess import check_output from time import time -from typing import Tuple +from typing import Tuple, Union from opentrons.config import infer_config_base_dir, IS_ROBOT @@ -19,13 +19,14 @@ def _build_git_description_string() -> str: if IS_ROBOT: raise RuntimeError("unable to run git describe on robot") raw_description = _git("describe") + raw_hash = _git("rev-parse", "--short", "HEAD") description_split = raw_description.split("-") - # separate everything but git hash with an underscore - description = "_".join(description_split[:-1]) + "-" + description_split[-1] + description = "_".join(description_split) + desc_with_hash = description + "-" + raw_hash mods = _git("ls-files", "-m").replace("\n", " ") if not mods: - return description - return f"{description}-[{mods}]" + return desc_with_hash + return f"{desc_with_hash}-[{mods}]" def get_git_description() -> str: @@ -69,7 +70,7 @@ def create_test_name_from_file(f: str) -> str: return os.path.basename(f).replace("_", "-").replace(".py", "") -def create_folder_for_test_data(test_name: str) -> Path: +def create_folder_for_test_data(test_name: Union[str, Path]) -> Path: """Create a folder for test data.""" base = _initialize_testing_data_base_dir() test_path = base / test_name @@ -99,28 +100,34 @@ def create_file_name( return f"{test_name}_{run_id}_{tag}.{extension}" -def _save_data(test_name: str, file_name: str, data: str, perm: str = "w+") -> Path: +def _save_data( + test_name: str, run_id: str, file_name: str, data: str, perm: str = "w+" +) -> Path: test_path = create_folder_for_test_data(test_name) - data_path = test_path / file_name + run_path = create_folder_for_test_data(test_path / run_id) + data_path = test_path / run_path / file_name with open(data_path, perm) as f: f.write(data) return data_path -def dump_data_to_file(test_name: str, file_name: str, data: str) -> Path: +def dump_data_to_file(test_name: str, run_id: str, file_name: str, data: str) -> Path: """Save entire file contents to a file on disk.""" - return _save_data(test_name, file_name, data, perm="w+") + return _save_data(test_name, run_id, file_name, data, perm="w+") -def append_data_to_file(test_name: str, file_name: str, data: str) -> Path: +def append_data_to_file(test_name: str, run_id: str, file_name: str, data: str) -> Path: """Append new content to an already existing file on disk.""" - return _save_data(test_name, file_name, data, perm="a+") + return _save_data(test_name, run_id, file_name, data, perm="a+") -def insert_data_to_file(test_name: str, file_name: str, data: str, line: int) -> None: +def insert_data_to_file( + test_name: str, run_id: str, file_name: str, data: str, line: int +) -> None: """Insert new data at a specified line.""" test_path = create_folder_for_test_data(test_name) - data_path = test_path / file_name + run_path = create_folder_for_test_data(test_path / run_id) + data_path = run_path / file_name # read data from file, insert line, then overwrite previous file with open(data_path, "r") as f: contents = f.readlines() diff --git a/hardware-testing/hardware_testing/data/csv_report.py b/hardware-testing/hardware_testing/data/csv_report.py index 7ed95469ced..a8e5a35b47b 100644 --- a/hardware-testing/hardware_testing/data/csv_report.py +++ b/hardware-testing/hardware_testing/data/csv_report.py @@ -23,12 +23,14 @@ def __str__(self) -> str: return self.value @classmethod - def from_bool(cls, b: bool) -> "CSVResult": + def from_bool(cls, b: Optional[bool]) -> Optional["CSVResult"]: """From bool.""" + if b is None: + return None return cls.PASS if b else cls.FAIL -def print_csv_result(test: str, result: CSVResult) -> None: +def print_csv_result(test: str, result: Optional[CSVResult]) -> None: """Print CSV Result.""" if bool(result): highlight = "" @@ -110,12 +112,17 @@ def result(self) -> Optional[CSVResult]: return None @property - def result_passed(self) -> bool: + def result_passed(self) -> Optional[bool]: """Line result passed.""" - for i, expected_type in enumerate(self._data_types): - if expected_type == CSVResult and self._data[i] != CSVResult.PASS: + if CSVResult in self._data_types: + if CSVResult.FAIL in self._data: return False - return True + elif CSVResult.PASS in self._data: + return True + else: + return None + else: + return None def store(self, *data: Any, print_results: bool = True) -> None: """Line store data.""" @@ -127,15 +134,18 @@ def store(self, *data: Any, print_results: bool = True) -> None: assert self._start_time, "no start time saved" self._elapsed_time = time() - self._start_time for i, expected_type in enumerate(self._data_types): - try: - self._data[i] = expected_type(data[i]) - except ValueError: - raise ValueError( - f"[{self.tag}] unexpected data type {type(data[i])} " - f'with value "{data[i]}" at index {i}' - ) - self._stored = True - if print_results and CSVResult in self._data_types: + if data[i] is None: + self._data[i] = None + else: + try: + self._data[i] = expected_type(data[i]) + except ValueError: + raise ValueError( + f"[{self.tag}] unexpected data type {type(data[i])} " + f'with value "{data[i]}" at index {i}' + ) + self._stored = bool(None not in self._data) + if self._stored and print_results and CSVResult in self._data_types: print_csv_result(self.tag, CSVResult.from_bool(self.result_passed)) @@ -164,12 +174,15 @@ def stored(self) -> bool: return True @property - def result_passed(self) -> bool: + def result_passed(self) -> Optional[bool]: """CSV Line Repeating result passed.""" - for line in self._lines: - if not line.result_passed: - return False - return True + results = [line.result_passed for line in self._lines] + if False in results: + return False + elif True in results: + return True + else: + return None def __getitem__(self, item: int) -> CSVLine: """CSV Line Repeating get item.""" @@ -252,29 +265,48 @@ def completed(self) -> bool: return True @property - def result_passed(self) -> bool: + def result_passed(self) -> Optional[bool]: """CSV Section result passed.""" - for line in self.lines: - if not line.result_passed: - return False - return True - - -def _generate_meta_data_section() -> CSVSection: - return CSVSection( - title=META_DATA_TITLE, - lines=[ - CSVLine(tag=META_DATA_TEST_NAME, data=[str]), - CSVLine(tag=META_DATA_TEST_TAG, data=[str]), - CSVLine(tag=META_DATA_TEST_RUN_ID, data=[str]), - CSVLine(tag=META_DATA_TEST_DEVICE_ID, data=[str, str, CSVResult]), - CSVLine(tag=META_DATA_TEST_ROBOT_ID, data=[str]), - CSVLine(tag=META_DATA_TEST_TIME_UTC, data=[str]), - CSVLine(tag=META_DATA_TEST_OPERATOR, data=[str, CSVResult]), - CSVLine(tag=META_DATA_TEST_VERSION, data=[str]), - CSVLine(tag=META_DATA_TEST_FIRMWARE, data=[str]), - ], - ) + results = [line.result_passed for line in self.lines] + if False in results: + return False + elif True in results: + return True + else: + return None + + +def _generate_meta_data_section(validate_meta_data: bool) -> CSVSection: + if validate_meta_data: + return CSVSection( + title=META_DATA_TITLE, + lines=[ + CSVLine(tag=META_DATA_TEST_NAME, data=[str]), + CSVLine(tag=META_DATA_TEST_TAG, data=[str]), + CSVLine(tag=META_DATA_TEST_RUN_ID, data=[str]), + CSVLine(tag=META_DATA_TEST_DEVICE_ID, data=[str, str, CSVResult]), + CSVLine(tag=META_DATA_TEST_ROBOT_ID, data=[str]), + CSVLine(tag=META_DATA_TEST_TIME_UTC, data=[str]), + CSVLine(tag=META_DATA_TEST_OPERATOR, data=[str, CSVResult]), + CSVLine(tag=META_DATA_TEST_VERSION, data=[str]), + CSVLine(tag=META_DATA_TEST_FIRMWARE, data=[str]), + ], + ) + else: + return CSVSection( + title=META_DATA_TITLE, + lines=[ + CSVLine(tag=META_DATA_TEST_NAME, data=[str]), + CSVLine(tag=META_DATA_TEST_TAG, data=[str]), + CSVLine(tag=META_DATA_TEST_RUN_ID, data=[str]), + CSVLine(tag=META_DATA_TEST_DEVICE_ID, data=[str]), + CSVLine(tag=META_DATA_TEST_ROBOT_ID, data=[str]), + CSVLine(tag=META_DATA_TEST_TIME_UTC, data=[str]), + CSVLine(tag=META_DATA_TEST_OPERATOR, data=[str]), + CSVLine(tag=META_DATA_TEST_VERSION, data=[str]), + CSVLine(tag=META_DATA_TEST_FIRMWARE, data=[str]), + ], + ) def _generate_results_overview_section(tags: List[str]) -> CSVSection: @@ -293,13 +325,15 @@ def __init__( sections: List[CSVSection], run_id: Optional[str] = None, start_time: Optional[float] = None, + validate_meta_data: bool = True, ) -> None: """CSV Report init.""" self._test_name = test_name self._run_id = run_id if run_id else data_io.create_run_id() + self._validate_meta_data = validate_meta_data self._tag: Optional[str] = None self._file_name: Optional[str] = None - _section_meta = _generate_meta_data_section() + _section_meta = _generate_meta_data_section(validate_meta_data) _section_titles = [META_DATA_TITLE] + [s.title for s in sections] _section_results = _generate_results_overview_section(_section_titles) self._sections = [_section_meta, _section_results] + sections @@ -345,12 +379,12 @@ def _refresh_results_overview_values(self) -> None: continue line = results_section[f"RESULT_{s.title}"] assert isinstance(line, CSVLine) - line.store(CSVResult.PASS, print_results=False) if s.result_passed: - result = CSVResult.PASS + line.store(CSVResult.PASS, print_results=False) + elif s.result_passed is False: + line.store(CSVResult.FAIL, print_results=False) else: - result = CSVResult.FAIL - line.store(result, print_results=False) + line.store(None, print_results=False) def __str__(self) -> str: """CSV Report string.""" @@ -377,7 +411,8 @@ def completed(self) -> bool: @property def parent(self) -> Path: """Parent directory of this report file.""" - return data_io.create_folder_for_test_data(self._test_name) + test_path = data_io.create_folder_for_test_data(self._test_name) + return data_io.create_folder_for_test_data(test_path / self._run_id) @property def tag(self) -> str: @@ -389,8 +424,7 @@ def file_path(self) -> Path: """Get file-path.""" if not self._file_name: raise RuntimeError("must set tag of report using `Report.set_tag()`") - test_path = data_io.create_folder_for_test_data(self._test_name) - return test_path / self._file_name + return self.parent / self._file_name def _cache_start_time(self, start_time: Optional[float] = None) -> None: checked_start_time = start_time if start_time else time() @@ -411,10 +445,17 @@ def set_tag(self, tag: str) -> None: ) self.save_to_disk() - def set_device_id(self, device_id: str, barcode_id: str) -> None: + def set_device_id(self, device_id: str, barcode_id: Optional[str] = None) -> None: """Store DUT serial number.""" - result = CSVResult.from_bool(device_id == barcode_id) - self(META_DATA_TITLE, META_DATA_TEST_DEVICE_ID, [device_id, barcode_id, result]) + if self._validate_meta_data: + result = CSVResult.from_bool(device_id == barcode_id) + self( + META_DATA_TITLE, + META_DATA_TEST_DEVICE_ID, + [device_id, barcode_id, result], + ) + else: + self(META_DATA_TITLE, META_DATA_TEST_DEVICE_ID, [device_id]) def set_robot_id(self, robot_id: str) -> None: """Store robot serial number.""" @@ -422,8 +463,11 @@ def set_robot_id(self, robot_id: str) -> None: def set_operator(self, operator: str) -> None: """Set operator.""" - result = CSVResult.from_bool(bool(operator)) - self(META_DATA_TITLE, META_DATA_TEST_OPERATOR, [operator, result]) + if self._validate_meta_data: + result = CSVResult.from_bool(bool(operator)) + self(META_DATA_TITLE, META_DATA_TEST_OPERATOR, [operator, result]) + else: + self(META_DATA_TITLE, META_DATA_TEST_OPERATOR, [operator]) def set_version(self, version: str) -> None: """Set version.""" @@ -440,7 +484,7 @@ def save_to_disk(self) -> Path: _report_str = str(self) assert self._file_name, "must set tag before saving to disk" return data_io.dump_data_to_file( - self._test_name, self._file_name, _report_str + "\n" + self._test_name, self._run_id, self._file_name, _report_str + "\n" ) def print_results(self) -> None: diff --git a/hardware-testing/hardware_testing/drivers/asair_sensor.py b/hardware-testing/hardware_testing/drivers/asair_sensor.py index be1d9937667..eb9a360eace 100644 --- a/hardware-testing/hardware_testing/drivers/asair_sensor.py +++ b/hardware-testing/hardware_testing/drivers/asair_sensor.py @@ -33,6 +33,7 @@ "08": "C492", "09": "C543", "10": "C74A", + "0A": "48d9", } @@ -65,6 +66,11 @@ def get_reading(self) -> Reading: """Get a temp and humidity reading.""" ... + @abc.abstractmethod + def get_serial(self) -> str: + """Read the device ID register.""" + ... + def BuildAsairSensor(simulate: bool) -> AsairSensorBase: """Try to find and return an Asair sensor, if not found return a simulator.""" @@ -144,8 +150,8 @@ def get_reading(self) -> Reading: log.debug(f"received {res}") res = codecs.encode(res, "hex") - temp = res[6:10] - relative_hum = res[10:14] + relative_hum = res[6:10] + temp = res[10:14] log.info(f"Temp: {temp}, RelativeHum: {relative_hum}") temp = float(int(temp, 16)) / 10 @@ -160,10 +166,40 @@ def get_reading(self) -> Reading: error_msg = "Asair Sensor not connected. Check if port number is correct." raise AsairSensorError(error_msg) + def get_serial(self) -> str: + """Read the device ID register.""" + serial_addr = "0A" + data_packet = "{}0300000002{}".format(serial_addr, addrs[serial_addr]) + log.debug(f"sending {data_packet}") + command_bytes = codecs.decode(data_packet.encode(), "hex") + try: + self._th_sensor.flushInput() + self._th_sensor.flushOutput() + self._th_sensor.write(command_bytes) + time.sleep(0.1) + + length = self._th_sensor.inWaiting() + res = self._th_sensor.read(length) + log.debug(f"received {res}") + dev_id = res[6:14] + return dev_id.decode() + + except (IndexError, ValueError) as e: + log.exception("Bad value read") + raise AsairSensorError(str(e)) + except SerialException: + log.exception("Communication error") + error_msg = "Asair Sensor not connected. Check if port number is correct." + raise AsairSensorError(error_msg) + class SimAsairSensor(AsairSensorBase): """Simulating Asair sensor driver.""" + def get_serial(self) -> str: + """Read the device ID register.""" + return "0102030405060708" + def get_reading(self) -> Reading: """Get a reading.""" temp = 25.0 diff --git a/hardware-testing/hardware_testing/drivers/radwag/commands.py b/hardware-testing/hardware_testing/drivers/radwag/commands.py index 10244770f85..d99d5d2c3ba 100644 --- a/hardware-testing/hardware_testing/drivers/radwag/commands.py +++ b/hardware-testing/hardware_testing/drivers/radwag/commands.py @@ -142,3 +142,10 @@ class RadwagValueRelease(Enum): fast = 1 fast_reliable = 2 reliable = 3 + + +class RadwagAmbiant(Enum): + """Radwag ambiant enviornment states.""" + + unstable = 0 + stable = 1 diff --git a/hardware-testing/hardware_testing/drivers/radwag/driver.py b/hardware-testing/hardware_testing/drivers/radwag/driver.py index 7745a5ab7b1..3e9e25e696f 100644 --- a/hardware-testing/hardware_testing/drivers/radwag/driver.py +++ b/hardware-testing/hardware_testing/drivers/radwag/driver.py @@ -1,7 +1,7 @@ """Radwag Scale Driver.""" from abc import ABC, abstractmethod from typing import Tuple, Optional - +import datetime from serial import Serial # type: ignore[import] from .commands import ( @@ -9,6 +9,7 @@ RadwagWorkingMode, RadwagFilter, RadwagValueRelease, + RadwagAmbiant, radwag_command_format, ) from .responses import RadwagResponse, RadwagResponseCodes, radwag_response_parse @@ -86,6 +87,7 @@ class RadwagScale(RadwagScaleBase): def __init__(self, connection: Serial) -> None: """Constructor.""" self._connection = connection + self._raw_log = open("/data/testing_data/scale_raw.txt", "w") @classmethod def create( @@ -102,6 +104,7 @@ def _write_command(self, cmd: str) -> None: cmd_str = radwag_command_format(cmd) cmd_bytes = cmd_str.encode("utf-8") send_len = self._connection.write(cmd_bytes) + self._raw_log.write(f"{datetime.datetime.now()} --> {cmd_bytes!r}\n") assert send_len == len(cmd_bytes), ( f'Radwag command "{cmd}" ({str(cmd_bytes)} ' f"bytes) only sent {send_len} bytes" @@ -117,6 +120,7 @@ def _read_response( self._connection.timeout = prev_timeout else: response = self._connection.readline() + self._raw_log.write(f"{datetime.datetime.now()} <-- {response}\n") data = radwag_response_parse(response.decode("utf-8"), command) return data @@ -150,6 +154,7 @@ def connect(self) -> None: def disconnect(self) -> None: """Disconnect.""" self._connection.close() + self._raw_log.close() def read_serial_number(self) -> str: """Read serial number.""" @@ -185,6 +190,14 @@ def value_release(self, val_rel: RadwagValueRelease) -> None: res.code == RadwagResponseCodes.CARRIED_OUT ), f"Unexpected response code: {res.code}" + def ambiant(self, amb_rel: RadwagAmbiant) -> None: + """Set the value release type.""" + cmd = RadwagCommand.SET_AMBIENT_CONDITIONS_STATE + res = self._write_command_and_read_response(cmd, append=str(amb_rel.value)) + assert ( + res.code == RadwagResponseCodes.CARRIED_OUT + ), f"Unexpected response code: {res.code}" + def continuous_transmission(self, enable: bool) -> None: """Enable/disable continuous transmissions.""" if enable: @@ -272,6 +285,10 @@ def value_release(self, val_rel: RadwagValueRelease) -> None: """Value release.""" return + def ambiant(self, amb_rel: RadwagAmbiant) -> None: + """Set the value release type.""" + return + def continuous_transmission(self, enable: bool) -> None: """Continuous transmission.""" return diff --git a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py index 3d495de76cc..9f775246cb6 100644 --- a/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py +++ b/hardware-testing/hardware_testing/examples/capacitive_probe_ot3.py @@ -66,7 +66,7 @@ async def _probe_sequence( await helpers_ot3.wait_for_stable_capacitance_ot3( api, mount, threshold_pf=STABLE_CAP_PF, duration=1.0 ) - found_z = await api.capacitive_probe( + found_z, _ = await api.capacitive_probe( mount, z_ax, ASSUMED_Z_LOCATION.z, PROBE_SETTINGS_Z_AXIS ) print(f"Found deck Z location = {found_z} mm") @@ -85,7 +85,7 @@ async def _probe_sequence( await helpers_ot3.wait_for_stable_capacitance_ot3( api, mount, threshold_pf=STABLE_CAP_PF, duration=1.0 ) - found_x_left = await api.capacitive_probe( + found_x_left, _ = await api.capacitive_probe( mount, types.Axis.X, ASSUMED_XY_LOCATION.x - half_cutout, @@ -95,7 +95,7 @@ async def _probe_sequence( await helpers_ot3.wait_for_stable_capacitance_ot3( api, mount, threshold_pf=STABLE_CAP_PF, duration=1.0 ) - found_x_right = await api.capacitive_probe( + found_x_right, _ = await api.capacitive_probe( mount, types.Axis.X, ASSUMED_XY_LOCATION.x + half_cutout, @@ -106,7 +106,7 @@ async def _probe_sequence( await helpers_ot3.wait_for_stable_capacitance_ot3( api, mount, threshold_pf=STABLE_CAP_PF, duration=1.0 ) - found_y_front = await api.capacitive_probe( + found_y_front, _ = await api.capacitive_probe( mount, types.Axis.Y, ASSUMED_XY_LOCATION.y - half_cutout, @@ -116,7 +116,7 @@ async def _probe_sequence( await helpers_ot3.wait_for_stable_capacitance_ot3( api, mount, threshold_pf=STABLE_CAP_PF, duration=1.0 ) - found_y_back = await api.capacitive_probe( + found_y_back, _ = await api.capacitive_probe( mount, types.Axis.Y, ASSUMED_XY_LOCATION.y + half_cutout, diff --git a/hardware-testing/hardware_testing/examples/data.py b/hardware-testing/hardware_testing/examples/data.py index 3cf4492290c..ff794e57c80 100644 --- a/hardware-testing/hardware_testing/examples/data.py +++ b/hardware-testing/hardware_testing/examples/data.py @@ -12,12 +12,14 @@ def _main() -> None: # write data.dump_data_to_file( test_name, + run_id, file_name, "some,data,to,record\ncan,be,entire,file,at,once\n", ) # append data.append_data_to_file( test_name, + run_id, file_name, "or,you,can,continue,appending,new,data\n", ) diff --git a/hardware-testing/hardware_testing/gravimetric/README.md b/hardware-testing/hardware_testing/gravimetric/README.md index f1c1d08d610..2d3b571674c 100644 --- a/hardware-testing/hardware_testing/gravimetric/README.md +++ b/hardware-testing/hardware_testing/gravimetric/README.md @@ -18,4 +18,31 @@ and substitute `internal-release` for whatever branch you're merging in to. ## Photometric tests +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 96 --photometric --tip 50 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 96 --photometric --tip 200 + ## Gravimetric tests + +###P1000 single channel QC +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 1 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 1 --extra +###P1000 multi channel QC +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 8 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 8 --extra +###P1000 96 channel QC +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 96 +###P50 single channel QC +python3 -m hardware_testing.gravimetric --pipette 50 --channels 1 +python3 -m hardware_testing.gravimetric --pipette 50 --channels 1 --extra +###P50 multi channel QC +python3 -m hardware_testing.gravimetric --pipette 50 --channels 8 +python3 -m hardware_testing.gravimetric --pipette 50 --channels 8 --extra +###Increment tests +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 1 --increment --tip 50 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 1 --increment --tip 200 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 1 --increment --tip 1000 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 8 --increment --tip 50 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 8 --increment --tip 200 +python3 -m hardware_testing.gravimetric --pipette 1000 --channels 8 --increment --tip 1000 +python3 -m hardware_testing.gravimetric --pipette 50 --channels 1 --increment --tip 50 +python3 -m hardware_testing.gravimetric --pipette 50 --channels 8 --increment --tip 50 diff --git a/hardware-testing/hardware_testing/gravimetric/__main__.py b/hardware-testing/hardware_testing/gravimetric/__main__.py index 07609e72da4..cb0fb717032 100644 --- a/hardware-testing/hardware_testing/gravimetric/__main__.py +++ b/hardware-testing/hardware_testing/gravimetric/__main__.py @@ -2,27 +2,31 @@ from json import load as json_load from pathlib import Path import argparse -from typing import List, Union - +from typing import List, Union, Dict, Optional, Any, Tuple +from dataclasses import dataclass from opentrons.protocol_api import ProtocolContext +from . import report +import subprocess +from time import sleep from hardware_testing.data import create_run_id_and_start_time, ui, get_git_description -from hardware_testing.protocols import ( - gravimetric_ot3_p50_single, - gravimetric_ot3_p50_multi_50ul_tip, +from hardware_testing.protocols.gravimetric_lpc.gravimetric import ( + gravimetric_ot3_p1000_96, + gravimetric_ot3_p1000_multi, gravimetric_ot3_p1000_single, - gravimetric_ot3_p1000_multi_50ul_tip, - gravimetric_ot3_p1000_multi_200ul_tip, - gravimetric_ot3_p1000_multi_1000ul_tip, - gravimetric_ot3_p1000_96_50ul_tip, - gravimetric_ot3_p1000_96_200ul_tip, - gravimetric_ot3_p1000_96_1000ul_tip, - photometric_ot3_p1000_96_50ul_tip, - photometric_ot3_p1000_96_200ul_tip, - gravimetric_ot3_p50_multi_50ul_tip_increment, + gravimetric_ot3_p50_single, gravimetric_ot3_p1000_multi_50ul_tip_increment, gravimetric_ot3_p1000_multi_200ul_tip_increment, + gravimetric_ot3_p50_multi, gravimetric_ot3_p1000_multi_1000ul_tip_increment, + gravimetric_ot3_p50_multi_50ul_tip_increment, +) +from hardware_testing.protocols.gravimetric_lpc.photometric import ( + photometric_ot3_p1000_multi, + photometric_ot3_p1000_single, + photometric_ot3_p50_multi, + photometric_ot3_p1000_96, + photometric_ot3_p50_single, ) from . import execute, helpers, workarounds, execute_photometric @@ -33,10 +37,13 @@ ConfigType, get_tip_volumes_for_qc, ) +from .measurement.record import GravimetricRecorder from .measurement import DELAY_FOR_MEASUREMENT -from .trial import TestResources +from .measurement.scale import Scale +from .trial import TestResources, _change_pipettes from .tips import get_tips from hardware_testing.drivers import asair_sensor +from opentrons.protocol_api import InstrumentContext # FIXME: bump to v2.15 to utilize protocol engine API_LEVEL = "2.13" @@ -46,25 +53,13 @@ # Keyed by pipette volume, channel count, and tip volume in that order GRAVIMETRIC_CFG = { 50: { - 1: {50: gravimetric_ot3_p50_single}, - 8: {50: gravimetric_ot3_p50_multi_50ul_tip}, + 1: gravimetric_ot3_p50_single, + 8: gravimetric_ot3_p50_multi, }, 1000: { - 1: { - 50: gravimetric_ot3_p1000_single, - 200: gravimetric_ot3_p1000_single, - 1000: gravimetric_ot3_p1000_single, - }, - 8: { - 50: gravimetric_ot3_p1000_multi_50ul_tip, - 200: gravimetric_ot3_p1000_multi_200ul_tip, - 1000: gravimetric_ot3_p1000_multi_1000ul_tip, - }, - 96: { - 50: gravimetric_ot3_p1000_96_50ul_tip, - 200: gravimetric_ot3_p1000_96_200ul_tip, - 1000: gravimetric_ot3_p1000_96_1000ul_tip, - }, + 1: gravimetric_ot3_p1000_single, + 8: gravimetric_ot3_p1000_multi, + 96: gravimetric_ot3_p1000_96, }, } @@ -85,180 +80,460 @@ 1000: gravimetric_ot3_p1000_multi_1000ul_tip_increment, }, 96: { - 50: gravimetric_ot3_p1000_96_50ul_tip, - 200: gravimetric_ot3_p1000_96_200ul_tip, - 1000: gravimetric_ot3_p1000_96_1000ul_tip, + 50: gravimetric_ot3_p1000_96, + 200: gravimetric_ot3_p1000_96, + 1000: gravimetric_ot3_p1000_96, }, }, } PHOTOMETRIC_CFG = { - 50: photometric_ot3_p1000_96_50ul_tip, - 200: photometric_ot3_p1000_96_200ul_tip, + 50: { + 1: { + 50: photometric_ot3_p50_single, + }, + 8: { + 50: photometric_ot3_p50_multi, + }, + }, + 1000: { + 1: { + 50: photometric_ot3_p1000_single, + 200: photometric_ot3_p1000_single, + 1000: photometric_ot3_p1000_single, + }, + 8: { + 50: photometric_ot3_p1000_multi, + 200: photometric_ot3_p1000_multi, + 1000: photometric_ot3_p1000_multi, + }, + 96: {50: photometric_ot3_p1000_96, 200: photometric_ot3_p1000_96}, + }, } +@dataclass +class RunArgs: + """Common resources across multiple runs.""" + + tip_volumes: List[int] + volumes: List[Tuple[int, List[float]]] + run_id: str + pipette: InstrumentContext + pipette_tag: str + operator_name: str + git_description: str + robot_serial: str + tip_batchs: Dict[str, str] + recorder: Optional[GravimetricRecorder] + pipette_volume: int + pipette_channels: int + increment: bool + name: str + environment_sensor: asair_sensor.AsairSensorBase + trials: int + ctx: ProtocolContext + protocol_cfg: Any + test_report: report.CSVReport + + @classmethod + def _get_protocol_context(cls, args: argparse.Namespace) -> ProtocolContext: + if not args.simulate and not args.skip_labware_offsets: + # getting labware offsets must be done before creating the protocol context + # because it requires the robot-server to be running + ui.print_title("SETUP") + ui.print_info( + "Starting opentrons-robot-server, so we can http GET labware offsets" + ) + offsets = workarounds.http_get_all_labware_offsets() + ui.print_info(f"found {len(offsets)} offsets:") + for offset in offsets: + ui.print_info(f"\t{offset['createdAt']}:") + ui.print_info(f"\t\t{offset['definitionUri']}") + ui.print_info(f"\t\t{offset['vector']}") + LABWARE_OFFSETS.append(offset) + # gather the custom labware (for simulation) + custom_defs = {} + if args.simulate: + labware_dir = Path(__file__).parent.parent / "labware" + custom_def_uris = [ + "radwag_pipette_calibration_vial", + "opentrons_flex_96_tiprack_50ul_adp", + "opentrons_flex_96_tiprack_200ul_adp", + "opentrons_flex_96_tiprack_1000ul_adp", + ] + for def_uri in custom_def_uris: + with open(labware_dir / def_uri / "1.json", "r") as f: + custom_def = json_load(f) + custom_defs[def_uri] = custom_def + _ctx = helpers.get_api_context( + API_LEVEL, # type: ignore[attr-defined] + is_simulating=args.simulate, + deck_version="2", + extra_labware=custom_defs, + ) + return _ctx + + @classmethod # noqa: C901 + def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": + """Build.""" + _ctx = RunArgs._get_protocol_context(args) + operator_name = helpers._get_operator_name(_ctx.is_simulating()) + robot_serial = helpers._get_robot_serial(_ctx.is_simulating()) + run_id, start_time = create_run_id_and_start_time() + environment_sensor = asair_sensor.BuildAsairSensor(_ctx.is_simulating()) + git_description = get_git_description() + if not args.photometric: + scale = Scale.build(simulate=_ctx.is_simulating()) + ui.print_header("LOAD PIPETTE") + pipette = helpers._load_pipette( + _ctx, + args.channels, + args.pipette, + "left", + args.increment, + args.gantry_speed if not args.photometric else None, + ) + pipette_tag = helpers._get_tag_from_pipette( + pipette, args.increment, args.user_volumes + ) + + recorder: Optional[GravimetricRecorder] = None + kind = ConfigType.photometric if args.photometric else ConfigType.gravimetric + tip_batches: Dict[str, str] = {} + if args.tip == 0: + tip_volumes: List[int] = get_tip_volumes_for_qc( + args.pipette, args.channels, args.extra, args.photometric + ) + for tip in tip_volumes: + tip_batches[f"tips_{tip}ul"] = helpers._get_tip_batch( + _ctx.is_simulating(), tip + ) + else: + tip_volumes = [args.tip] + tip_batches[f"tips_{args.tip}ul"] = helpers._get_tip_batch( + _ctx.is_simulating(), args.tip + ) + + volumes: List[Tuple[int, List[float]]] = [] + for tip in tip_volumes: + vls = helpers._get_volumes( + _ctx, + args.increment, + args.channels, + args.pipette, + tip, + args.user_volumes, + kind, + False, # set extra to false so we always do the normal tests first + args.channels, + mode=args.mode, # NOTE: only needed for increment test + ) + if len(vls) > 0: + volumes.append( + ( + tip, + vls, + ) + ) + if args.isolate_volumes: + # check that all volumes passed in are actually test volumes + all_vols = set( + [vol for tip_vol_list in volumes for vol in tip_vol_list[-1]] + ) + for isolated_volume in args.isolate_volumes: + assert isolated_volume in all_vols, ( + f"cannot isolate volume {isolated_volume}, " f"not a test volume" + ) + if args.extra: + # if we use extra, add those tests after + for tip in tip_volumes: + vls = helpers._get_volumes( + _ctx, + args.increment, + args.channels, + args.pipette, + tip, + args.user_volumes, + kind, + True, + args.channels, + ) + if len(vls) > 0: + volumes.append( + ( + tip, + vls, + ) + ) + if not volumes: + raise ValueError("no volumes to test, check the configuration") + volumes_list: List[float] = [] + for _, vls in volumes: + volumes_list.extend(vls) + + if args.trials == 0: + trials = helpers.get_default_trials(args.increment, kind, args.channels) + else: + trials = args.trials + + if args.photometric: + _tip_cfg = max(tip_volumes) + if len(tip_volumes) > 0: + ui.print_info( + f"WARNING: using source Protocol for {_tip_cfg} tip, " + f"but test includes multiple tips ({tip_volumes})" + ) + protocol_cfg = PHOTOMETRIC_CFG[args.pipette][args.channels][_tip_cfg] + name = protocol_cfg.metadata["protocolName"] # type: ignore[attr-defined] + report = execute_photometric.build_pm_report( + test_volumes=volumes_list, + run_id=run_id, + pipette_tag=pipette_tag, + operator_name=operator_name, + git_description=git_description, + tip_batches=tip_batches, + environment_sensor=environment_sensor, + trials=trials, + name=name, + robot_serial=robot_serial, + fw_version=_ctx._core.get_hardware().fw_version, + ) + else: + if args.increment: + assert len(tip_volumes) == 1, ( + f"tip must be specified " + f"when running --increment test " + f"with {args.channels}ch P{args.pipette}" + ) + protocol_cfg = GRAVIMETRIC_CFG_INCREMENT[args.pipette][args.channels][ + tip_volumes[0] + ] + else: + protocol_cfg = GRAVIMETRIC_CFG[args.pipette][args.channels] + name = protocol_cfg.metadata["protocolName"] # type: ignore[attr-defined] + recorder = execute._load_scale( + name, scale, run_id, pipette_tag, start_time, _ctx.is_simulating() + ) + + report = execute.build_gm_report( + test_volumes=volumes_list, + run_id=run_id, + pipette_tag=pipette_tag, + operator_name=operator_name, + git_description=git_description, + robot_serial=robot_serial, + tip_batchs=tip_batches, + recorder=recorder, + pipette_channels=args.channels, + increment=args.increment, + name=name, + environment_sensor=environment_sensor, + trials=trials, + fw_version=_ctx._core.get_hardware().fw_version, + ) + + return RunArgs( + tip_volumes=tip_volumes, + volumes=volumes, + run_id=run_id, + pipette=pipette, + pipette_tag=pipette_tag, + operator_name=operator_name, + git_description=git_description, + robot_serial=robot_serial, + tip_batchs=tip_batches, + recorder=recorder, + pipette_volume=args.pipette, + pipette_channels=args.channels, + increment=args.increment, + name=name, + environment_sensor=environment_sensor, + trials=trials, + ctx=_ctx, + protocol_cfg=protocol_cfg, + test_report=report, + ) + + def build_gravimetric_cfg( protocol: ProtocolContext, - pipette_volume: int, - pipette_channels: int, tip_volume: int, - trials: int, increment: bool, return_tip: bool, blank: bool, mix: bool, - inspect: bool, user_volumes: bool, gantry_speed: int, scale_delay: int, isolate_channels: List[int], + isolate_volumes: List[float], extra: bool, + jog: bool, + same_tip: bool, + ignore_fail: bool, + mode: str, + run_args: RunArgs, ) -> GravimetricConfig: - """Run.""" - if increment: - protocol_cfg = GRAVIMETRIC_CFG_INCREMENT[pipette_volume][pipette_channels][ - tip_volume - ] - else: - protocol_cfg = GRAVIMETRIC_CFG[pipette_volume][pipette_channels][tip_volume] + """Build.""" return GravimetricConfig( - name=protocol_cfg.metadata["protocolName"], # type: ignore[attr-defined] + name=run_args.name, pipette_mount="left", - pipette_volume=pipette_volume, - pipette_channels=pipette_channels, + pipette_volume=run_args.pipette_volume, + pipette_channels=run_args.pipette_channels, tip_volume=tip_volume, - trials=trials, + trials=run_args.trials, labware_offsets=LABWARE_OFFSETS, - labware_on_scale=protocol_cfg.LABWARE_ON_SCALE, # type: ignore[attr-defined] - slot_scale=protocol_cfg.SLOT_SCALE, # type: ignore[attr-defined] - slots_tiprack=protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] + labware_on_scale=run_args.protocol_cfg.LABWARE_ON_SCALE, # type: ignore[attr-defined] + slot_scale=run_args.protocol_cfg.SLOT_SCALE, # type: ignore[attr-defined] + slots_tiprack=run_args.protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] increment=increment, return_tip=return_tip, blank=blank, mix=mix, - inspect=inspect, user_volumes=user_volumes, gantry_speed=gantry_speed, scale_delay=scale_delay, isolate_channels=isolate_channels, + isolate_volumes=isolate_volumes, kind=ConfigType.gravimetric, - extra=args.extra, + extra=extra, + jog=jog, + same_tip=same_tip, + ignore_fail=ignore_fail, + mode=mode, ) def build_photometric_cfg( protocol: ProtocolContext, - pipette_volume: int, tip_volume: int, - trials: int, return_tip: bool, mix: bool, - inspect: bool, user_volumes: bool, touch_tip: bool, refill: bool, extra: bool, + jog: bool, + same_tip: bool, + ignore_fail: bool, + pipette_channels: int, + photoplate_column_offset: List[int], + dye_well_column_offset: List[int], + mode: str, + run_args: RunArgs, ) -> PhotometricConfig: """Run.""" - protocol_cfg = PHOTOMETRIC_CFG[tip_volume] return PhotometricConfig( - name=protocol_cfg.metadata["protocolName"], # type: ignore[attr-defined] + name=run_args.name, pipette_mount="left", - pipette_volume=pipette_volume, - pipette_channels=96, + pipette_volume=run_args.pipette_volume, + pipette_channels=pipette_channels, increment=False, tip_volume=tip_volume, - trials=trials, + trials=run_args.trials, labware_offsets=LABWARE_OFFSETS, - photoplate=protocol_cfg.PHOTOPLATE_LABWARE, # type: ignore[attr-defined] - photoplate_slot=protocol_cfg.SLOT_PLATE, # type: ignore[attr-defined] - reservoir=protocol_cfg.RESERVOIR_LABWARE, # type: ignore[attr-defined] - reservoir_slot=protocol_cfg.SLOT_RESERVOIR, # type: ignore[attr-defined] - slots_tiprack=protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] + photoplate=run_args.protocol_cfg.PHOTOPLATE_LABWARE, # type: ignore[attr-defined] + photoplate_slot=run_args.protocol_cfg.SLOT_PLATE, # type: ignore[attr-defined] + reservoir=run_args.protocol_cfg.RESERVOIR_LABWARE, # type: ignore[attr-defined] + reservoir_slot=run_args.protocol_cfg.SLOT_RESERVOIR, # type: ignore[attr-defined] + slots_tiprack=run_args.protocol_cfg.SLOTS_TIPRACK[tip_volume], # type: ignore[attr-defined] return_tip=return_tip, mix=mix, - inspect=inspect, user_volumes=user_volumes, touch_tip=touch_tip, refill=refill, kind=ConfigType.photometric, - extra=args.extra, + extra=extra, + jog=jog, + same_tip=same_tip, + ignore_fail=ignore_fail, + photoplate_column_offset=photoplate_column_offset, + dye_well_column_offset=dye_well_column_offset, + mode=mode, ) -def _main(args: argparse.Namespace, _ctx: ProtocolContext) -> None: +def _main( + args: argparse.Namespace, + run_args: RunArgs, + tip: int, + volumes: List[float], +) -> None: union_cfg: Union[PhotometricConfig, GravimetricConfig] if args.photometric: cfg_pm: PhotometricConfig = build_photometric_cfg( - _ctx, - args.pipette, - args.tip, - args.trials, + run_args.ctx, + tip, args.return_tip, args.mix, - args.inspect, args.user_volumes, args.touch_tip, args.refill, args.extra, + args.jog, + args.same_tip, + args.ignore_fail, + args.channels, + args.photoplate_col_offset, + args.dye_well_col_offset, + args.mode, + run_args, ) - if args.trials == 0: - cfg_pm.trials = helpers.get_default_trials(cfg_pm) union_cfg = cfg_pm else: cfg_gm: GravimetricConfig = build_gravimetric_cfg( - _ctx, - args.pipette, - args.channels, - args.tip, - args.trials, + run_args.ctx, + tip, args.increment, args.return_tip, False if args.no_blank else True, args.mix, - args.inspect, args.user_volumes, args.gantry_speed, args.scale_delay, args.isolate_channels if args.isolate_channels else [], + args.isolate_volumes if args.isolate_volumes else [], args.extra, + args.jog, + args.same_tip, + args.ignore_fail, + args.mode, + run_args, ) - if args.trials == 0: - cfg_gm.trials = helpers.get_default_trials(cfg_gm) + union_cfg = cfg_gm - run_id, start_time = create_run_id_and_start_time() - ui.print_header("LOAD PIPETTE") - pipette = helpers._load_pipette(_ctx, union_cfg) ui.print_header("GET PARAMETERS") - test_volumes = helpers._get_volumes(_ctx, union_cfg) - for v in test_volumes: + + for v in volumes: ui.print_info(f"\t{v} uL") all_channels_same_time = ( - getattr(union_cfg, "increment", False) or union_cfg.pipette_channels == 96 + getattr(union_cfg, "increment", False) + or union_cfg.pipette_channels == 96 + or args.photometric ) - run_args = TestResources( - ctx=_ctx, - pipette=pipette, - pipette_tag=helpers._get_tag_from_pipette(pipette, union_cfg), + test_resources = TestResources( + ctx=run_args.ctx, + pipette=run_args.pipette, tipracks=helpers._load_tipracks( - _ctx, union_cfg, use_adapters=args.channels == 96 + run_args.ctx, union_cfg, use_adapters=args.channels == 96 ), - test_volumes=test_volumes, - run_id=run_id, - start_time=start_time, - operator_name=helpers._get_operator_name(_ctx.is_simulating()), - robot_serial=helpers._get_robot_serial(_ctx.is_simulating()), - tip_batch=helpers._get_tip_batch(_ctx.is_simulating()), - git_description=get_git_description(), - tips=get_tips(_ctx, pipette, args.tip, all_channels=all_channels_same_time), - env_sensor=asair_sensor.BuildAsairSensor(_ctx.is_simulating()), + test_volumes=volumes, + tips=get_tips( + run_args.ctx, + run_args.pipette, + tip, + all_channels=all_channels_same_time, + ), + env_sensor=run_args.environment_sensor, + recorder=run_args.recorder, + test_report=run_args.test_report, ) if args.photometric: - execute_photometric.run(cfg_pm, run_args) + execute_photometric.run(cfg_pm, test_resources) else: - execute.run(cfg_gm, run_args) + execute.run(cfg_gm, test_resources) if __name__ == "__main__": @@ -273,7 +548,6 @@ def _main(args: argparse.Namespace, _ctx: ProtocolContext) -> None: parser.add_argument("--skip-labware-offsets", action="store_true") parser.add_argument("--no-blank", action="store_true") parser.add_argument("--mix", action="store_true") - parser.add_argument("--inspect", action="store_true") parser.add_argument("--user-volumes", action="store_true") parser.add_argument("--gantry-speed", type=int, default=GANTRY_MAX_SPEED) parser.add_argument("--scale-delay", type=int, default=DELAY_FOR_MEASUREMENT) @@ -281,50 +555,43 @@ def _main(args: argparse.Namespace, _ctx: ProtocolContext) -> None: parser.add_argument("--touch-tip", action="store_true") parser.add_argument("--refill", action="store_true") parser.add_argument("--isolate-channels", nargs="+", type=int, default=None) + parser.add_argument("--isolate-volumes", nargs="+", type=float, default=None) parser.add_argument("--extra", action="store_true") + parser.add_argument("--jog", action="store_true") + parser.add_argument("--same-tip", action="store_true") + parser.add_argument("--ignore-fail", action="store_true") + parser.add_argument("--photoplate-col-offset", nargs="+", type=int, default=[1]) + parser.add_argument("--dye-well-col-offset", nargs="+", type=int, default=[1]) + parser.add_argument( + "--mode", type=str, choices=["", "default", "lowVolumeDefault"], default="" + ) args = parser.parse_args() - if not args.simulate and not args.skip_labware_offsets: - # getting labware offsets must be done before creating the protocol context - # because it requires the robot-server to be running - ui.print_title("SETUP") - ui.print_info( - "Starting opentrons-robot-server, so we can http GET labware offsets" + run_args = RunArgs.build_run_args(args) + if not run_args.ctx.is_simulating(): + serial_logger = subprocess.Popen( + [ + "python3 -m opentrons_hardware.scripts.can_mon > /data/testing_data/serial.log" + ], + shell=True, ) - offsets = workarounds.http_get_all_labware_offsets() - ui.print_info(f"found {len(offsets)} offsets:") - for offset in offsets: - ui.print_info(f"\t{offset['createdAt']}:") - ui.print_info(f"\t\t{offset['definitionUri']}") - ui.print_info(f"\t\t{offset['vector']}") - LABWARE_OFFSETS.append(offset) - # gather the custom labware (for simulation) - custom_defs = {} - if args.simulate: - labware_dir = Path(__file__).parent.parent / "labware" - custom_def_uris = [ - "radwag_pipette_calibration_vial", - "opentrons_flex_96_tiprack_50ul_adp", - "opentrons_flex_96_tiprack_200ul_adp", - "opentrons_flex_96_tiprack_1000ul_adp", - ] - for def_uri in custom_def_uris: - with open(labware_dir / def_uri / "1.json", "r") as f: - custom_def = json_load(f) - custom_defs[def_uri] = custom_def - _ctx = helpers.get_api_context( - API_LEVEL, # type: ignore[attr-defined] - is_simulating=args.simulate, - deck_version="2", - extra_labware=custom_defs, - ) - if args.tip == 0: - for tip in get_tip_volumes_for_qc( - args.pipette, args.channels, args.extra, args.photometric - ): - hw = _ctx._core.get_hardware() - if not _ctx.is_simulating(): - ui.alert_user_ready(f"Ready to run with {tip}ul tip?", hw) - args.tip = tip - _main(args, _ctx) - else: - _main(args, _ctx) + sleep(1) + try: + if not run_args.ctx.is_simulating() and not args.photometric: + ui.get_user_ready("CLOSE the door, and MOVE AWAY from machine") + ui.print_info("homing...") + run_args.ctx.home() + for tip, volumes in run_args.volumes: + if args.channels == 96 and not run_args.ctx.is_simulating(): + hw = run_args.ctx._core.get_hardware() + ui.alert_user_ready(f"prepare the {tip}ul tipracks", hw) + _main(args, run_args, tip, volumes) + finally: + if run_args.recorder is not None: + ui.print_info("ending recording") + run_args.recorder.stop() + run_args.recorder.deactivate() + _change_pipettes(run_args.ctx, run_args.pipette) + if not run_args.ctx.is_simulating(): + serial_logger.terminate() + del run_args.ctx._core.get_hardware()._backend.eeprom_driver._gpio + print("done\n\n") diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index f5cbc3ba8e8..895cdbe9674 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -1,8 +1,10 @@ """Config.""" from dataclasses import dataclass -from typing import List, Dict +from typing import List, Dict, Tuple from typing_extensions import Final from enum import Enum +from opentrons.config.types import LiquidProbeSettings +from opentrons.protocol_api.labware import Well class ConfigType(Enum): @@ -27,10 +29,13 @@ class VolumetricConfig: increment: bool return_tip: bool mix: bool - inspect: bool user_volumes: bool kind: ConfigType extra: bool + jog: bool + same_tip: bool + ignore_fail: bool + mode: str @dataclass @@ -43,6 +48,7 @@ class GravimetricConfig(VolumetricConfig): gantry_speed: int scale_delay: int isolate_channels: List[int] + isolate_volumes: List[float] @dataclass @@ -55,6 +61,8 @@ class PhotometricConfig(VolumetricConfig): reservoir_slot: int touch_tip: bool refill: bool + photoplate_column_offset: List[int] + dye_well_column_offset: List[int] GRAV_CONFIG_EXCLUDE_FROM_REPORT = ["labware_offsets", "slots_tiprack"] @@ -77,106 +85,323 @@ class PhotometricConfig(VolumetricConfig): VIAL_SAFE_Z_OFFSET: Final = 25 LABWARE_BOTTOM_CLEARANCE = 1.5 - -QC_VOLUMES_G: Dict[int, Dict[int, Dict[int, List[float]]]] = { - 1: { - 50: { # P50 - 50: [1.0, 50.0], # T50 +LIQUID_PROBE_SETTINGS: Dict[int, Dict[int, Dict[int, Dict[str, int]]]] = { + 50: { + 1: { + 50: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 11, + "plunger_speed": 21, + "sensor_threshold_pascals": 150, + }, }, - 1000: { # P1000 - 50: [5.0], # T50 - 200: [], # T200 - 1000: [1000.0], # T1000 + 8: { + 50: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 11, + "plunger_speed": 21, + "sensor_threshold_pascals": 150, + }, }, }, - 8: { - 50: { # P50 - 50: [1.0, 50.0], # T50 + 1000: { + 1: { + 50: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 10, + "sensor_threshold_pascals": 200, + }, + 200: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 10, + "sensor_threshold_pascals": 200, + }, + 1000: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 11, + "sensor_threshold_pascals": 150, + }, }, - 1000: { # P1000 - 50: [5.0], # T50 - 200: [], # T200 - 1000: [1000.0], # T1000 + 8: { + 50: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 10, + "sensor_threshold_pascals": 200, + }, + 200: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 10, + "sensor_threshold_pascals": 200, + }, + 1000: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 11, + "sensor_threshold_pascals": 150, + }, + }, + 96: { + 50: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 10, + "sensor_threshold_pascals": 200, + }, + 200: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 10, + "sensor_threshold_pascals": 200, + }, + 1000: { + "max_z_distance": 20, + "min_z_distance": 5, + "mount_speed": 5, + "plunger_speed": 11, + "sensor_threshold_pascals": 150, + }, }, }, +} + + +def _get_liquid_probe_settings( + cfg: VolumetricConfig, well: Well +) -> LiquidProbeSettings: + lqid_cfg: Dict[str, int] = LIQUID_PROBE_SETTINGS[cfg.pipette_volume][ + cfg.pipette_channels + ][cfg.tip_volume] + return LiquidProbeSettings( + starting_mount_height=well.top().point.z, + max_z_distance=min(well.depth, lqid_cfg["max_z_distance"]), + min_z_distance=lqid_cfg["min_z_distance"], + mount_speed=lqid_cfg["mount_speed"], + plunger_speed=lqid_cfg["plunger_speed"], + sensor_threshold_pascals=lqid_cfg["sensor_threshold_pascals"], + expected_liquid_height=110, + log_pressure=True, + aspirate_while_sensing=False, + auto_zero_sensor=True, + num_baseline_reads=10, + data_file="/var/pressure_sensor_data.csv", + ) + + +QC_VOLUMES_G: Dict[int, Dict[int, List[Tuple[int, List[float]]]]] = { + 1: { + 50: [ # P50 + (50, [1.0, 50.0]), # T50 + ], + 1000: [ # P1000 + (50, [5.0]), # T50 + (200, []), # T200 + (1000, [1000.0]), # T1000 + ], + }, + 8: { + 50: [ # P50 + (50, [1.0, 50.0]), # T50 + ], + 1000: [ # P1000 + (50, [5.0]), # T50 + (200, []), # T200 + (1000, [1000.0]), # T1000 + ], + }, 96: { - 1000: { # P1000 - 50: [], # T50 - 200: [], # T200 - 1000: [1000.0], # T1000 - }, + 1000: [ # P1000 + (50, [5.0]), # T50 + (200, [200.0]), # T200 + (1000, [1000.0]), # T1000 + ], }, } -QC_VOLUMES_EXTRA_G: Dict[int, Dict[int, Dict[int, List[float]]]] = { +QC_VOLUMES_EXTRA_G: Dict[int, Dict[int, List[Tuple[int, List[float]]]]] = { 1: { - 50: { # P50 - 50: [1.0, 10.0, 50.0], # T50 - }, - 1000: { # P1000 - 50: [5.0, 50], # T50 - 200: [200.0], # T200 - 1000: [1000.0], # T1000 - }, + 50: [ # P50 + (50, [10.0]), # T50 + ], + 1000: [ # P1000 + (50, [50]), # T50 + (200, [200.0]), # T200 + (1000, []), # T1000 + ], }, 8: { - 50: { # P50 - 50: [1.0, 10.0, 50.0], # T50 - }, - 1000: { # P1000 - 50: [5.0, 50], # T50 - 200: [200.0], # T200 - 1000: [1000.0], # T1000 - }, + 50: [ # P50 + (50, [10.0]), # T50 + ], + 1000: [ # P1000 + (50, [50.0]), # T50 + (200, [200.0]), # T200 + (1000, []), # T1000 + ], }, 96: { - 1000: { # P1000 - 50: [], # T50 - 200: [], # T200 - 1000: [1000.0], # T1000 - }, + 1000: [ # P1000 + (50, []), # T50 + (200, []), # T200 + (1000, []), # T1000 + ], }, } -QC_VOLUMES_P: Dict[int, Dict[int, Dict[int, List[float]]]] = { +QC_VOLUMES_P: Dict[int, Dict[int, List[Tuple[int, List[float]]]]] = { + 1: { + 50: [ # P50 + (50, [1.0]), + ], + 1000: [ # P1000 + (50, [5.0]), # T50 + (200, [200.0]), # T200 + (1000, []), # T1000 + ], + }, + 8: { + 50: [ # P50 + (50, [1.0]), + ], + 1000: [ # P1000 + (50, [5.0]), # T50 + (200, [200.0]), # T200 + (1000, []), # T1000 + ], + }, 96: { - 1000: { # P1000 - 50: [5.0], # T50 - 200: [200.0], # T200 - 1000: [], # T1000 - }, + 1000: [ # P1000 + (50, [5.0]), # T50 + (200, [200.0]), # T200 + (1000, []), # T1000 + ], }, } QC_DEFAULT_TRIALS: Dict[ConfigType, Dict[int, int]] = { ConfigType.gravimetric: { 1: 10, - 8: 8, + 8: 10, 96: 9, }, ConfigType.photometric: { + 1: 8, 96: 5, }, } +QC_TEST_SAFETY_FACTOR = 0.0 + +QC_TEST_MIN_REQUIREMENTS: Dict[ + int, Dict[int, Dict[int, Dict[float, Tuple[float, float]]]] +] = { + # channels: [Pipette: [tip: [Volume: (%d, Cv)]]] + 1: { + 50: { # P50 + 50: { + 1.0: (5.0, 4.0), + 10.0: (1.0, 0.5), + 50.0: (1, 0.4), + }, + }, # T50 + 1000: { # P1000 + 50: { # T50 + 5.0: (5.0, 5.0), + 10.0: (2.0, 2.0), + 50.0: (1.0, 1.0), + }, + 200: { # T200 + 5.0: (7.0, 4.00), + 50.0: (2.0, 1.0), + 200.0: (0.5, 0.2), + }, + 1000: { # T1000 + 10.0: (7.5, 3.5), + 100.0: (2.0, 0.75), + 1000.0: (0.7, 0.15), + }, + }, + }, + 8: { + 50: { # P50 + 50: { # T50 + 1.0: (20.0, 5.0), + 10.0: (3.0, 2.0), + 50.0: (1.25, 0.4), + }, + }, + 1000: { # P1000 + 50: { # T50 + 5.0: (5.0, 5.0), + 10.0: (1.5, 1.5), + 50.0: (1.0, 1.0), + }, + 200: { # T200 + 5.0: (5.0, 5.0), + 50.0: (1.5, 1.5), + 200.0: (1.0, 0.4), + }, + 1000: { # T1000 + 10.0: (10.0, 5.0), + 100.0: (2.5, 1.0), + 1000.0: (0.7, 0.15), + }, + }, + }, + 96: { + 1000: { # P1000 + 50: { # T50 + 5.0: (2.5, 2.0), + 10.0: (3.1, 1.7), + 50.0: (1.5, 0.75), + }, + 200: { # T200 + 5.0: (2.5, 4.0), + 50.0: (1.5, 2.0), + 200.0: (1.4, 0.9), + }, + 1000: { # T1000 + 10.0: (5.0, 5.0), + 100.0: (2.5, 1.5), + 1000.0: (1.0, 0.75), + }, + }, + }, +} + def get_tip_volumes_for_qc( pipette_volume: int, pipette_channels: int, extra: bool, photometric: bool ) -> List[int]: """Build the default testing volumes for qc.""" - config: Dict[int, Dict[int, Dict[int, List[float]]]] = {} + config: Dict[int, Dict[int, List[Tuple[int, List[float]]]]] = {} + tip_volumes: List[int] = [] if photometric: config = QC_VOLUMES_P else: - if extra: - config = QC_VOLUMES_EXTRA_G - else: - config = QC_VOLUMES_G - tip_volumes = [ - t - for t in config[pipette_channels][pipette_volume].keys() - if len(config[pipette_channels][pipette_volume][t]) > 0 - ] + config = QC_VOLUMES_G + for t, vls in config[pipette_channels][pipette_volume]: + if len(vls) > 0 and t not in tip_volumes: + tip_volumes.append(t) + if extra: + for t, vls in QC_VOLUMES_EXTRA_G[pipette_channels][pipette_volume]: + if len(vls) > 0 and t not in tip_volumes: + tip_volumes.append(t) + assert len(tip_volumes) > 0 return tip_volumes diff --git a/hardware-testing/hardware_testing/gravimetric/execute.py b/hardware-testing/hardware_testing/gravimetric/execute.py index ce98ee66bb7..d1a72f4e4c9 100644 --- a/hardware-testing/hardware_testing/gravimetric/execute.py +++ b/hardware-testing/hardware_testing/gravimetric/execute.py @@ -2,12 +2,14 @@ from time import sleep from typing import Optional, Tuple, List, Dict -from opentrons.protocol_api import ProtocolContext, Well, Labware - +from opentrons.protocol_api import ProtocolContext, Well, Labware, InstrumentContext +from subprocess import run as run_subprocess +import subprocess from hardware_testing.data import ui from hardware_testing.data.csv_report import CSVReport -from hardware_testing.opentrons_api.types import Point, OT3Mount - +from hardware_testing.opentrons_api.types import Point, OT3Mount, Axis +from hardware_testing.drivers import asair_sensor +import os from . import report from . import config from .helpers import ( @@ -15,6 +17,7 @@ _get_channel_offset, _calculate_average, _jog_to_find_liquid_height, + _sense_liquid_height, _apply_labware_offsets, _pick_up_tip, _drop_tip, @@ -26,6 +29,7 @@ _finish_test, ) from .liquid_class.pipetting import ( + mix_with_liquid_class, aspirate_with_liquid_class, dispense_with_liquid_class, PipettingCallbacks, @@ -42,9 +46,11 @@ from .measurement.record import ( GravimetricRecorder, GravimetricRecorderConfig, + GravimetricRecording, ) +from .measurement.scale import Scale from .tips import MULTI_CHANNEL_TEST_ORDER - +import glob _MEASUREMENTS: List[Tuple[str, MeasurementData]] = list() @@ -52,6 +58,11 @@ _tip_counter: Dict[int, int] = {} +CAM_CMD_OT3 = ( + "v4l2-ctl --device {1} --set-fmt-video=width=1920,height=1080,pixelformat=MJPG " + "--stream-mmap --stream-to={0} --stream-count=1" +) + def _minimum_z_height(cfg: config.GravimetricConfig) -> int: if cfg.pipette_channels == 96: @@ -61,6 +72,9 @@ def _minimum_z_height(cfg: config.GravimetricConfig) -> int: def _generate_callbacks_for_trial( + ctx: ProtocolContext, + pipette: InstrumentContext, + test_report: CSVReport, recorder: GravimetricRecorder, volume: Optional[float], channel: int, @@ -72,6 +86,46 @@ def _generate_callbacks_for_trial( # very helpful for debugging and learning more about the system. if blank_measurement: volume = None + + hw_api = ctx._core.get_hardware() + hw_mount = OT3Mount.LEFT if pipette.mount == "left" else OT3Mount.RIGHT + pip_ax = Axis.of_main_tool_actuator(hw_mount) + estimate_bottom: float = -1 + estimate_aspirated: float = -1 + encoder_bottom: float = -1 + encoder_aspirated: float = -1 + + def _on_aspirating() -> None: + nonlocal estimate_bottom, encoder_bottom + recorder.set_sample_tag( + create_measurement_tag("aspirate", volume, channel, trial) + ) + if not volume: + return + estimate_bottom = hw_api.current_position_ot3(hw_mount)[pip_ax] + encoder_bottom = hw_api.encoder_current_position_ot3(hw_mount)[pip_ax] + + def _on_retracting() -> None: + nonlocal estimate_aspirated, encoder_aspirated + recorder.set_sample_tag( + create_measurement_tag("retract", volume, channel, trial) + ) + if not volume or estimate_aspirated >= 0 or encoder_aspirated >= 0: + # NOTE: currently in dispense, because trial was already recorded + return + estimate_aspirated = hw_api.current_position_ot3(hw_mount)[pip_ax] + encoder_aspirated = hw_api.encoder_current_position_ot3(hw_mount)[pip_ax] + report.store_encoder( + test_report, + volume, + channel, + trial, + estimate_bottom, + encoder_bottom, + estimate_aspirated, + encoder_aspirated, + ) + return PipettingCallbacks( on_submerging=lambda: recorder.set_sample_tag( create_measurement_tag("submerge", volume, channel, trial) @@ -79,12 +133,8 @@ def _generate_callbacks_for_trial( on_mixing=lambda: recorder.set_sample_tag( create_measurement_tag("mix", volume, channel, trial) ), - on_aspirating=lambda: recorder.set_sample_tag( - create_measurement_tag("aspirate", volume, channel, trial) - ), - on_retracting=lambda: recorder.set_sample_tag( - create_measurement_tag("retract", volume, channel, trial) - ), + on_aspirating=_on_aspirating, + on_retracting=_on_retracting, on_dispensing=lambda: recorder.set_sample_tag( create_measurement_tag("dispense", volume, channel, trial) ), @@ -182,18 +232,50 @@ def _next_tip_for_channel( return _tip +def _take_photos(trial: GravimetricTrial, stage_str: str) -> None: + if trial.ctx.is_simulating(): + cameras = ["/dev/video0"] + else: + cameras = glob.glob("/dev/video*") + for camera in cameras: + cam_pic_name = f"camera{camera[-1]}_channel{trial.channel}_volume{trial.volume}" + cam_pic_name += f"_trial{trial.trial}_{stage_str}.jpg" + if trial.ctx.is_simulating(): + cam_pic_name = cam_pic_name.replace(".jpg", ".txt") + cam_pic_path = ( + f"{trial.test_report.parent}/{trial.test_report._run_id}/{cam_pic_name}" + ) + process_cmd = CAM_CMD_OT3.format(str(cam_pic_path), camera) + if trial.ctx.is_simulating(): + with open(cam_pic_path, "w") as f: + f.write(str(cam_pic_name)) # create a test file + else: + try: + run_subprocess(process_cmd.split(" "), timeout=2) # take a picture + except subprocess.TimeoutExpired: + os.remove(cam_pic_path) + + def _run_trial( trial: GravimetricTrial, ) -> Tuple[float, MeasurementData, float, MeasurementData]: global _PREV_TRIAL_GRAMS pipetting_callbacks = _generate_callbacks_for_trial( - trial.recorder, trial.volume, trial.channel, trial.trial, trial.blank + trial.ctx, + trial.pipette, + trial.test_report, + trial.recorder, + trial.volume, + trial.channel, + trial.trial, + trial.blank, ) def _tag(m_type: MeasurementType) -> str: - return create_measurement_tag( + tag = create_measurement_tag( m_type, None if trial.blank else trial.volume, trial.channel, trial.trial ) + return tag def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: m_tag = _tag(m_type) @@ -209,7 +291,7 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: trial.pipette.mount, trial.stable, trial.env_sensor, - shorten=trial.inspect, + shorten=False, # TODO: remove this delay_seconds=trial.scale_delay, ) report.store_measurement(trial.test_report, m_tag, m_data) @@ -224,14 +306,30 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: ui.print_info("recorded weights:") - # RUN INIT - trial.pipette.move_to( - trial.well.top(50).move(trial.channel_offset) - ) # center channel over well + # RUN MIX + if trial.mix: + mix_with_liquid_class( + trial.ctx, + trial.pipette, + trial.tip_volume, + max(trial.volume, 5), + trial.well, + trial.channel_offset, + trial.channel_count, + trial.liquid_tracker, + callbacks=pipetting_callbacks, + blank=trial.blank, + mode=trial.mode, + clear_accuracy_function=trial.cfg.increment, + ) + else: + # center channel over well + trial.pipette.move_to(trial.well.top(50).move(trial.channel_offset)) mnt = OT3Mount.RIGHT if trial.pipette.mount == "right" else OT3Mount.LEFT trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry m_data_init = _record_measurement_and_store(MeasurementType.INIT) ui.print_info(f"\tinitial grams: {m_data_init.grams_average} g") + # update the vials volumes, using the last-known weight if _PREV_TRIAL_GRAMS is not None: _evaporation_loss_ul = abs( calculate_change_in_volume(_PREV_TRIAL_GRAMS, m_data_init) @@ -254,10 +352,12 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: trial.liquid_tracker, callbacks=pipetting_callbacks, blank=trial.blank, - inspect=trial.inspect, - mix=trial.mix, + mode=trial.mode, + clear_accuracy_function=trial.cfg.increment, ) trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry + + _take_photos(trial, "aspirate") m_data_aspirate = _record_measurement_and_store(MeasurementType.ASPIRATE) ui.print_info(f"\tgrams after aspirate: {m_data_aspirate.grams_average} g") ui.print_info(f"\tcelsius after aspirate: {m_data_aspirate.celsius_pipette} C") @@ -274,10 +374,11 @@ def _record_measurement_and_store(m_type: MeasurementType) -> MeasurementData: trial.liquid_tracker, callbacks=pipetting_callbacks, blank=trial.blank, - inspect=trial.inspect, - mix=trial.mix, + mode=trial.mode, + clear_accuracy_function=trial.cfg.increment, ) trial.ctx._core.get_hardware().retract(mnt) # retract to top of gantry + _take_photos(trial, "dispense") m_data_dispense = _record_measurement_and_store(MeasurementType.DISPENSE) ui.print_info(f"\tgrams after dispense: {m_data_dispense.grams_average} g") # calculate volumes @@ -302,32 +403,49 @@ def _get_channel_divider(cfg: config.GravimetricConfig) -> float: def build_gm_report( - cfg: config.GravimetricConfig, - resources: TestResources, + test_volumes: List[float], + run_id: str, + pipette_tag: str, + operator_name: str, + git_description: str, + robot_serial: str, + tip_batchs: Dict[str, str], recorder: GravimetricRecorder, + pipette_channels: int, + increment: bool, + name: str, + environment_sensor: asair_sensor.AsairSensorBase, + trials: int, + fw_version: str, ) -> report.CSVReport: """Build a CSVReport formated for gravimetric tests.""" ui.print_header("CREATE TEST-REPORT") test_report = report.create_csv_test_report( - resources.test_volumes, cfg, run_id=resources.run_id + test_volumes, pipette_channels, increment, trials, name, run_id=run_id ) - test_report.set_tag(resources.pipette_tag) - test_report.set_operator(resources.operator_name) - test_report.set_version(resources.git_description) + test_report.set_tag(pipette_tag) + test_report.set_operator(operator_name) + test_report.set_version(git_description) + test_report.set_firmware(fw_version) report.store_serial_numbers( test_report, - robot=resources.robot_serial, - pipette=resources.pipette_tag, - tips=resources.tip_batch, + robot=robot_serial, + pipette=pipette_tag, + tips=tip_batchs, scale=recorder.serial_number, - environment="None", + environment=environment_sensor.get_serial(), liquid="None", ) return test_report def _load_scale( - cfg: config.GravimetricConfig, resources: TestResources + name: str, + scale: Scale, + run_id: str, + pipette_tag: str, + start_time: float, + simulating: bool, ) -> GravimetricRecorder: ui.print_header("LOAD SCALE") ui.print_info( @@ -338,20 +456,20 @@ def _load_scale( ) recorder = GravimetricRecorder( GravimetricRecorderConfig( - test_name=cfg.name, - run_id=resources.run_id, - tag=resources.pipette_tag, - start_time=resources.start_time, + test_name=name, + run_id=run_id, + tag=pipette_tag, + start_time=start_time, duration=0, - frequency=1000 if resources.ctx.is_simulating() else 5, + frequency=1000 if simulating else 5, stable=False, ), - simulate=resources.ctx.is_simulating(), + scale, + simulate=simulating, ) ui.print_info(f'found scale "{recorder.serial_number}"') - if resources.ctx.is_simulating(): - start_sim_mass = {50: 15, 200: 200, 1000: 200} - recorder.set_simulation_mass(start_sim_mass[cfg.tip_volume]) + if simulating: + recorder.set_simulation_mass(0) recorder.record(in_thread=True) ui.print_info(f'scale is recording to "{recorder.file_name}"') return recorder @@ -417,7 +535,30 @@ def _calculate_evaporation( return average_aspirate_evaporation_ul, average_dispense_evaporation_ul -def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: +def _get_liquid_height( + resources: TestResources, cfg: config.GravimetricConfig, well: Well +) -> float: + resources.pipette.move_to(well.top(0), minimum_z_height=_minimum_z_height(cfg)) + if cfg.pipette_channels == 96: + if not resources.ctx.is_simulating() and not cfg.same_tip: + ui.alert_user_ready( + f"Please replace the {cfg.tip_volume}ul tips in slot 2", + resources.ctx._core.get_hardware(), + ) + _tip_counter[0] = 0 + if cfg.jog: + _liquid_height = _jog_to_find_liquid_height( + resources.ctx, resources.pipette, well + ) + else: + _liquid_height = _sense_liquid_height( + resources.ctx, resources.pipette, well, cfg + ) + resources.pipette.move_to(well.top().move(Point(0, 0, _minimum_z_height(cfg)))) + return _liquid_height + + +def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # noqa: C901 """Run.""" global _PREV_TRIAL_GRAMS global _MEASUREMENTS @@ -433,15 +574,24 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: # initialize the global tip counter, per each channel that will be tested _tip_counter[channel] = 0 trial_total = len(resources.test_volumes) * cfg.trials * len(channels_to_test) - support_tip_resupply = bool(cfg.pipette_channels == 96 and cfg.increment) - if trial_total > total_tips: + support_tip_resupply = bool(cfg.pipette_channels == 96) + if (trial_total + 1) > total_tips: if not support_tip_resupply: raise ValueError(f"more trials ({trial_total}) than tips ({total_tips})") elif not resources.ctx.is_simulating(): - ui.get_user_ready(f"prepare {trial_total - total_tips} extra tip-racks") - recorder = _load_scale(cfg, resources) - test_report = build_gm_report(cfg, resources, recorder) - + ui.get_user_ready( + f"prepare {(trial_total + 1) - total_tips} extra tip-racks" + ) + assert resources.recorder is not None + recorder = resources.recorder + if resources.ctx.is_simulating(): + start_sim_mass = {50: 15, 200: 200, 1000: 200} + resources.recorder.set_simulation_mass(start_sim_mass[cfg.tip_volume]) + os.makedirs( + f"{resources.test_report.parent}/{resources.test_report._run_id}", exist_ok=True + ) + recorder._recording = GravimetricRecording() + report.store_config_gm(resources.test_report, cfg) calibration_tip_in_use = True if resources.ctx.is_simulating(): @@ -449,35 +599,25 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: _MEASUREMENTS = list() try: ui.print_title("FIND LIQUID HEIGHT") - ui.print_info("homing...") - resources.ctx.home() - resources.pipette.home_plunger() - first_tip = resources.tips[0][0] + first_tip = _next_tip_for_channel(cfg, resources, 0, total_tips) setup_channel_offset = _get_channel_offset(cfg, channel=0) first_tip_location = first_tip.top().move(setup_channel_offset) _pick_up_tip(resources.ctx, resources.pipette, cfg, location=first_tip_location) mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT resources.ctx._core.get_hardware().retract(mnt) - if not resources.ctx.is_simulating(): - ui.get_user_ready("REPLACE first tip with NEW TIP") - ui.get_user_ready("CLOSE the door, and MOVE AWAY from machine") ui.print_info("moving to scale") well = labware_on_scale["A1"] - resources.pipette.move_to(well.top(0), minimum_z_height=_minimum_z_height(cfg)) - _liquid_height = _jog_to_find_liquid_height( - resources.ctx, resources.pipette, well - ) - resources.pipette.move_to(well.top().move(Point(0, 0, _minimum_z_height(cfg)))) + _liquid_height = _get_liquid_height(resources, cfg, well) height_below_top = well.depth - _liquid_height ui.print_info(f"liquid is {height_below_top} mm below top of vial") liquid_tracker.set_start_volume_from_liquid_height( - labware_on_scale["A1"], _liquid_height, name="Water" + well, _liquid_height, name="Water" ) vial_volume = liquid_tracker.get_volume(well) ui.print_info( f"software thinks there is {vial_volume} uL of liquid in the vial" ) - if not cfg.blank or cfg.inspect: + if not cfg.blank: average_aspirate_evaporation_ul = 0.0 average_dispense_evaporation_ul = 0.0 else: @@ -489,14 +629,17 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: resources, recorder, liquid_tracker, - test_report, + resources.test_report, labware_on_scale, ) ui.print_info("dropping tip") - _drop_tip( - resources.pipette, return_tip=False, minimum_z_height=_minimum_z_height(cfg) - ) # always trash calibration tips + if not cfg.same_tip: + _drop_tip( + resources.pipette, + return_tip=False, + minimum_z_height=_minimum_z_height(cfg), + ) # always trash calibration tips calibration_tip_in_use = False trial_count = 0 trials = build_gravimetric_trials( @@ -507,7 +650,7 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: resources.test_volumes, channels_to_test, recorder, - test_report, + resources.test_report, liquid_tracker, False, resources.env_sensor, @@ -541,12 +684,19 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: cfg, resources, channel, total_tips ) next_tip_location = next_tip.top().move(channel_offset) - _pick_up_tip( - resources.ctx, - resources.pipette, - cfg, - location=next_tip_location, - ) + if not cfg.same_tip: + _pick_up_tip( + resources.ctx, + resources.pipette, + cfg, + location=next_tip_location, + ) + mnt = ( + OT3Mount.LEFT + if cfg.pipette_mount == "left" + else OT3Mount.RIGHT + ) + resources.ctx._core.get_hardware().retract(mnt) ( actual_aspirate, aspirate_data, @@ -579,15 +729,25 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: dispense_data_list.append(dispense_data) report.store_trial( - test_report, + resources.test_report, run_trial.trial, run_trial.volume, run_trial.channel, asp_with_evap, disp_with_evap, + liquid_tracker.get_liquid_height(well), ) ui.print_info("dropping tip") - _drop_tip(resources.pipette, cfg.return_tip, _minimum_z_height(cfg)) + if not cfg.same_tip: + mnt = ( + OT3Mount.LEFT + if cfg.pipette_mount == "left" + else OT3Mount.RIGHT + ) + resources.ctx._core.get_hardware().retract(mnt) + _drop_tip( + resources.pipette, cfg.return_tip, _minimum_z_height(cfg) + ) ui.print_header(f"{volume} uL channel {channel + 1} CALCULATIONS") aspirate_average, aspirate_cv, aspirate_d = _calculate_stats( @@ -616,7 +776,7 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: _print_stats("dispense", dispense_average, dispense_cv, dispense_d) report.store_volume_per_channel( - report=test_report, + report=resources.test_report, mode="aspirate", volume=volume, channel=channel, @@ -625,9 +785,10 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: d=aspirate_d, celsius=aspirate_celsius_avg, humidity=aspirate_humidity_avg, + flag="isolated" if cfg.isolate_volumes else "", ) report.store_volume_per_channel( - report=test_report, + report=resources.test_report, mode="dispense", volume=volume, channel=channel, @@ -636,10 +797,32 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: d=dispense_d, celsius=dispense_celsius_avg, humidity=dispense_humidity_avg, + flag="isolated" if cfg.isolate_volumes else "", ) actual_asp_list_all.extend(actual_asp_list_channel) actual_disp_list_all.extend(actual_disp_list_channel) + acceptable_cv = trials[volume][channel][0].acceptable_cv + acceptable_d = trials[volume][channel][0].acceptable_d + print(f"acceptable cv {acceptable_cv} acceptable_d {acceptable_d}") + print(f"dispense cv {dispense_cv} aspirate_cv {aspirate_cv}") + print(f"dispense d {dispense_cv} aspirate_d {aspirate_d}") + if ( + not cfg.ignore_fail + and acceptable_cv is not None + and acceptable_d is not None + ): + acceptable_cv = abs(acceptable_cv / 100) + acceptable_d = abs(acceptable_d / 100) + if ( + dispense_cv > acceptable_cv + or aspirate_cv > acceptable_cv + or aspirate_d > acceptable_d + or dispense_d > acceptable_d + ): + raise RuntimeError( + f"Trial with volume {volume} on channel {channel} did not pass spec" + ) for trial in range(cfg.trials): trial_asp_list = trial_asp_dict[trial] trial_disp_list = trial_disp_dict[trial] @@ -652,22 +835,24 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: ) report.store_volume_per_trial( - report=test_report, + report=resources.test_report, mode="aspirate", volume=volume, trial=trial, average=aspirate_average, cv=aspirate_cv, d=aspirate_d, + flag="isolated" if cfg.isolate_volumes else "", ) report.store_volume_per_trial( - report=test_report, + report=resources.test_report, mode="dispense", volume=volume, trial=trial, average=dispense_average, cv=dispense_cv, d=dispense_d, + flag="isolated" if cfg.isolate_volumes else "", ) ui.print_header(f"{volume} uL channel all CALCULATIONS") @@ -682,30 +867,29 @@ def run(cfg: config.GravimetricConfig, resources: TestResources) -> None: _print_stats("dispense", dispense_average, dispense_cv, dispense_d) report.store_volume_all( - report=test_report, + report=resources.test_report, mode="aspirate", volume=volume, average=aspirate_average, cv=aspirate_cv, d=aspirate_d, + flag="isolated" if cfg.isolate_volumes else "", ) report.store_volume_all( - report=test_report, + report=resources.test_report, mode="dispense", volume=volume, average=dispense_average, cv=dispense_cv, d=dispense_d, + flag="isolated" if cfg.isolate_volumes else "", ) finally: - ui.print_info("ending recording") - recorder.stop() - recorder.deactivate() _return_tip = False if calibration_tip_in_use else cfg.return_tip _finish_test(cfg, resources, _return_tip) ui.print_title("RESULTS") _print_final_results( volumes=resources.test_volumes, channel_count=len(channels_to_test), - test_report=test_report, + test_report=resources.test_report, ) diff --git a/hardware-testing/hardware_testing/gravimetric/execute_photometric.py b/hardware-testing/hardware_testing/gravimetric/execute_photometric.py index 8e68e36fea4..c2c5f314428 100644 --- a/hardware-testing/hardware_testing/gravimetric/execute_photometric.py +++ b/hardware-testing/hardware_testing/gravimetric/execute_photometric.py @@ -11,14 +11,17 @@ create_measurement_tag, EnvironmentData, ) +from hardware_testing.drivers import asair_sensor from .measurement.environment import read_environment_data from . import report from . import config from .helpers import ( _jog_to_find_liquid_height, + _sense_liquid_height, _apply_labware_offsets, _pick_up_tip, _drop_tip, + get_list_of_wells_affected, ) from .trial import ( PhotometricTrial, @@ -45,9 +48,40 @@ "C": {"min": 2, "max": 9.999}, "D": {"min": 1, "max": 1.999}, } -_MIN_START_VOLUME_UL = 30000 -_MIN_END_VOLUME_UL = 10000 -_MAX_VOLUME_UL = 165000 +_MIN_START_VOLUME_UL = {1: 500, 8: 3000, 96: 30000} +_MIN_END_VOLUME_UL = {1: 400, 8: 3000, 96: 10000} +_MAX_VOLUME_UL = {1: 2000, 8: 15000, 96: 165000} + + +def _next_tip( + resources: TestResources, cfg: config.PhotometricConfig, pop: bool = True +) -> Well: + # get the first channel's first-used tip + # NOTE: note using list.pop(), b/c tip will be re-filled by operator, + # and so we can use pick-up-tip from there again + if not len(resources.tips[0]): + if not resources.ctx.is_simulating(): + ui.get_user_ready(f"replace TIPRACKS in slots {cfg.slots_tiprack}") + resources.tips = get_tips( + resources.ctx, resources.pipette, cfg.tip_volume, True + ) + if pop: + return resources.tips[0].pop(0) + return resources.tips[0][0] + + +def _get_res_well_names(cfg: config.PhotometricConfig) -> List[str]: + return [f"A{col}" for col in cfg.dye_well_column_offset] + + +def _get_photo_plate_dest(cfg: config.PhotometricConfig, trial: int) -> str: + if cfg.pipette_channels == 96: + return "A1" + elif cfg.pipette_channels == 8: + return f"A{trial + 1}" + else: + rows = "ABCDEFGH" + return f"{rows[trial]}{cfg.photoplate_column_offset}" def _get_dye_type(volume: float) -> str: @@ -130,7 +164,7 @@ def _record_measurement_and_store(m_type: MeasurementType) -> EnvironmentData: on_exiting=_no_op, ) - channel_count = 96 + channel_count = trial.channel_count # RUN INIT target_volume, volume_to_dispense, num_dispenses = _dispense_volumes(trial.volume) photoplate_preped_vol = max(target_volume - volume_to_dispense, 0) @@ -141,6 +175,7 @@ def _record_measurement_and_store(m_type: MeasurementType) -> EnvironmentData: # what volumes need to be added between trials. ui.get_user_ready("check DYE is enough") + ui.print_info(f"aspirating from {trial.source}") _record_measurement_and_store(MeasurementType.INIT) trial.pipette.move_to(location=trial.source.top(), minimum_z_height=133) # RUN ASPIRATE @@ -155,44 +190,40 @@ def _record_measurement_and_store(m_type: MeasurementType) -> EnvironmentData: trial.liquid_tracker, callbacks=pipetting_callbacks, blank=False, - inspect=trial.inspect, - mix=trial.mix, touch_tip=False, ) _record_measurement_and_store(MeasurementType.ASPIRATE) for i in range(num_dispenses): - - for w in trial.dest.wells(): - trial.liquid_tracker.set_start_volume(w, photoplate_preped_vol) - trial.pipette.move_to(trial.dest["A1"].top()) - + dest_name = _get_photo_plate_dest(trial.cfg, trial.trial) + dest_well = trial.dest[dest_name] + affected_wells = get_list_of_wells_affected(dest_well, trial.pipette.channels) + for _w in affected_wells: + trial.liquid_tracker.set_start_volume(_w, photoplate_preped_vol) + trial.pipette.move_to(dest_well.top()) + ui.print_info(f"dispensing to {dest_well}") # RUN DISPENSE dispense_with_liquid_class( trial.ctx, trial.pipette, trial.tip_volume, volume_to_dispense, - trial.dest["A1"], + dest_well, Point(), channel_count, trial.liquid_tracker, callbacks=pipetting_callbacks, blank=False, - inspect=trial.inspect, - mix=trial.mix, added_blow_out=(i + 1) == num_dispenses, touch_tip=trial.cfg.touch_tip, ) _record_measurement_and_store(MeasurementType.DISPENSE) - trial.pipette.move_to(location=trial.dest["A1"].top().move(Point(0, 0, 133))) + trial.ctx._core.get_hardware().retract(OT3Mount.LEFT) if (i + 1) == num_dispenses: - _drop_tip(trial.pipette, trial.cfg.return_tip) - else: - trial.pipette.move_to( - location=trial.dest["A1"].top().move(Point(0, 107, 133)) - ) - if not trial.ctx.is_simulating(): + if not trial.cfg.same_tip: + _drop_tip(trial.pipette, trial.cfg.return_tip) + trial.ctx._core.get_hardware().retract(OT3Mount.LEFT) + if not trial.ctx.is_simulating() and trial.channel_count == 96: ui.get_user_ready("add SEAL to plate and remove from DECK") return @@ -215,7 +246,10 @@ def _display_dye_information( for dye in dye_types_req.keys(): transfered_ul = dye_types_req[dye] - reservoir_ul = max(_MIN_START_VOLUME_UL, transfered_ul + _MIN_END_VOLUME_UL) + reservoir_ul = max( + _MIN_START_VOLUME_UL[cfg.pipette_channels], + transfered_ul + _MIN_END_VOLUME_UL[cfg.pipette_channels], + ) leftover_ul = reservoir_ul - transfered_ul def _ul_to_ml(x: float) -> float: @@ -236,27 +270,39 @@ def _ul_to_ml(x: float) -> float: if not resources.ctx.is_simulating(): dye_msg = 'A" or "HV' if include_hv and dye == "A" else dye ui.get_user_ready( - f'add {_ul_to_ml(reservoir_ul)} mL of DYE type "{dye_msg}"' + f"add {_ul_to_ml(reservoir_ul)} mL of DYE type {dye_msg} " + f"in well A{cfg.dye_well_column_offset}" ) def build_pm_report( - cfg: config.PhotometricConfig, resources: TestResources + test_volumes: List[float], + run_id: str, + pipette_tag: str, + operator_name: str, + git_description: str, + tip_batches: Dict[str, str], + environment_sensor: asair_sensor.AsairSensorBase, + trials: int, + name: str, + robot_serial: str, + fw_version: str, ) -> report.CSVReport: """Build a CSVReport formated for photometric tests.""" ui.print_header("CREATE TEST-REPORT") test_report = report.create_csv_test_report_photometric( - resources.test_volumes, cfg, run_id=resources.run_id + test_volumes, trials, name, run_id ) - test_report.set_tag(resources.pipette_tag) - test_report.set_operator(resources.operator_name) - test_report.set_version(resources.git_description) + test_report.set_tag(pipette_tag) + test_report.set_operator(operator_name) + test_report.set_version(git_description) + test_report.set_firmware(fw_version) report.store_serial_numbers_pm( test_report, - robot=resources.robot_serial, - pipette=resources.pipette_tag, - tips=resources.tip_batch, - environment="None", + robot=robot_serial, + pipette=pipette_tag, + tips=tip_batches, + environment=environment_sensor.get_serial(), liquid="None", ) return test_report @@ -269,37 +315,29 @@ def execute_trials( trials: Dict[float, List[PhotometricTrial]], ) -> None: """Execute a batch of pre-constructed trials.""" - ui.print_info("homing...") - resources.ctx.home() - resources.pipette.home_plunger() - - def _next_tip() -> Well: - # get the first channel's first-used tip - # NOTE: note using list.pop(), b/c tip will be re-filled by operator, - # and so we can use pick-up-tip from there again - nonlocal tips - if not len(tips[0]): - if not resources.ctx.is_simulating(): - ui.get_user_ready(f"replace TIPRACKS in slots {cfg.slots_tiprack}") - tips = get_tips(resources.ctx, resources.pipette, cfg.tip_volume, True) - return tips[0].pop(0) - trial_total = len(resources.test_volumes) * cfg.trials trial_count = 0 for volume in trials.keys(): ui.print_title(f"{volume} uL") + if cfg.pipette_channels == 1 and not resources.ctx.is_simulating(): + ui.get_user_ready( + f"put PLATE with prepped column {cfg.photoplate_column_offset} and remove SEAL" + ) for trial in trials[volume]: trial_count += 1 ui.print_header(f"{volume} uL ({trial.trial + 1}/{cfg.trials})") ui.print_info(f"trial total {trial_count}/{trial_total}") - if not resources.ctx.is_simulating(): + if not resources.ctx.is_simulating() and cfg.pipette_channels == 96: ui.get_user_ready(f"put PLATE #{trial.trial + 1} and remove SEAL") - next_tip: Well = _next_tip() + next_tip: Well = _next_tip(resources, cfg) next_tip_location = next_tip.top() - _pick_up_tip( - resources.ctx, resources.pipette, cfg, location=next_tip_location - ) + if not cfg.same_tip: + _pick_up_tip( + resources.ctx, resources.pipette, cfg, location=next_tip_location + ) _run_trial(trial) + if not trial.ctx.is_simulating() and trial.channel_count == 1: + ui.get_user_ready("add SEAL to plate and remove from DECK") def _find_liquid_height( @@ -308,22 +346,34 @@ def _find_liquid_height( liquid_tracker: LiquidTracker, reservoir: Well, ) -> None: - channel_count = 96 - setup_tip = resources.tips[0][0] + channel_count = cfg.pipette_channels + setup_tip = _next_tip(resources, cfg, cfg.pipette_channels == 1) volume_for_setup = max(resources.test_volumes) _pick_up_tip(resources.ctx, resources.pipette, cfg, location=setup_tip.top()) mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT resources.ctx._core.get_hardware().retract(mnt) - if not resources.ctx.is_simulating(): + if ( + not resources.ctx.is_simulating() + and not cfg.same_tip + and cfg.pipette_channels == 96 + ): ui.get_user_ready("REPLACE first tip with NEW TIP") + required_ul_per_src = (volume_for_setup * channel_count * cfg.trials) / len( + cfg.dye_well_column_offset + ) required_ul = max( - (volume_for_setup * channel_count * cfg.trials) + _MIN_END_VOLUME_UL, - _MIN_START_VOLUME_UL, + required_ul_per_src + _MIN_END_VOLUME_UL[cfg.pipette_channels], + _MIN_START_VOLUME_UL[cfg.pipette_channels], ) if not resources.ctx.is_simulating(): - _liquid_height = _jog_to_find_liquid_height( - resources.ctx, resources.pipette, reservoir - ) + if cfg.jog: + _liquid_height = _jog_to_find_liquid_height( + resources.ctx, resources.pipette, reservoir + ) + else: + _liquid_height = _sense_liquid_height( + resources.ctx, resources.pipette, reservoir, cfg + ) height_below_top = reservoir.depth - _liquid_height ui.print_info(f"liquid is {height_below_top} mm below top of reservoir") liquid_tracker.set_start_volume_from_liquid_height( @@ -336,9 +386,9 @@ def _find_liquid_height( f"software thinks there is {round(reservoir_ul / 1000, 1)} mL " f"of liquid in the reservoir (required = {round(required_ul / 1000, 1)} ml)" ) - if required_ul <= reservoir_ul < _MAX_VOLUME_UL: + if required_ul <= reservoir_ul < _MAX_VOLUME_UL[cfg.pipette_channels]: ui.print_info("valid liquid height") - elif required_ul > _MAX_VOLUME_UL: + elif required_ul > _MAX_VOLUME_UL[cfg.pipette_channels]: raise NotImplementedError( f"too many trials ({cfg.trials}) at {volume_for_setup} uL, " f"refilling reservoir is currently not supported" @@ -361,9 +411,12 @@ def _find_liquid_height( raise RuntimeError( f"bad volume in reservoir: {round(reservoir_ul / 1000, 1)} ml" ) - resources.pipette.drop_tip(home_after=False) # always trash setup tips - # NOTE: the first tip-rack should have already been replaced - # with new tips by the operator + resources.ctx._core.get_hardware().retract(OT3Mount.LEFT) + if not cfg.same_tip: + resources.pipette.drop_tip(home_after=False) # always trash setup tips + resources.ctx._core.get_hardware().retract(OT3Mount.LEFT) + # NOTE: the first tip-rack should have already been replaced + # with new tips by the operator def run(cfg: config.PhotometricConfig, resources: TestResources) -> None: @@ -382,16 +435,16 @@ def run(cfg: config.PhotometricConfig, resources: TestResources) -> None: trial_total <= total_tips ), f"more trials ({trial_total}) than tips ({total_tips})" - test_report = build_pm_report(cfg, resources) - _display_dye_information(cfg, resources) - _find_liquid_height(cfg, resources, liquid_tracker, reservoir["A1"]) + src_wells = [reservoir[res_well] for res_well in _get_res_well_names(cfg)] + for well in src_wells: + _find_liquid_height(cfg, resources, liquid_tracker, well) trials = build_photometric_trials( resources.ctx, - test_report, + resources.test_report, resources.pipette, - reservoir["A1"], + src_wells, photoplate, resources.test_volumes, liquid_tracker, diff --git a/hardware-testing/hardware_testing/gravimetric/helpers.py b/hardware-testing/hardware_testing/gravimetric/helpers.py index 2247395dd01..cc891234616 100644 --- a/hardware-testing/hardware_testing/gravimetric/helpers.py +++ b/hardware-testing/hardware_testing/gravimetric/helpers.py @@ -97,13 +97,29 @@ def get_list_of_wells_affected( channels: int, ) -> List[Well]: """Get list of wells affected.""" - if channels > 1 and not well_is_reservoir(well): - well_col = well.well_name[1:] # the "1" in "A1" - wells_list = [w for w in well.parent.columns_by_name()[well_col]] - assert well in wells_list, "Well is not inside column" - else: - wells_list = [well] - return wells_list + labware = well.parent + num_rows = len(labware.rows()) + num_cols = len(labware.columns()) + if num_rows == 1 and num_cols == 1: + return [well] # aka: 1-well reservoir + if channels == 1: + return [well] # 1ch pipette + if channels == 8: + if num_rows == 1: + return [well] # aka: 12-well reservoir + else: + assert ( + num_rows == 8 + ), f"8ch pipette cannot go to labware with {num_rows} rows" + well_col = well.well_name[1:] # the "1" in "A1" + wells_list = [w for w in well.parent.columns_by_name()[well_col]] + assert well in wells_list, "Well is not inside column" + return wells_list + if channels == 96: + return labware.wells() + raise ValueError( + f"unable to find affected wells for {channels}ch pipette (well={well})" + ) def get_pipette_unique_name(pipette: protocol_api.InstrumentContext) -> str: @@ -141,19 +157,39 @@ def _jog_to_find_liquid_height( return _liquid_height +def _sense_liquid_height( + ctx: ProtocolContext, + pipette: InstrumentContext, + well: Well, + cfg: config.VolumetricConfig, +) -> float: + hwapi = get_sync_hw_api(ctx) + pipette.move_to(well.top()) + lps = config._get_liquid_probe_settings(cfg, well) + # NOTE: very important that probing is done only 1x time, + # with a DRY tip, for reliability + probed_z = hwapi.liquid_probe(OT3Mount.LEFT, lps) + if ctx.is_simulating(): + probed_z = well.top().point.z - 1 + liq_height = probed_z - well.bottom().point.z + if abs(liq_height - lps.max_z_distance) < 0.01: + raise RuntimeError("unable to probe liquid, reach max travel distance") + return liq_height + + def _calculate_average(volume_list: List[float]) -> float: return sum(volume_list) / len(volume_list) def _reduce_volumes_to_not_exceed_software_limit( test_volumes: List[float], - cfg: config.VolumetricConfig, + pipette_volume: int, + pipette_channels: int, + tip_volume: int, ) -> List[float]: for i, v in enumerate(test_volumes): - liq_cls = get_liquid_class( - cfg.pipette_volume, cfg.pipette_channels, cfg.tip_volume, int(v) - ) - max_vol = cfg.tip_volume - liq_cls.aspirate.trailing_air_gap + liq_cls = get_liquid_class(pipette_volume, pipette_channels, tip_volume, int(v)) + max_vol = tip_volume - liq_cls.aspirate.trailing_air_gap test_volumes[i] = min(v, max_vol - 0.1) return test_volumes @@ -208,9 +244,9 @@ def _calculate_stats( return average, cv, d -def _get_tip_batch(is_simulating: bool) -> str: +def _get_tip_batch(is_simulating: bool, tip: int) -> str: if not is_simulating: - return input("TIP BATCH:").strip() + return input(f"TIP BATCH for {tip}ul tips:").strip() else: return "simulation-tip-batch" @@ -243,6 +279,8 @@ def _pick_up_tip( f"from slot #{location.labware.parent.parent}" ) pipette.pick_up_tip(location) + if pipette.channels == 96: + get_sync_hw_api(ctx).retract(OT3Mount.LEFT) # NOTE: the accuracy-adjust function gets set on the Pipette # each time we pick-up a new tip. if cfg.increment: @@ -266,66 +304,85 @@ def _drop_tip( pipette.move_to(cur_location.move(Point(0, 0, minimum_z_height))) -def _get_volumes(ctx: ProtocolContext, cfg: config.VolumetricConfig) -> List[float]: - if cfg.increment: - test_volumes = get_volume_increments(cfg.pipette_volume, cfg.tip_volume) - elif cfg.user_volumes and not ctx.is_simulating(): - _inp = input('Enter desired volumes, comma separated (eg: "10,100,1000") :') +def _get_volumes( + ctx: ProtocolContext, + increment: bool, + pipette_channels: int, + pipette_volume: int, + tip_volume: int, + user_volumes: bool, + kind: config.ConfigType, + extra: bool, + channels: int, + mode: str = "", +) -> List[float]: + if increment: + test_volumes = get_volume_increments( + pipette_channels, pipette_volume, tip_volume, mode=mode + ) + elif user_volumes and not ctx.is_simulating(): + _inp = input( + f'Enter desired volumes for tip{tip_volume}, comma separated (eg: "10,100,1000") :' + ) test_volumes = [ float(vol_str) for vol_str in _inp.strip().split(",") if vol_str ] else: - test_volumes = get_test_volumes(cfg) - if not test_volumes: - raise ValueError("no volumes to test, check the configuration") + test_volumes = get_test_volumes( + kind, channels, pipette_volume, tip_volume, extra + ) if not _check_if_software_supports_high_volumes(): if ctx.is_simulating(): test_volumes = _reduce_volumes_to_not_exceed_software_limit( - test_volumes, cfg + test_volumes, pipette_volume, channels, tip_volume ) else: raise RuntimeError("you are not the correct branch") - return sorted(test_volumes, reverse=False) # lowest volumes first + return test_volumes def _load_pipette( - ctx: ProtocolContext, cfg: config.VolumetricConfig + ctx: ProtocolContext, + pipette_channels: int, + pipette_volume: int, + pipette_mount: str, + increment: bool, + gantry_speed: Optional[int] = None, ) -> InstrumentContext: - pip_name = f"flex_{cfg.pipette_channels}channel_{cfg.pipette_volume}" - ui.print_info(f'pipette "{pip_name}" on mount "{cfg.pipette_mount}"') + pip_name = f"flex_{pipette_channels}channel_{pipette_volume}" + ui.print_info(f'pipette "{pip_name}" on mount "{pipette_mount}"') # if we're doing multiple tests in one run, the pipette may already be loaded loaded_pipettes = ctx.loaded_instruments - if cfg.pipette_mount in loaded_pipettes.keys(): - return loaded_pipettes[cfg.pipette_mount] + if pipette_mount in loaded_pipettes.keys(): + return loaded_pipettes[pipette_mount] - pipette = ctx.load_instrument(pip_name, cfg.pipette_mount) - assert pipette.max_volume == cfg.pipette_volume, ( - f"expected {cfg.pipette_volume} uL pipette, " + pipette = ctx.load_instrument(pip_name, pipette_mount) + assert pipette.max_volume == pipette_volume, ( + f"expected {pipette_volume} uL pipette, " f"but got a {pipette.max_volume} uL pipette" ) - if hasattr(cfg, "gantry_speed"): - pipette.default_speed = getattr(cfg, "gantry_speed") + if gantry_speed is not None: + pipette.default_speed = gantry_speed # NOTE: 8ch QC testing means testing 1 channel at a time, # so we need to decrease the pick-up current to work with 1 tip. - if pipette.channels == 8 and not cfg.increment: + if pipette.channels == 8 and not increment: hwapi = get_sync_hw_api(ctx) - mnt = OT3Mount.LEFT if cfg.pipette_mount == "left" else OT3Mount.RIGHT + mnt = OT3Mount.LEFT if pipette_mount == "left" else OT3Mount.RIGHT hwpipette: Pipette = hwapi.hardware_pipettes[mnt.to_mount()] hwpipette.pick_up_configurations.current = 0.2 return pipette def _get_tag_from_pipette( - pipette: InstrumentContext, - cfg: config.VolumetricConfig, + pipette: InstrumentContext, increment: bool, user_volumes: bool ) -> str: pipette_tag = get_pipette_unique_name(pipette) ui.print_info(f'found pipette "{pipette_tag}"') - if cfg.increment: + if increment: pipette_tag += "-increment" - elif cfg.user_volumes: + elif user_volumes: pipette_tag += "-user-volume" else: pipette_tag += "-qc" @@ -356,8 +413,16 @@ def _load_tipracks( loaded_labwares = ctx.loaded_labwares pre_loaded_tips: List[Labware] = [] for ls in tiprack_load_settings: - if ls[0] in loaded_labwares.keys() and loaded_labwares[ls[0]].name == ls[1]: - pre_loaded_tips.append(loaded_labwares[ls[0]]) + if ls[0] in loaded_labwares.keys(): + if loaded_labwares[ls[0]].name == ls[1]: + pre_loaded_tips.append(loaded_labwares[ls[0]]) + else: + # If something is in the slot that's not what we want, remove it + # we use this only for the 96 channel + ui.print_info( + f"Removing {loaded_labwares[ls[0]].name} from slot {ls[0]}" + ) + del ctx._core.get_deck()[ls[0]] # type: ignore[attr-defined] if len(pre_loaded_tips) == len(tiprack_load_settings): return pre_loaded_tips @@ -369,26 +434,35 @@ def _load_tipracks( return tipracks -def get_test_volumes(cfg: config.VolumetricConfig) -> List[float]: +def get_test_volumes( + kind: config.ConfigType, pipette: int, volume: int, tip: int, extra: bool +) -> List[float]: """Get test volumes.""" - if cfg.kind is config.ConfigType.photometric: - return config.QC_VOLUMES_P[cfg.pipette_channels][cfg.pipette_volume][ - cfg.tip_volume - ] + volumes: List[float] = [] + print(f"Finding volumes for p {pipette} {volume} with tip {tip}, extra: {extra}") + if kind is config.ConfigType.photometric: + for t, vls in config.QC_VOLUMES_P[pipette][volume]: + if t == tip: + volumes = vls + break else: - if cfg.extra: - return config.QC_VOLUMES_EXTRA_G[cfg.pipette_channels][cfg.pipette_volume][ - cfg.tip_volume - ] + if extra: + cfg = config.QC_VOLUMES_EXTRA_G else: - return config.QC_VOLUMES_G[cfg.pipette_channels][cfg.pipette_volume][ - cfg.tip_volume - ] + cfg = config.QC_VOLUMES_G + + for t, vls in cfg[pipette][volume]: + print(f"tip {t} volumes {vls}") + if t == tip: + volumes = vls + break + print(f"final volumes: {volumes}") + return volumes -def get_default_trials(cfg: config.VolumetricConfig) -> int: +def get_default_trials(increment: bool, kind: config.ConfigType, channels: int) -> int: """Return the default number of trials for QC tests.""" - if cfg.increment: + if increment: return 3 else: - return config.QC_DEFAULT_TRIALS[cfg.kind][cfg.pipette_channels] + return config.QC_DEFAULT_TRIALS[kind][channels] diff --git a/hardware-testing/hardware_testing/gravimetric/increments.py b/hardware-testing/hardware_testing/gravimetric/increments.py index 2b625e86ae8..5bf6b8efd3b 100644 --- a/hardware-testing/hardware_testing/gravimetric/increments.py +++ b/hardware-testing/hardware_testing/gravimetric/increments.py @@ -1,100 +1,334 @@ """Increments.""" from typing import List -P50_T50 = [ - 1.100, - 1.200, - 1.370, - 1.700, - 2.040, - 2.660, - 3.470, - 3.960, - 4.350, - 4.800, - 5.160, - 5.890, - 6.730, - 8.200, - 10.020, - 11.100, - 14.910, - 28.940, - 53.500, - 56.160, -] +INCREMENTS = { + 1: { + 50: { + 50: { + "default": [ + 1.100, + 1.200, + 1.370, + 1.700, + 2.040, + 2.660, + 3.470, + 3.960, + 4.350, + 4.800, + 5.160, + 5.890, + 6.730, + 8.200, + 10.020, + 11.100, + 14.910, + 28.940, + 53.500, + 56.160, + ], + "lowVolumeDefault": [ + 1.100, + 1.200, + 1.370, + 1.700, + 2.040, + 2.660, + 3.470, + 3.960, + 4.350, + 4.800, + 5.160, + 5.890, + 6.730, + 8.200, + 10.020, + 11.100, + 14.910, + 28.940, + 48.27, + ], + } + }, + 1000: { + 50: { + "default": [ + 2.530, + 2.700, + 3.000, + 3.600, + 4.040, + 4.550, + 5.110, + 5.500, + 5.750, + 6.000, + 6.460, + 7.270, + 8.170, + 11.000, + 12.900, + 16.510, + 26.400, + 33.380, + 53.360, + 60.000, + ], + }, + 200: { + "default": [ + 3.250, + 3.600, + 4.400, + 6.220, + 7.310, + 8.600, + 11.890, + 13.990, + 22.750, + 36.990, + 56.000, + 97.830, + 159.090, + 187.080, + 220.000, + ], + }, + 1000: { + "default": [ + 3.000, + 4.000, + 5.000, + 7.270, + 12.800, + 15.370, + 18.530, + 56.950, + 99.840, + 120.380, + 254.480, + 369.990, + 446.130, + 648.650, + 1030.000, + 1137.160, + ], + }, + }, + }, + 8: { + 50: { # FIXME: need to update based on PVT data + 50: { + "default": [ + 0.80, + 1.00, + 1.25, + 1.57, + 1.96, + 2.45, + 3.06, + 3.30, + 3.60, + 3.83, + 4.00, + 4.30, + 4.60, + 4.79, + 5.30, + 5.99, + 7.49, + 9.37, + 11.72, + 18.34, + 22.93, + 28.68, + 35.88, + 44.87, + 56.12, + ], + "lowVolumeDefault": [ + 0.80, + 1.00, + 1.25, + 1.57, + 1.96, + 2.45, + 3.06, + 3.30, + 3.60, + 3.83, + 4.00, + 4.30, + 4.60, + 4.79, + 5.30, + 5.99, + 7.49, + 9.37, + 11.72, + 18.34, + 22.93, + 28.68, + 35.88, + 48.27, + ], + } + }, + 1000: { # FIXME: need to update based on PVT data + 50: { + "default": [ + 1.00, + 1.24, + 1.54, + 1.91, + 2.37, + 2.94, + 3.64, + 3.90, + 4.20, + 4.52, + 4.80, + 5.10, + 5.61, + 5.90, + 6.20, + 6.95, + 8.63, + 10.70, + 13.28, + 16.47, + 20.43, + 25.34, + 31.43, + 38.99, + 48.37, + 60.00, + ], + }, + 200: { + "default": [ + 1.50, + 1.85, + 2.27, + 2.80, + 3.44, + 4.24, + 5.22, + 6.43, + 7.91, + 9.74, + 11.99, + 14.76, + 18.17, + 22.36, + 27.53, + 33.89, + 41.72, + 51.35, + 63.22, + 77.82, + 95.80, + 117.93, + 145.18, + 178.71, + 220.00, + ], + }, + 1000: { + "default": [ + 2.00, + 2.61, + 3.39, + 4.42, + 5.76, + 7.50, + 9.77, + 12.72, + 16.57, + 21.58, + 28.11, + 36.61, + 47.69, + 62.11, + 80.91, + 105.38, + 137.26, + 178.78, + 232.87, + 303.31, + 395.07, + 514.58, + 670.25, + 873.00, + 1137.10, + ], + }, + }, + }, + 96: { + 1000: { # FIXME: need to update based on DVT data + 50: { + "default": [ + 2.000, + 3.000, + 4.000, + 5.000, + 6.000, + 7.000, + 8.000, + 9.000, + 10.000, + 15.000, + 25.000, + 40.000, + 60.000, + ], + }, + 200: { + "default": [ + 2.000, + 3.000, + 4.000, + 5.000, + 6.000, + 7.000, + 8.000, + 9.000, + 10.000, + 50.000, + 100.000, + 220.000, + ], + }, + 1000: { + "default": [ + 2.000, + 3.000, + 4.000, + 5.000, + 6.000, + 7.000, + 8.000, + 9.000, + 10.000, + 50.000, + 200.000, + 1137.10, + ], + }, + } + }, +} -P1000_T50 = [ - 2.530, - 2.700, - 3.000, - 3.600, - 4.040, - 4.550, - 5.110, - 5.500, - 5.750, - 6.000, - 6.460, - 7.270, - 8.170, - 11.000, - 12.900, - 16.510, - 26.400, - 33.380, - 53.360, - 60.000, -] -P1000_T200 = [ - 3.250, - 3.600, - 4.400, - 6.220, - 7.310, - 8.600, - 11.890, - 13.990, - 22.750, - 36.990, - 56.000, - 97.830, - 159.090, - 187.080, - 220.000, -] - -P1000_T1000 = [ - 3.000, - 4.000, - 5.000, - 7.270, - 12.800, - 15.370, - 18.530, - 56.950, - 99.840, - 120.380, - 254.480, - 369.990, - 446.130, - 648.650, - 1030.000, - 1137.160, -] - - -def get_volume_increments(pipette_volume: int, tip_volume: int) -> List[float]: +def get_volume_increments( + channels: int, pipette_volume: int, tip_volume: int, mode: str = "" +) -> List[float]: """Get volume increments.""" - if pipette_volume == 50: - if tip_volume == 50: - return P50_T50 - elif pipette_volume == 1000: - if tip_volume == 50: - return P1000_T50 - elif tip_volume == 200: - return P1000_T200 - elif tip_volume == 1000: - return P1000_T1000 - raise ValueError(f"unexpected pipette-tip combo: P{pipette_volume}-T{tip_volume}") + try: + mode = mode if mode else "default" + return INCREMENTS[channels][pipette_volume][tip_volume][mode] + except KeyError: + raise ValueError( + f"unexpected channel-pipette-tip combo: {channels}ch P{pipette_volume} w/ T{tip_volume}" + ) diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py index b6852ee0493..ec6f34b2999 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/defaults.py @@ -8,8 +8,10 @@ interpolate, ) -_default_submerge_mm = 1.5 -_default_retract_mm = 3.0 +_default_submerge_aspirate_mm = 1.5 +_p50_multi_submerge_aspirate_mm = 3.0 +_default_submerge_dispense_mm = 1.5 +_default_retract_mm = 5.0 _default_retract_discontinuity = 20 _default_aspirate_delay_seconds = 1.0 @@ -25,16 +27,16 @@ 50: { # P50 50: { # T50 1: DispenseSettings( # 1uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=57, # ul/sec delay=_default_dispense_delay_seconds, z_retract_discontinuity=_default_retract_discontinuity, z_retract_height=_default_retract_mm, - blow_out_submerged=2, + blow_out_submerged=7, ), 10: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=57, # ul/sec delay=_default_dispense_delay_seconds, @@ -43,7 +45,7 @@ blow_out_submerged=2, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=57, # ul/sec delay=_default_dispense_delay_seconds, @@ -56,7 +58,7 @@ 1000: { # P1000 50: { # T50 5: DispenseSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=318, # ul/sec delay=_default_dispense_delay_seconds, @@ -65,7 +67,7 @@ blow_out_submerged=5, ), 10: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_dispense_delay_seconds, @@ -74,7 +76,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_dispense_delay_seconds, @@ -85,7 +87,7 @@ }, 200: { # T200 5: DispenseSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -94,7 +96,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -103,7 +105,7 @@ blow_out_submerged=5, ), 200: DispenseSettings( # 200uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -114,7 +116,7 @@ }, 1000: { # T1000 10: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_dispense_delay_seconds, @@ -123,7 +125,7 @@ blow_out_submerged=20, ), 100: DispenseSettings( # 100uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -132,7 +134,7 @@ blow_out_submerged=20, ), 1000: DispenseSettings( # 1000uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -147,16 +149,16 @@ 50: { # P50 50: { # T50 1: DispenseSettings( # 1uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=57, # ul/sec delay=_default_dispense_delay_seconds, z_retract_discontinuity=_default_retract_discontinuity, z_retract_height=_default_retract_mm, - blow_out_submerged=2, + blow_out_submerged=6, ), 10: DispenseSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=57, # ul/sec delay=_default_dispense_delay_seconds, @@ -165,7 +167,7 @@ blow_out_submerged=2, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=57, # ul/sec delay=_default_dispense_delay_seconds, @@ -178,7 +180,7 @@ 1000: { # P1000 50: { # T50 5: DispenseSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=318, # ul/sec delay=_default_dispense_delay_seconds, @@ -187,7 +189,7 @@ blow_out_submerged=5, ), 10: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_dispense_delay_seconds, @@ -196,7 +198,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_dispense_delay_seconds, @@ -207,7 +209,7 @@ }, 200: { # T200 5: DispenseSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -216,7 +218,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -225,7 +227,7 @@ blow_out_submerged=5, ), 200: DispenseSettings( # 200uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -236,7 +238,7 @@ }, 1000: { # T1000 10: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_dispense_delay_seconds, @@ -245,7 +247,7 @@ blow_out_submerged=20, ), 100: DispenseSettings( # 100uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -254,7 +256,7 @@ blow_out_submerged=20, ), 1000: DispenseSettings( # 1000uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_dispense_delay_seconds, @@ -269,7 +271,7 @@ 1000: { # P1000 50: { # T50 5: DispenseSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -278,7 +280,7 @@ blow_out_submerged=5, ), 10: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -287,7 +289,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -298,7 +300,7 @@ }, 200: { # T200 5: DispenseSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -307,7 +309,7 @@ blow_out_submerged=5, ), 50: DispenseSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -316,7 +318,7 @@ blow_out_submerged=5, ), 200: DispenseSettings( # 200uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -327,7 +329,7 @@ }, 1000: { # T1000 10: DispenseSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -336,7 +338,7 @@ blow_out_submerged=20, ), 100: DispenseSettings( # 100uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -345,7 +347,7 @@ blow_out_submerged=20, ), 1000: DispenseSettings( # 1000uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_dispense_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_dispense_delay_seconds, @@ -363,7 +365,7 @@ 50: { # P50 50: { # T50 1: AspirateSettings( # 1uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=35, # ul/sec delay=_default_aspirate_delay_seconds, @@ -373,7 +375,7 @@ trailing_air_gap=0.1, ), 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=23.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -383,7 +385,7 @@ trailing_air_gap=0.1, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=35, # ul/sec delay=_default_aspirate_delay_seconds, @@ -397,7 +399,7 @@ 1000: { # P1000 50: { # T50 5: AspirateSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=318, # ul/sec delay=_default_aspirate_delay_seconds, @@ -407,7 +409,7 @@ trailing_air_gap=0.1, ), 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_aspirate_delay_seconds, @@ -417,7 +419,7 @@ trailing_air_gap=0.1, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_aspirate_delay_seconds, @@ -429,7 +431,7 @@ }, 200: { # T200 5: AspirateSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -439,7 +441,7 @@ trailing_air_gap=5, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -449,7 +451,7 @@ trailing_air_gap=3.5, ), 200: AspirateSettings( # 200uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -461,7 +463,7 @@ }, 1000: { # T1000 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, @@ -471,7 +473,7 @@ trailing_air_gap=10, ), 100: AspirateSettings( # 100uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -481,7 +483,7 @@ trailing_air_gap=10, ), 1000: AspirateSettings( # 1000uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -497,7 +499,7 @@ 50: { # P50 50: { # T50 1: AspirateSettings( # 1uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_p50_multi_submerge_aspirate_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=35, # ul/sec delay=_default_aspirate_delay_seconds, @@ -507,7 +509,7 @@ trailing_air_gap=0.1, ), 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_p50_multi_submerge_aspirate_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=23.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -517,7 +519,7 @@ trailing_air_gap=0.1, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_p50_multi_submerge_aspirate_mm, plunger_acceleration=_default_accel_p50_ul_sec_sec, plunger_flow_rate=35, # ul/sec delay=_default_aspirate_delay_seconds, @@ -531,7 +533,7 @@ 1000: { # P1000 50: { # T50 5: AspirateSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=318, # ul/sec delay=_default_aspirate_delay_seconds, @@ -541,7 +543,7 @@ trailing_air_gap=0.1, ), 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_aspirate_delay_seconds, @@ -551,7 +553,7 @@ trailing_air_gap=0.1, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=478, # ul/sec delay=_default_aspirate_delay_seconds, @@ -563,7 +565,7 @@ }, 200: { # T200 5: AspirateSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -573,7 +575,7 @@ trailing_air_gap=5, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -583,7 +585,7 @@ trailing_air_gap=3.5, ), 200: AspirateSettings( # 200uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -595,7 +597,7 @@ }, 1000: { # T1000 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, @@ -605,7 +607,7 @@ trailing_air_gap=10, ), 100: AspirateSettings( # 100uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -615,7 +617,7 @@ trailing_air_gap=10, ), 1000: AspirateSettings( # 1000uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_p1000_ul_sec_sec, plunger_flow_rate=716, # ul/sec delay=_default_aspirate_delay_seconds, @@ -631,7 +633,7 @@ 1000: { # P1000 50: { # T50 5: AspirateSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=6.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -641,7 +643,7 @@ trailing_air_gap=0.1, ), 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=6.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -651,7 +653,7 @@ trailing_air_gap=0.1, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=6.5, # ul/sec delay=_default_aspirate_delay_seconds, @@ -663,7 +665,7 @@ }, 200: { # T200 5: AspirateSettings( # 5uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_aspirate_delay_seconds, @@ -673,7 +675,7 @@ trailing_air_gap=2, ), 50: AspirateSettings( # 50uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_aspirate_delay_seconds, @@ -683,7 +685,7 @@ trailing_air_gap=3.5, ), 200: AspirateSettings( # 200uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=80, # ul/sec delay=_default_aspirate_delay_seconds, @@ -695,7 +697,7 @@ }, 1000: { # T1000 10: AspirateSettings( # 10uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, @@ -705,7 +707,7 @@ trailing_air_gap=10, ), 100: AspirateSettings( # 100uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, @@ -715,7 +717,7 @@ trailing_air_gap=10, ), 1000: AspirateSettings( # 1000uL - z_submerge_depth=_default_submerge_mm, + z_submerge_depth=_default_submerge_aspirate_mm, plunger_acceleration=_default_accel_96ch_ul_sec_sec, plunger_flow_rate=160, # ul/sec delay=_default_aspirate_delay_seconds, diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py index a18bd7701de..473877208ea 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_class/pipetting.py @@ -10,6 +10,7 @@ from hardware_testing.gravimetric import config from hardware_testing.gravimetric.liquid_height.height import LiquidTracker from hardware_testing.opentrons_api.types import OT3Mount, Point +from hardware_testing.opentrons_api.helpers_ot3 import clear_pipette_ul_per_mm from .definition import LiquidClassSettings from .defaults import get_liquid_class @@ -47,7 +48,7 @@ def _get_heights_in_well( below=max(height_after - submerge, config.LABWARE_BOTTOM_CLEARANCE), ), ) - approach = max(pipetting_heights.start.above, pipetting_heights.end.below) + approach = max(pipetting_heights.start.above, pipetting_heights.end.above) submerge = pipetting_heights.end.below retract = pipetting_heights.end.above return approach, submerge, retract @@ -67,18 +68,19 @@ class PipettingCallbacks: def _check_aspirate_dispense_args( - aspirate: Optional[float], dispense: Optional[float] + mix: Optional[float], aspirate: Optional[float], dispense: Optional[float] ) -> None: - if aspirate is None and dispense is None: - raise ValueError("either a aspirate or dispense volume must be set") - if aspirate and dispense: - raise ValueError("both aspirate and dispense volumes cannot be set together") + if mix is None and aspirate is None and dispense is None: + raise ValueError("either mix, aspirate or dispense volume must be set") + if aspirate and dispense or mix and aspirate or mix and dispense: + raise ValueError("only a mix, aspirate or dispense volumes can be set") def _get_approach_submerge_retract_heights( well: Well, liquid_tracker: LiquidTracker, liquid_class: LiquidClassSettings, + mix: Optional[float], aspirate: Optional[float], dispense: Optional[float], blank: bool, @@ -86,7 +88,7 @@ def _get_approach_submerge_retract_heights( ) -> Tuple[float, float, float]: liquid_before, liquid_after = liquid_tracker.get_before_and_after_heights( well, - aspirate=aspirate, + aspirate=aspirate if aspirate else 0, dispense=dispense, channels=channel_count, ) @@ -141,7 +143,7 @@ def _retract( # NOTE: re-setting the gantry-load will reset the move-manager's per-axis constraints hw_api.set_gantry_load(hw_api.gantry_load) # retract out of the liquid (not out of the well) - pipette.move_to(well.top(mm_above_well_bottom).move(channel_offset), speed=speed) + pipette.move_to(well.bottom(mm_above_well_bottom).move(channel_offset), speed=speed) # reset discontinuity back to default if pipette.channels == 96: hw_api.config.motion_settings.max_speed_discontinuity.high_throughput[ @@ -155,7 +157,7 @@ def _retract( hw_api.set_gantry_load(hw_api.gantry_load) -def _pipette_with_liquid_settings( +def _pipette_with_liquid_settings( # noqa: C901 ctx: ProtocolContext, pipette: InstrumentContext, liquid_class: LiquidClassSettings, @@ -164,20 +166,28 @@ def _pipette_with_liquid_settings( channel_count: int, liquid_tracker: LiquidTracker, callbacks: PipettingCallbacks, + mix: Optional[float] = None, aspirate: Optional[float] = None, dispense: Optional[float] = None, blank: bool = True, - inspect: bool = False, - mix: bool = False, added_blow_out: bool = True, touch_tip: bool = False, + mode: str = "", + clear_accuracy_function: bool = False, ) -> None: """Run a pipette given some Pipetting Liquid Settings.""" # FIXME: stop using hwapi, and get those functions into core software hw_api = ctx._core.get_hardware() hw_mount = OT3Mount.LEFT if pipette.mount == "left" else OT3Mount.RIGHT hw_pipette = hw_api.hardware_pipettes[hw_mount.to_mount()] - _check_aspirate_dispense_args(aspirate, dispense) + _check_aspirate_dispense_args(mix, aspirate, dispense) + + def _get_max_blow_out_ul() -> float: + # NOTE: calculated using blow-out distance (mm) and the nominal ul-per-mm + blow_out_ul_per_mm = hw_pipette.config.shaft_ul_per_mm + bottom = hw_pipette.plunger_positions.bottom + blow_out = hw_pipette.plunger_positions.blow_out + return (blow_out - bottom) * blow_out_ul_per_mm def _dispense_with_added_blow_out() -> None: # dispense all liquid, plus some air @@ -185,7 +195,13 @@ def _dispense_with_added_blow_out() -> None: # we again use the hardware controller hw_api = ctx._core.get_hardware() hw_mount = OT3Mount.LEFT if pipette.mount == "left" else OT3Mount.RIGHT - hw_api.dispense(hw_mount, push_out=liquid_class.dispense.blow_out_submerged) + push_out = min(liquid_class.dispense.blow_out_submerged, _get_max_blow_out_ul()) + hw_api.dispense(hw_mount, push_out=push_out) + + def _blow_out_remaining_air() -> None: + # FIXME: using the HW-API to specify that we want to blow-out the full + # available blow-out volume + hw_api.blow_out(hw_mount, _get_max_blow_out_ul()) # ASPIRATE/DISPENSE SEQUENCE HAS THREE PHASES: # 1. APPROACH @@ -197,6 +213,7 @@ def _dispense_with_added_blow_out() -> None: well, liquid_tracker, liquid_class, + mix, aspirate, dispense, blank, @@ -204,12 +221,14 @@ def _dispense_with_added_blow_out() -> None: ) # SET Z SPEEDS DURING SUBMERGE/RETRACT - if aspirate: + if aspirate or mix: submerge_speed = config.TIP_SPEED_WHILE_SUBMERGING_ASPIRATE retract_speed = config.TIP_SPEED_WHILE_RETRACTING_ASPIRATE + _z_disc = liquid_class.aspirate.z_retract_discontinuity else: submerge_speed = config.TIP_SPEED_WHILE_SUBMERGING_DISPENSE retract_speed = config.TIP_SPEED_WHILE_RETRACTING_DISPENSE + _z_disc = liquid_class.dispense.z_retract_discontinuity # CREATE CALLBACKS FOR EACH PHASE def _aspirate_on_approach() -> None: @@ -219,11 +238,38 @@ def _aspirate_on_approach() -> None: "this should only happen during blank trials" ) hw_api.dispense(hw_mount) - hw_api.configure_for_volume(hw_mount, aspirate if aspirate else dispense) + if mode: + # NOTE: increment test requires the plunger's "bottom" position + # does not change during the entire test run + hw_api.set_liquid_class(hw_mount, mode) + else: + hw_api.configure_for_volume(hw_mount, aspirate if aspirate else dispense) + if clear_accuracy_function: + clear_pipette_ul_per_mm(hw_api, hw_mount) # type: ignore[arg-type] hw_api.prepare_for_aspirate(hw_mount) if liquid_class.aspirate.leading_air_gap > 0: pipette.aspirate(liquid_class.aspirate.leading_air_gap) + def _aspirate_on_mix() -> None: + callbacks.on_mixing() + _submerge(pipette, well, submerge_mm, channel_offset, submerge_speed) + _num_mixes = 5 + for i in range(_num_mixes): + pipette.aspirate(mix) + ctx.delay(liquid_class.aspirate.delay) + if i < _num_mixes - 1: + pipette.dispense(mix) + else: + _dispense_with_added_blow_out() + ctx.delay(liquid_class.dispense.delay) + # don't go all the way up to retract position, but instead just above liquid + _retract( + ctx, pipette, well, channel_offset, approach_mm, retract_speed, _z_disc + ) + _blow_out_remaining_air() + hw_api.prepare_for_aspirate(hw_mount) + assert pipette.current_volume == 0 + def _aspirate_on_submerge() -> None: # aspirate specified volume callbacks.on_aspirating() @@ -260,11 +306,7 @@ def _dispense_on_retract() -> None: if pipette.current_volume <= 0 and added_blow_out: # blow-out any remaining air in pipette (any reason why not?) callbacks.on_blowing_out() - # FIXME: using the HW-API to specify that we want to blow-out the full - # available blow-out volume - # NOTE: calculated using blow-out distance (mm) and the nominal ul-per-mm - max_blow_out_volume = 79.5 if pipette.max_volume >= 1000 else 3.9 - hw_api.blow_out(hw_mount, max_blow_out_volume) + _blow_out_remaining_air() hw_api.prepare_for_aspirate(hw_mount) if touch_tip: pipette.touch_tip(speed=config.TOUCH_TIP_SPEED) @@ -277,27 +319,63 @@ def _dispense_on_retract() -> None: pipette.flow_rate.dispense = liquid_class.dispense.plunger_flow_rate pipette.flow_rate.blow_out = liquid_class.dispense.plunger_flow_rate pipette.move_to(well.bottom(approach_mm).move(channel_offset)) - _aspirate_on_approach() if aspirate else _dispense_on_approach() + _aspirate_on_approach() if aspirate or mix else _dispense_on_approach() - # PHASE 2: SUBMERGE - callbacks.on_submerging() - _submerge(pipette, well, submerge_mm, channel_offset, submerge_speed) - _aspirate_on_submerge() if aspirate else _dispense_on_submerge() - - # PHASE 3: RETRACT - callbacks.on_retracting() - if aspirate: - _z_disc = liquid_class.aspirate.z_retract_discontinuity + if mix: + # PHASE 2A: MIXING + _aspirate_on_mix() else: - _z_disc = liquid_class.dispense.z_retract_discontinuity - _retract(ctx, pipette, well, channel_offset, retract_mm, retract_speed, _z_disc) - _aspirate_on_retract() if aspirate else _dispense_on_retract() + # PHASE 2B: ASPIRATE or DISPENSE + callbacks.on_submerging() + _submerge(pipette, well, submerge_mm, channel_offset, submerge_speed) + _aspirate_on_submerge() if aspirate else _dispense_on_submerge() + + # PHASE 3: RETRACT + callbacks.on_retracting() + _retract(ctx, pipette, well, channel_offset, retract_mm, retract_speed, _z_disc) + _aspirate_on_retract() if aspirate else _dispense_on_retract() # EXIT callbacks.on_exiting() hw_api.retract(hw_mount) +def mix_with_liquid_class( + ctx: ProtocolContext, + pipette: InstrumentContext, + tip_volume: int, + mix_volume: float, + well: Well, + channel_offset: Point, + channel_count: int, + liquid_tracker: LiquidTracker, + callbacks: PipettingCallbacks, + blank: bool = False, + touch_tip: bool = False, + mode: str = "", + clear_accuracy_function: bool = False, +) -> None: + """Mix with liquid class.""" + liquid_class = get_liquid_class( + int(pipette.max_volume), pipette.channels, tip_volume, int(mix_volume) + ) + _pipette_with_liquid_settings( + ctx, + pipette, + liquid_class, + well, + channel_offset, + channel_count, + liquid_tracker, + callbacks, + mix=mix_volume, + blank=blank, + touch_tip=touch_tip, + mode=mode, + clear_accuracy_function=clear_accuracy_function, + ) + + def aspirate_with_liquid_class( ctx: ProtocolContext, pipette: InstrumentContext, @@ -309,9 +387,9 @@ def aspirate_with_liquid_class( liquid_tracker: LiquidTracker, callbacks: PipettingCallbacks, blank: bool = False, - inspect: bool = False, - mix: bool = False, touch_tip: bool = False, + mode: str = "", + clear_accuracy_function: bool = False, ) -> None: """Aspirate with liquid class.""" pip_size = 50 if "50" in pipette.name else 1000 @@ -329,9 +407,9 @@ def aspirate_with_liquid_class( callbacks, aspirate=aspirate_volume, blank=blank, - inspect=inspect, - mix=mix, touch_tip=touch_tip, + mode=mode, + clear_accuracy_function=clear_accuracy_function, ) @@ -346,10 +424,10 @@ def dispense_with_liquid_class( liquid_tracker: LiquidTracker, callbacks: PipettingCallbacks, blank: bool = False, - inspect: bool = False, - mix: bool = False, added_blow_out: bool = True, touch_tip: bool = False, + mode: str = "", + clear_accuracy_function: bool = False, ) -> None: """Dispense with liquid class.""" pip_size = 50 if "50" in pipette.name else 1000 @@ -367,8 +445,8 @@ def dispense_with_liquid_class( callbacks, dispense=dispense_volume, blank=blank, - inspect=inspect, - mix=mix, added_blow_out=added_blow_out, touch_tip=touch_tip, + mode=mode, + clear_accuracy_function=clear_accuracy_function, ) diff --git a/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py b/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py index 9d2cba5baf1..3c908187571 100644 --- a/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py +++ b/hardware-testing/hardware_testing/gravimetric/liquid_height/height.py @@ -133,8 +133,9 @@ def __init__(self, calc_type: CalcType) -> None: def set_volume(self, volume: float) -> None: """Set volume.""" - if not 0 <= volume <= self._calc_type.max_volume(): - raise ValueError(f"Volume out of range: {volume}") + _max = self._calc_type.max_volume() + if not 0 <= volume <= _max: + raise ValueError(f"Volume ({volume}) out of range (max={_max})") self._volume = volume def get_volume(self) -> float: diff --git a/hardware-testing/hardware_testing/gravimetric/measurement/record.py b/hardware-testing/hardware_testing/gravimetric/measurement/record.py index 48048490835..7e69e2ca4f3 100644 --- a/hardware-testing/hardware_testing/gravimetric/measurement/record.py +++ b/hardware-testing/hardware_testing/gravimetric/measurement/record.py @@ -268,11 +268,13 @@ def _record_get_interval_overlap(samples: GravimetricRecording, period: float) - class GravimetricRecorder: """Gravimetric Recorder.""" - def __init__(self, cfg: GravimetricRecorderConfig, simulate: bool = False) -> None: + def __init__( + self, cfg: GravimetricRecorderConfig, scale: Scale, simulate: bool = False + ) -> None: """Gravimetric Recorder.""" self._cfg = cfg self._file_name: Optional[str] = None - self._scale: Scale = Scale.build(simulate=simulate) + self._scale: Scale = scale self._recording = GravimetricRecording() self._is_recording = Event() self._reading_samples = Event() @@ -494,14 +496,22 @@ def _on_new_sample(recording: GravimetricRecording) -> None: new_sample = recording[-1] csv_line = new_sample.as_csv(_start_time) append_data_to_file( - str(self._cfg.test_name), _file_name, csv_line + "\n" + str(self._cfg.test_name), + str(self._cfg.run_id), + _file_name, + csv_line + "\n", ) # type: ignore[arg-type] # add the header to the CSV file dump_data_to_file( - self._cfg.test_name, self._file_name, GravimetricSample.csv_header() + "\n" + self._cfg.test_name, + self._cfg.run_id, + self._file_name, + GravimetricSample.csv_header() + "\n", ) _rec = self._record_samples(timeout=timeout, on_new_sample=_on_new_sample) # add a final newline character to the CSV file - append_data_to_file(self._cfg.test_name, self._file_name, "\n") + append_data_to_file( + self._cfg.test_name, self._cfg.run_id, self._file_name, "\n" + ) return _rec diff --git a/hardware-testing/hardware_testing/gravimetric/measurement/scale.py b/hardware-testing/hardware_testing/gravimetric/measurement/scale.py index 006c5eeafb4..7dc5a31489d 100644 --- a/hardware-testing/hardware_testing/gravimetric/measurement/scale.py +++ b/hardware-testing/hardware_testing/gravimetric/measurement/scale.py @@ -12,6 +12,7 @@ RadwagWorkingMode, RadwagFilter, RadwagValueRelease, + RadwagAmbiant, ) @@ -25,6 +26,7 @@ class ScaleConfig: filter: RadwagFilter value_release: RadwagValueRelease tare: float + ambiant: RadwagAmbiant DEFAULT_SCALE_CONFIG = ScaleConfig( @@ -34,6 +36,7 @@ class ScaleConfig: filter=RadwagFilter.very_fast, value_release=RadwagValueRelease.fast, tare=0.0, + ambiant=RadwagAmbiant.stable, ) @@ -102,6 +105,7 @@ def initialize(self, cfg: ScaleConfig = DEFAULT_SCALE_CONFIG) -> None: self._scale.working_mode(mode=cfg.working_mode) self._scale.filter(cfg.filter) self._scale.value_release(cfg.value_release) + self._scale.ambiant(cfg.ambiant) self.tare(cfg.tare) def tare(self, grams: float) -> None: diff --git a/hardware-testing/hardware_testing/gravimetric/report.py b/hardware-testing/hardware_testing/gravimetric/report.py index b13d25e3358..3a15e0b213e 100644 --- a/hardware-testing/hardware_testing/gravimetric/report.py +++ b/hardware-testing/hardware_testing/gravimetric/report.py @@ -1,7 +1,7 @@ """Report.""" from dataclasses import fields from enum import Enum -from typing import List, Tuple, Any +from typing import List, Tuple, Any, Dict from hardware_testing.data.csv_report import ( CSVReport, @@ -79,7 +79,7 @@ class EnvironmentReportState(str, Enum): def create_csv_test_report_photometric( - volumes: List[float], cfg: config.PhotometricConfig, run_id: str + volumes: List[float], trials: int, name: str, run_id: str ) -> CSVReport: """Create CSV test report.""" env_info = [field.name.replace("_", "-") for field in fields(EnvironmentData)] @@ -92,11 +92,11 @@ def create_csv_test_report_photometric( 0, trial, ) - for trial in range(cfg.trials) + for trial in range(trials) ] report = CSVReport( - test_name=cfg.name, + test_name=name, run_id=run_id, sections=[ CSVSection( @@ -104,7 +104,9 @@ def create_csv_test_report_photometric( lines=[ CSVLine("robot", [str]), CSVLine("pipette", [str]), - CSVLine("tips", [str]), + CSVLine("tips_50ul", [str]), + CSVLine("tips_200ul", [str]), + CSVLine("tips_1000ul", [str]), CSVLine("environment", [str]), CSVLine("liquid", [str]), ], @@ -112,7 +114,7 @@ def create_csv_test_report_photometric( CSVSection( title="CONFIG", lines=[ - CSVLine(field.name, [field.type]) + CSVLine(field.name, [str]) # just convert to a string, always for field in fields(config.PhotometricConfig) if field.name not in config.PHOTO_CONFIG_EXCLUDE_FROM_REPORT ], @@ -133,21 +135,31 @@ def create_csv_test_report_photometric( ), ], ) - # might as well set the configuration values now + return report + + +def store_config_pm(report: CSVReport, cfg: config.PhotometricConfig) -> None: + """Store the config file list.""" for field in fields(config.PhotometricConfig): if field.name in config.PHOTO_CONFIG_EXCLUDE_FROM_REPORT: continue - report("CONFIG", field.name, [getattr(cfg, field.name)]) - return report + val_str = str(getattr(cfg, field.name)).replace(" ", "") + val_str = val_str.replace("[", "").replace("]", "").replace(",", "-") + report("CONFIG", field.name, [val_str]) def create_csv_test_report( - volumes: List[float], cfg: config.GravimetricConfig, run_id: str + volumes: List[float], + pipette_channels: int, + increment: bool, + trials: int, + name: str, + run_id: str, ) -> CSVReport: """Create CSV test report.""" env_info = [field.name.replace("_", "-") for field in fields(EnvironmentData)] meas_info = [field.name.replace("_", "-") for field in fields(MeasurementData)] - if cfg.pipette_channels == 8 and not cfg.increment: + if pipette_channels == 8 and not increment: pip_channels_tested = 8 else: pip_channels_tested = 1 @@ -167,7 +179,7 @@ def create_csv_test_report( trial, ) for channel in range(pip_channels_tested) - for trial in range(cfg.trials) + for trial in range(trials) ] # Get label for different volume stores, "channel_all", "channel_1" through channel count, @@ -175,7 +187,7 @@ def create_csv_test_report( volume_stat_type = ( ["channel_all"] + [f"channel_{c+1}" for c in range(pip_channels_tested)] - + [f"trial_{t+1}" for t in range(cfg.trials)] + + [f"trial_{t+1}" for t in range(trials)] ) def _field_type_not_using_typing(t: Any) -> Any: @@ -184,15 +196,18 @@ def _field_type_not_using_typing(t: Any) -> Any: return t report = CSVReport( - test_name=cfg.name, + test_name=name, run_id=run_id, + validate_meta_data=False, # to avoid >3 columns in CSV (:shrug:) sections=[ CSVSection( title="SERIAL-NUMBERS", lines=[ CSVLine("robot", [str]), CSVLine("pipette", [str]), - CSVLine("tips", [str]), + CSVLine("tips_50ul", [str]), + CSVLine("tips_200ul", [str]), + CSVLine("tips_1000ul", [str]), CSVLine("scale", [str]), CSVLine("environment", [str]), CSVLine("liquid", [str]), @@ -201,7 +216,7 @@ def _field_type_not_using_typing(t: Any) -> Any: CSVSection( title="CONFIG", lines=[ - CSVLine(field.name, [_field_type_not_using_typing(field.type)]) + CSVLine(field.name, [str]) # just convert to a string, always for field in fields(config.GravimetricConfig) if field.name not in config.GRAV_CONFIG_EXCLUDE_FROM_REPORT ], @@ -209,7 +224,7 @@ def _field_type_not_using_typing(t: Any) -> Any: CSVSection( title="VOLUMES", lines=[ - CSVLine(f"volume-{m}-{round(v, 2)}-{s}-{i}", [float]) + CSVLine(f"volume-{m}-{round(v, 2)}-{s}-{i}", [float, str]) for v in volumes for s in volume_stat_type for m in ["aspirate", "dispense"] @@ -230,8 +245,8 @@ def _field_type_not_using_typing(t: Any) -> Any: ) for v in volumes for c in range(pip_channels_tested) - for t in range(cfg.trials) - for m in ["aspirate", "dispense"] + for t in range(trials) + for m in ["aspirate", "dispense", "liquid_height"] ], ), CSVSection( @@ -263,28 +278,53 @@ def _field_type_not_using_typing(t: Any) -> Any: if volume is not None or trial < config.NUM_BLANK_TRIALS ], ), + CSVSection( + title="ENCODER", + lines=[ + CSVLine( + f"encoder-volume-{round(v, 2)}-channel_{c}" + f"-trial-{t + 1}-{i}-{d}", + [float], + ) + for v in volumes + for c in range(pip_channels_tested) + for t in range(trials) + for i in ["start", "end"] + for d in ["target", "encoder", "drift"] + ], + ), ], ) - # might as well set the configuration values now + # NOTE: just immediately clear all the "isolate" flags on the volume section + # so that final CSV is guaranteed to not be filled with a bunch of "None" + for line in report["VOLUMES"].lines: + line.store(None, "") + return report + + +def store_config_gm(report: CSVReport, cfg: config.GravimetricConfig) -> None: + """Store the config file list.""" for field in fields(config.GravimetricConfig): if field.name in config.GRAV_CONFIG_EXCLUDE_FROM_REPORT: continue - report("CONFIG", field.name, [getattr(cfg, field.name)]) - return report + val_str = str(getattr(cfg, field.name)).replace(" ", "") + val_str = val_str.replace("[", "").replace("]", "").replace(",", "-") + report("CONFIG", field.name, [val_str]) def store_serial_numbers_pm( report: CSVReport, robot: str, pipette: str, - tips: str, + tips: Dict[str, str], environment: str, liquid: str, ) -> None: """Report serial numbers.""" report("SERIAL-NUMBERS", "robot", [robot]) report("SERIAL-NUMBERS", "pipette", [pipette]) - report("SERIAL-NUMBERS", "tips", [tips]) + for tip in tips.keys(): + report("SERIAL-NUMBERS", tip, [tips[tip]]) report("SERIAL-NUMBERS", "environment", [environment]) report("SERIAL-NUMBERS", "liquid", [liquid]) @@ -304,7 +344,7 @@ def store_serial_numbers( report: CSVReport, robot: str, pipette: str, - tips: str, + tips: Dict[str, str], scale: str, environment: str, liquid: str, @@ -314,14 +354,21 @@ def store_serial_numbers( report.set_device_id(pipette, pipette) report("SERIAL-NUMBERS", "robot", [robot]) report("SERIAL-NUMBERS", "pipette", [pipette]) - report("SERIAL-NUMBERS", "tips", [tips]) + for tip in tips.keys(): + report("SERIAL-NUMBERS", tip, [tips[tip]]) report("SERIAL-NUMBERS", "scale", [scale]) report("SERIAL-NUMBERS", "environment", [environment]) report("SERIAL-NUMBERS", "liquid", [liquid]) def store_volume_all( - report: CSVReport, mode: str, volume: float, average: float, cv: float, d: float + report: CSVReport, + mode: str, + volume: float, + average: float, + cv: float, + d: float, + flag: str = "", ) -> None: """Report volume.""" assert mode in ["aspirate", "dispense"] @@ -329,13 +376,17 @@ def store_volume_all( report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_all-average", - [round(average, 2)], + [round(average, 2), flag], ) report( - "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_all-cv", [round(cv * 100.0, 2)] + "VOLUMES", + f"volume-{mode}-{vol_in_tag}-channel_all-cv", + [round(cv * 100.0, 2), flag], ) report( - "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_all-d", [round(d * 100.0, 2)] + "VOLUMES", + f"volume-{mode}-{vol_in_tag}-channel_all-d", + [round(d * 100.0, 2), flag], ) @@ -349,6 +400,7 @@ def store_volume_per_channel( d: float, celsius: float, humidity: float, + flag: str = "", ) -> None: """Report volume.""" assert mode in ["aspirate", "dispense"] @@ -356,27 +408,27 @@ def store_volume_per_channel( report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_{channel + 1}-average", - [round(average, 2)], + [round(average, 2), flag], ) report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_{channel + 1}-cv", - [round(cv * 100.0, 2)], + [round(cv * 100.0, 2), flag], ) report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_{channel + 1}-d", - [round(d * 100.0, 2)], + [round(d * 100.0, 2), flag], ) report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_{channel + 1}-celsius-pipette-avg", - [round(celsius, 2)], + [round(celsius, 2), flag], ) report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-channel_{channel + 1}-humidity-pipette-avg", - [round(humidity, 2)], + [round(humidity, 2), flag], ) @@ -388,6 +440,7 @@ def store_volume_per_trial( average: float, cv: float, d: float, + flag: str = "", ) -> None: """Report volume.""" assert mode in ["aspirate", "dispense"] @@ -395,17 +448,61 @@ def store_volume_per_trial( report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-trial_{trial + 1}-average", - [round(average, 2)], + [round(average, 2), flag], ) report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-trial_{trial + 1}-cv", - [round(cv * 100.0, 2)], + [round(cv * 100.0, 2), flag], ) report( "VOLUMES", f"volume-{mode}-{vol_in_tag}-trial_{trial + 1}-d", - [round(d * 100.0, 2)], + [round(d * 100.0, 2), flag], + ) + + +def store_encoder( + report: CSVReport, + volume: float, + channel: int, + trial: int, + estimate_bottom: float, + encoder_bottom: float, + estimate_aspirated: float, + encoder_aspirated: float, +) -> None: + """Store encoder information.""" + vol_in_tag = str(round(volume, 5)) + report( + "ENCODER", + f"encoder-volume-{vol_in_tag}-channel_{channel}-trial-{trial + 1}-start-target", + [round(estimate_bottom, 5)], + ) + report( + "ENCODER", + f"encoder-volume-{vol_in_tag}-channel_{channel}-trial-{trial + 1}-start-encoder", + [round(encoder_bottom, 5)], + ) + report( + "ENCODER", + f"encoder-volume-{vol_in_tag}-channel_{channel}-trial-{trial + 1}-start-drift", + [round(encoder_bottom - estimate_bottom, 5)], + ) + report( + "ENCODER", + f"encoder-volume-{vol_in_tag}-channel_{channel}-trial-{trial + 1}-end-target", + [round(estimate_aspirated, 5)], + ) + report( + "ENCODER", + f"encoder-volume-{vol_in_tag}-channel_{channel}-trial-{trial + 1}-end-encoder", + [round(encoder_aspirated, 5)], + ) + report( + "ENCODER", + f"encoder-volume-{vol_in_tag}-channel_{channel}-trial-{trial + 1}-end-drift", + [round(encoder_aspirated - estimate_aspirated, 5)], ) @@ -442,6 +539,7 @@ def store_trial( channel: int, aspirate: float, dispense: float, + liquid_height: float, ) -> None: """Report trial.""" vol_in_tag = str(round(volume, 2)) @@ -455,6 +553,11 @@ def store_trial( f"trial-{trial + 1}-dispense-{vol_in_tag}-ul-channel_{channel + 1}", [dispense], ) + report( + "TRIALS", + f"trial-{trial + 1}-liquid_height-{vol_in_tag}-ul-channel_{channel + 1}", + [liquid_height], + ) def store_average_evaporation( diff --git a/hardware-testing/hardware_testing/gravimetric/tips.py b/hardware-testing/hardware_testing/gravimetric/tips.py index 34ca135774a..520a959cd77 100644 --- a/hardware-testing/hardware_testing/gravimetric/tips.py +++ b/hardware-testing/hardware_testing/gravimetric/tips.py @@ -35,24 +35,60 @@ MULTI_CHANNEL_TEST_ORDER = [0, 1, 2, 3, 7, 6, 5, 4] # zero indexed CHANNEL_TO_TIP_ROW_LOOKUP = { # zero indexed + 0: "G", + 1: "F", + 2: "D", + 3: "A", + 4: "H", + 5: "E", + 6: "C", + 7: "B", +} +CHANNEL_TO_TIP_ROW_LOOKUP_BACK = { # zero indexed 0: "H", 1: "G", 2: "E", 3: "B", - 4: "G", + 4: "F", 5: "D", 6: "B", 7: "A", } -CHANNEL_TO_SLOT_ROW_LOOKUP = { # zero indexed - 0: "B", - 1: "B", - 2: "B", - 3: "B", - 4: "C", - 5: "C", - 6: "C", - 7: "C", +CHANNEL_TO_TIP_ROW_LOOKUP_BY_SLOT = { + "1": CHANNEL_TO_TIP_ROW_LOOKUP, + "2": CHANNEL_TO_TIP_ROW_LOOKUP, + "3": CHANNEL_TO_TIP_ROW_LOOKUP, + "4": CHANNEL_TO_TIP_ROW_LOOKUP, + "5": CHANNEL_TO_TIP_ROW_LOOKUP, + "6": CHANNEL_TO_TIP_ROW_LOOKUP, + "7": CHANNEL_TO_TIP_ROW_LOOKUP, + "8": CHANNEL_TO_TIP_ROW_LOOKUP, + "9": CHANNEL_TO_TIP_ROW_LOOKUP, + "10": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, + "11": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, + "12": CHANNEL_TO_TIP_ROW_LOOKUP_BACK, +} +REAR_CHANNELS = [0, 1, 2, 3] +FRONT_CHANNELS = [4, 5, 6, 7] +REAR_CHANNELS_TIP_SLOTS = { + 50: { + 50: [2, 3, 5], + }, + 1000: { + 50: [2, 7], + 200: [10], + 1000: [3], + }, +} +FRONT_CHANNELS_TIP_SLOTS = { + 50: { + 50: [8, 9, 6], + }, + 1000: { + 50: [8, 6], + 200: [5], + 1000: [9], + }, } @@ -64,39 +100,61 @@ def _get_racks(ctx: ProtocolContext) -> Dict[int, Labware]: } +def _unused_tips_for_racks(racks: List[Labware]) -> List[Well]: + wells: List[Well] = [] + rows = "ABCDEFGH" + for rack in racks: + for col in range(1, 13): + for row in rows: + wellname = f"{row}{col}" + next_well = rack.next_tip(1, rack[wellname]) + if next_well is not None and wellname == next_well.well_name: + wells.append(rack[wellname]) + return wells + + +def get_unused_tips(ctx: ProtocolContext, tip_volume: int) -> List[Well]: + """Use the labware's tip tracker to get a list of all unused tips for a given tip volume.""" + racks = [ + r for r in _get_racks(ctx).values() if r.wells()[0].max_volume == tip_volume + ] + return _unused_tips_for_racks(racks) + + def get_tips_for_single(ctx: ProtocolContext, tip_volume: int) -> List[Well]: """Get tips for single channel.""" - racks = _get_racks(ctx) - return [ - tip - for rack in racks.values() - for tip in rack.wells() - if tip.max_volume == tip_volume - ] + return get_unused_tips(ctx, tip_volume) def get_tips_for_individual_channel_on_multi( - ctx: ProtocolContext, channel: int + ctx: ProtocolContext, channel: int, tip_volume: int, pipette_volume: int ) -> List[Well]: """Get tips for a multi's channel.""" - racks = _get_racks(ctx) - slot_row = CHANNEL_TO_SLOT_ROW_LOOKUP[channel] - tip_row = CHANNEL_TO_TIP_ROW_LOOKUP[channel] - # FIXME: need custom deck to support 3x racks horizontally - slots = [5, 6] if slot_row == "B" else [8, 9] + print(f"getting {tip_volume} tips for channel {channel}") + if channel in FRONT_CHANNELS: + slots = FRONT_CHANNELS_TIP_SLOTS[pipette_volume][tip_volume] + else: + slots = REAR_CHANNELS_TIP_SLOTS[pipette_volume][tip_volume] + print(f"Slots for this channel/tip {slots}") + all_racks = _get_racks(ctx) + specific_racks: List[Labware] = [] + for slot in slots: + specific_racks.append(all_racks[slot]) + unused_tips = _unused_tips_for_racks(specific_racks) tips = [ tip - for slot in slots - for tip in racks[slot].wells() - if tip.well_name[0] == tip_row + for tip in unused_tips + if tip.well_name[0] + == CHANNEL_TO_TIP_ROW_LOOKUP_BY_SLOT[tip.parent.parent][channel] # type: ignore[index] ] return tips -def get_tips_for_all_channels_on_multi(ctx: ProtocolContext) -> List[Well]: +def get_tips_for_all_channels_on_multi(ctx: ProtocolContext, tip: int) -> List[Well]: """Get tips for all the multi's channels.""" - racks = _get_racks(ctx) - return [rack[f"A{col + 1}"] for _, rack in racks.items() for col in range(12)] + racks = [rack for _, rack in _get_racks(ctx).items() if f"{tip}ul" in rack.name] + assert racks, f"no {tip}ul racks found" + return [rack[f"A{col + 1}"] for rack in racks for col in range(12)] def get_tips_for_96_channel(ctx: ProtocolContext) -> List[Well]: @@ -116,10 +174,12 @@ def get_tips( return {0: get_tips_for_single(ctx, tip_volume)} elif pipette.channels == 8: if all_channels: - return {0: get_tips_for_all_channels_on_multi(ctx)} + return {0: get_tips_for_all_channels_on_multi(ctx, tip_volume)} else: return { - channel: get_tips_for_individual_channel_on_multi(ctx, channel) + channel: get_tips_for_individual_channel_on_multi( + ctx, channel, tip_volume, int(pipette.max_volume) + ) for channel in range(pipette.channels) } elif pipette.channels == 96: diff --git a/hardware-testing/hardware_testing/gravimetric/trial.py b/hardware-testing/hardware_testing/gravimetric/trial.py index 936b3c25b32..72d370930c2 100644 --- a/hardware-testing/hardware_testing/gravimetric/trial.py +++ b/hardware-testing/hardware_testing/gravimetric/trial.py @@ -22,12 +22,13 @@ class VolumetricTrial: pipette: InstrumentContext test_report: CSVReport liquid_tracker: LiquidTracker - inspect: bool trial: int + channel_count: int tip_volume: int volume: float mix: bool acceptable_cv: Optional[float] + acceptable_d: Optional[float] env_sensor: asair_sensor.AsairSensorBase @@ -38,12 +39,12 @@ class GravimetricTrial(VolumetricTrial): well: Well channel_offset: Point channel: int - channel_count: int recorder: GravimetricRecorder blank: bool stable: bool cfg: config.GravimetricConfig scale_delay: int = DELAY_FOR_MEASUREMENT + mode: str = "" @dataclass @@ -68,17 +69,12 @@ class TestResources: ctx: ProtocolContext pipette: InstrumentContext - pipette_tag: str tipracks: List[Labware] test_volumes: List[float] - run_id: str - start_time: float - operator_name: str - robot_serial: str - tip_batch: str - git_description: str tips: Dict[int, List[Well]] env_sensor: asair_sensor.AsairSensorBase + recorder: Optional[GravimetricRecorder] + test_report: CSVReport def build_gravimetric_trials( @@ -118,25 +114,44 @@ def build_gravimetric_trials( test_report=test_report, liquid_tracker=liquid_tracker, blank=blank, - inspect=cfg.inspect, mix=cfg.mix, stable=True, scale_delay=cfg.scale_delay, acceptable_cv=None, + acceptable_d=None, cfg=cfg, env_sensor=env_sensor, + mode=cfg.mode, ) ) else: for volume in test_volumes: + if cfg.isolate_volumes and (volume not in cfg.isolate_volumes): + ui.print_info(f"skipping volume: {volume} ul") + continue trial_list[volume] = {} for channel in channels_to_test: - if cfg.isolate_channels and (channel + 1) not in cfg.isolate_channels: + vls_list = config.QC_VOLUMES_G[cfg.pipette_channels][cfg.pipette_volume] + standard_qc_volumes = [ + vls for t, vls in vls_list if t == cfg.tip_volume + ][-1] + print(standard_qc_volumes) + if ( + cfg.isolate_channels and (channel + 1) not in cfg.isolate_channels + ) or (channel > 0 and volume not in standard_qc_volumes): ui.print_info(f"skipping channel {channel + 1}") continue trial_list[volume][channel] = [] channel_offset = helpers._get_channel_offset(cfg, channel) for trial in range(cfg.trials): + d: Optional[float] = None + cv: Optional[float] = None + if not cfg.increment: + d, cv = config.QC_TEST_MIN_REQUIREMENTS[cfg.pipette_channels][ + cfg.pipette_volume + ][cfg.tip_volume][volume] + d = d * (1 - config.QC_TEST_SAFETY_FACTOR) + cv = cv * (1 - config.QC_TEST_SAFETY_FACTOR) trial_list[volume][channel].append( GravimetricTrial( ctx=ctx, @@ -152,13 +167,14 @@ def build_gravimetric_trials( test_report=test_report, liquid_tracker=liquid_tracker, blank=blank, - inspect=cfg.inspect, mix=cfg.mix, stable=True, scale_delay=cfg.scale_delay, - acceptable_cv=None, + acceptable_cv=cv, + acceptable_d=d, cfg=cfg, env_sensor=env_sensor, + mode=cfg.mode, ) ) return trial_list @@ -168,7 +184,7 @@ def build_photometric_trials( ctx: ProtocolContext, test_report: CSVReport, pipette: InstrumentContext, - source: Well, + sources: List[Well], dest: Labware, test_volumes: List[float], liquid_tracker: LiquidTracker, @@ -176,25 +192,38 @@ def build_photometric_trials( env_sensor: asair_sensor.AsairSensorBase, ) -> Dict[float, List[PhotometricTrial]]: """Build a list of all the trials that will be run.""" - trial_list: Dict[float, List[PhotometricTrial]] = {} + trial_list: Dict[float, List[PhotometricTrial]] = {vol: [] for vol in test_volumes} + total_trials = len(test_volumes) * cfg.trials + trials_per_src = int(total_trials / len(sources)) + sources_per_trials = [src for src in sources for _ in range(trials_per_src)] + print("sources per trial") + print(sources_per_trials) for volume in test_volumes: - trial_list[volume] = [] for trial in range(cfg.trials): + d: Optional[float] = None + cv: Optional[float] = None + if not cfg.increment: + d, cv = config.QC_TEST_MIN_REQUIREMENTS[cfg.pipette_channels][ + cfg.pipette_volume + ][cfg.tip_volume][volume] + d = d * (1 - config.QC_TEST_SAFETY_FACTOR) + cv = cv * (1 - config.QC_TEST_SAFETY_FACTOR) trial_list[volume].append( PhotometricTrial( ctx=ctx, test_report=test_report, pipette=pipette, - source=source, + source=sources_per_trials.pop(0), dest=dest, tip_volume=cfg.tip_volume, + channel_count=cfg.pipette_channels, volume=volume, trial=trial, liquid_tracker=liquid_tracker, - inspect=cfg.inspect, cfg=cfg, mix=cfg.mix, - acceptable_cv=None, + acceptable_cv=cv, + acceptable_d=d, env_sensor=env_sensor, ) ) @@ -206,11 +235,16 @@ def _finish_test( resources: TestResources, return_tip: bool, ) -> None: - ui.print_title("CHANGE PIPETTES") - if resources.pipette.has_tip: + # there are WAY too many tips on a 96ch pipette + # so drop them incase something bad happened during the test run + if resources.pipette.channels == 96 and resources.pipette.has_tip: + resources.ctx.home() if resources.pipette.current_volume > 0: ui.print_info("dispensing liquid to trash") trash = resources.pipette.trash_container.wells()[0] + dispense_location = trash.top() + if resources.pipette.channels == 96: + dispense_location = dispense_location.move(Point(-36.0, -25.5, 0)) # FIXME: this should be a blow_out() at max volume, # but that is not available through PyAPI yet # so instead just dispensing. @@ -218,7 +252,12 @@ def _finish_test( resources.pipette.aspirate(10) # to pull any droplets back up ui.print_info("dropping tip") helpers._drop_tip(resources.pipette, return_tip) + + +def _change_pipettes( + ctx: ProtocolContext, + pipette: InstrumentContext, +) -> None: + ui.print_title("CHANGE PIPETTES") ui.print_info("moving to attach position") - resources.pipette.move_to( - resources.ctx.deck.position_for(5).move(Point(x=0, y=9 * 7, z=150)) - ) + pipette.move_to(ctx.deck.position_for(5).move(Point(x=0, y=9 * 7, z=150))) diff --git a/hardware-testing/hardware_testing/gravimetric/workarounds.py b/hardware-testing/hardware_testing/gravimetric/workarounds.py index 98da9e90ce8..0d2c425d830 100644 --- a/hardware-testing/hardware_testing/gravimetric/workarounds.py +++ b/hardware-testing/hardware_testing/gravimetric/workarounds.py @@ -1,11 +1,10 @@ """Opentrons API Workarounds.""" -from atexit import register as atexit_register from datetime import datetime from urllib.request import Request, urlopen from typing import List import platform from json import loads as json_loads - +from hardware_testing.data import ui from opentrons.hardware_control import SyncHardwareAPI from opentrons.protocol_api.labware import Labware from opentrons.protocol_api import InstrumentContext, ProtocolContext @@ -44,7 +43,6 @@ def http_get_all_labware_offsets() -> List[dict]: runs_response = urlopen(req) runs_response_data = runs_response.read() stop_server_ot3() - atexit_register(start_server_ot3) runs_json = json_loads(runs_response_data) protocols_list = runs_json["data"] @@ -84,7 +82,8 @@ def _offset_applies_to_labware(_o: dict) -> bool: if _o["location"]["slotName"] != lw_slot: return False offset_uri = _o["definitionUri"] - if offset_uri != lw_uri: + if offset_uri[0:-1] != lw_uri[0:-1]: # drop schema version number + ui.print_info(f"{_o} does not apply {offset_uri} != {lw_uri}") # NOTE: we're allowing tip-rack adapters to share offsets # because it doesn't make a difference which volume # of tip it holds diff --git a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py index 287d5bca125..66f73e275b0 100644 --- a/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py +++ b/hardware-testing/hardware_testing/opentrons_api/helpers_ot3.py @@ -609,10 +609,7 @@ async def move_tip_motor_relative_ot3( tip_motor_move = api._build_moves(current_gear_pos_dict, target_pos_dict) - _move_coro = api._backend.tip_action( - moves=tip_motor_move[0], - tip_action="clamp", - ) + _move_coro = api._backend.tip_action(moves=tip_motor_move[0]) if motor_current is None: await _move_coro else: diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py index 7da282fc77a..d7d5ce72cb2 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_force.py @@ -26,7 +26,7 @@ GRIP_FORCES_NEWTON: List[int] = [20, 15, 10, 5] NUM_NEWTONS_TRIALS = 1 -FAILURE_THRESHOLD_PERCENTAGES = [10, 10, 10, 20] +FAILURE_THRESHOLD_PERCENTAGES = [20, 20, 20, 40] WARMUP_SECONDS = 10 diff --git a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py index 1bc9a2247c9..70501f6b497 100644 --- a/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py +++ b/hardware-testing/hardware_testing/production_qc/gripper_assembly_qc_ot3/test_probe.py @@ -146,7 +146,9 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: # move to 5 mm above the deck await api.move_to(mount, probe_pos._replace(z=PROBE_PREP_HEIGHT_MM)) z_ax = Axis.by_mount(mount) - found_pos = await api.capacitive_probe(mount, z_ax, probe_pos.z, pass_settings) + found_pos, _ = await api.capacitive_probe( + mount, z_ax, probe_pos.z, pass_settings + ) print(f"Found deck height: {found_pos}") # check against max overrun diff --git a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py index 2cb5d38b701..0689e23d492 100644 --- a/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py +++ b/hardware-testing/hardware_testing/production_qc/ninety_six_assembly_qc_ot3/test_capacitance.py @@ -167,7 +167,7 @@ async def run(api: OT3API, report: CSVReport, section: str) -> None: async def _probe(distance: float, speed: float) -> float: if api.is_simulator: return 0.0 - pos, _ = await capacitive_probe( + pos = await capacitive_probe( api._backend._messenger, # type: ignore[union-attr] NodeId.pipette_left, NodeId.head_l, @@ -176,7 +176,7 @@ async def _probe(distance: float, speed: float) -> float: sensor_id=sensor_id, relative_threshold_pf=default_probe_cfg.sensor_threshold_pf, ) - return pos + return pos.motor_position if not api.is_simulator: ui.get_user_ready("about to probe the DECK") diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py index 7633428c41c..f64d6b79a41 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py @@ -16,10 +16,15 @@ PushTipPresenceNotification, ) from opentrons_hardware.firmware_bindings.messages.messages import MessageDefinition -from opentrons_hardware.firmware_bindings.constants import SensorType +from opentrons_hardware.firmware_bindings.constants import SensorType, SensorId from opentrons.config.types import LiquidProbeSettings -from opentrons.hardware_control.types import TipStateType, FailedTipStateCheck +from opentrons.hardware_control.types import ( + TipStateType, + FailedTipStateCheck, + SubSystem, + InstrumentProbeType, +) from opentrons.hardware_control.ot3api import OT3API from opentrons.hardware_control.ot3_calibration import ( calibrate_pipette, @@ -42,6 +47,7 @@ PressureEvent, PressureEventConfig, PRESSURE_FIXTURE_INSERT_DEPTH, + PRESSURE_ASPIRATE_DELTA_SPEC, ) from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.opentrons_api.types import ( @@ -59,11 +65,12 @@ DEFAULT_SLOT_PLATE = 2 DEFAULT_SLOT_TRASH = 12 -PROBING_DECK_PRECISION_MM = 0.1 +PROBING_DECK_PRECISION_MM = 1.0 TRASH_HEIGHT_MM: Final = 45 LEAK_HOVER_ABOVE_LIQUID_MM: Final = 50 ASPIRATE_SUBMERGE_MM: Final = 3 +TRAILING_AIR_GAP_DROPLETS_UL: Final = 0.5 # FIXME: reduce this spec after dial indicator is implemented LIQUID_PROBE_ERROR_THRESHOLD_PRECISION_MM = 0.4 @@ -123,7 +130,8 @@ class LabwareLocations: tip_rack_200: Optional[Point] tip_rack_50: Optional[Point] reservoir: Optional[Point] - plate: Optional[Point] + plate_primary: Optional[Point] + plate_secondary: Optional[Point] fixture: Optional[Point] @@ -135,7 +143,8 @@ class LabwareLocations: tip_rack_200=None, tip_rack_50=None, reservoir=None, - plate=None, + plate_primary=None, + plate_secondary=None, fixture=None, ) CALIBRATED_LABWARE_LOCATIONS: LabwareLocations = LabwareLocations( @@ -144,7 +153,8 @@ class LabwareLocations: tip_rack_200=None, tip_rack_50=None, reservoir=None, - plate=None, + plate_primary=None, + plate_secondary=None, fixture=None, ) @@ -155,22 +165,33 @@ class LabwareLocations: # THRESHOLDS: capacitive sensor CAP_THRESH_OPEN_AIR = { 1: [3.0, 7.0], - 8: [3.0, 7.0], # TODO: update for PVT multi build + 8: [8.0, 16.0], } CAP_THRESH_PROBE = { 1: [4.0, 8.0], - 8: [5.0, 20.0], # TODO: update for PVT multi build + 8: [8.0, 16.0], } CAP_THRESH_SQUARE = { 1: [8.0, 15.0], - 8: [0.0, 1000.0], # TODO: update for PVT multi build + 8: [18.0, 26.0], } # THRESHOLDS: air-pressure sensor -PRESSURE_ASPIRATE_VOL = {50: 10.0, 1000: 20.0} -PRESSURE_THRESH_OPEN_AIR = [-15, 15] -PRESSURE_THRESH_SEALED = [-50, 50] -PRESSURE_THRESH_COMPRESS = [-2600, 1600] +PRESSURE_ASPIRATE_VOL = {1: {50: 10.0, 1000: 20.0}, 8: {50: 10.0, 1000: 20.0}} +PRESSURE_THRESH_OPEN_AIR = {1: [-150, 150], 8: [-150, 150]} +PRESSURE_THRESH_SEALED = {1: [-500, 500], 8: [-200, 400]} +PRESSURE_THRESH_COMPRESS = {1: [-2600, 1600], 8: [-8000, 8000]} + +_trash_loc_counter = 0 +TRASH_OFFSETS = [ + Point(x=(64 * -0.75)), + Point(x=(64 * -0.5)), + Point(x=(64 * -0.25)), + Point(x=(64 * 0)), + Point(x=(64 * 0.25)), + Point(x=(64 * 0.5)), + Point(x=(64 * 0.75)), +] def _bool_to_pass_fail(result: bool) -> str: @@ -241,7 +262,10 @@ def _get_ideal_labware_locations( tip_rack_200=tip_rack_200_loc_ideal, tip_rack_50=tip_rack_50_loc_ideal, reservoir=reservoir_loc_ideal, - plate=plate_loc_ideal + Point(z=5), # give a few extra mm to help alignment + plate_primary=plate_loc_ideal + + Point(z=5), # give a few extra mm to help alignment + plate_secondary=plate_loc_ideal + + Point(z=5), # give a few extra mm to help alignment trash=trash_loc_ideal, fixture=fixture_loc_ideal, ) @@ -347,27 +371,49 @@ async def _move_to_reservoir_liquid(api: OT3API, mount: OT3Mount) -> None: ) -async def _move_to_plate_liquid(api: OT3API, mount: OT3Mount) -> None: - CALIBRATED_LABWARE_LOCATIONS.plate = await _move_to_or_calibrate( - api, - mount, - IDEAL_LABWARE_LOCATIONS.plate, - CALIBRATED_LABWARE_LOCATIONS.plate, - ) +async def _move_to_plate_liquid( + api: OT3API, mount: OT3Mount, probe: InstrumentProbeType +) -> None: + if probe == InstrumentProbeType.PRIMARY: + CALIBRATED_LABWARE_LOCATIONS.plate_primary = await _move_to_or_calibrate( + api, + mount, + IDEAL_LABWARE_LOCATIONS.plate_primary, + CALIBRATED_LABWARE_LOCATIONS.plate_primary, + ) + else: + # move towards back of machine, so 8th channel can reach well + CALIBRATED_LABWARE_LOCATIONS.plate_secondary = await _move_to_or_calibrate( + api, + mount, + IDEAL_LABWARE_LOCATIONS.plate_secondary + Point(y=9 * 7), # type: ignore[operator] + CALIBRATED_LABWARE_LOCATIONS.plate_secondary, + ) async def _move_to_above_plate_liquid( - api: OT3API, mount: OT3Mount, height_mm: float + api: OT3API, mount: OT3Mount, probe: InstrumentProbeType, height_mm: float ) -> None: - assert ( - CALIBRATED_LABWARE_LOCATIONS.plate - ), "you must calibrate the liquid before hovering" - await _move_to_or_calibrate( - api, - mount, - IDEAL_LABWARE_LOCATIONS.plate, - CALIBRATED_LABWARE_LOCATIONS.plate + Point(z=height_mm), - ) + if probe == InstrumentProbeType.PRIMARY: + assert ( + CALIBRATED_LABWARE_LOCATIONS.plate_primary + ), "you must calibrate the liquid before hovering" + await _move_to_or_calibrate( + api, + mount, + IDEAL_LABWARE_LOCATIONS.plate_primary, + CALIBRATED_LABWARE_LOCATIONS.plate_primary + Point(z=height_mm), + ) + else: + assert ( + CALIBRATED_LABWARE_LOCATIONS.plate_secondary + ), "you must calibrate the liquid before hovering" + await _move_to_or_calibrate( + api, + mount, + IDEAL_LABWARE_LOCATIONS.plate_secondary, + CALIBRATED_LABWARE_LOCATIONS.plate_secondary + Point(z=height_mm), + ) async def _move_to_fixture(api: OT3API, mount: OT3Mount) -> None: @@ -381,12 +427,17 @@ async def _move_to_fixture(api: OT3API, mount: OT3Mount) -> None: async def _drop_tip_in_trash(api: OT3API, mount: OT3Mount) -> None: + global _trash_loc_counter # assume the ideal is accurate enough ideal = IDEAL_LABWARE_LOCATIONS.trash assert ideal + random_trash_pos = ideal + TRASH_OFFSETS[_trash_loc_counter] + _trash_loc_counter = (_trash_loc_counter + 1) % len(TRASH_OFFSETS) current_pos = await api.gantry_position(mount) - safe_height = max(ideal.z, current_pos.z) + SAFE_HEIGHT_TRAVEL - await helpers_ot3.move_to_arched_ot3(api, mount, ideal, safe_height=safe_height) + safe_height = max(random_trash_pos.z, current_pos.z) + SAFE_HEIGHT_TRAVEL + await helpers_ot3.move_to_arched_ot3( + api, mount, random_trash_pos, safe_height=safe_height + ) await api.drop_tip(mount, home_after=False) @@ -398,8 +449,9 @@ async def _aspirate_and_look_for_droplets( pipette_volume = pip.working_volume print(f"aspirating {pipette_volume} microliters") await api.move_rel(mount, Point(z=-ASPIRATE_SUBMERGE_MM)) - await api.aspirate(mount, pipette_volume) + await api.aspirate(mount, pipette_volume - TRAILING_AIR_GAP_DROPLETS_UL) await api.move_rel(mount, Point(z=LEAK_HOVER_ABOVE_LIQUID_MM)) + await api.aspirate(mount, TRAILING_AIR_GAP_DROPLETS_UL) for t in range(wait_time): print(f"waiting for leaking tips ({t + 1}/{wait_time})") if not api.is_simulator: @@ -431,12 +483,15 @@ def _connect_to_fixture(test_config: TestConfig) -> PressureFixture: async def _read_pressure_and_check_results( api: OT3API, + pipette_channels: int, + pipette_volume: int, fixture: PressureFixture, tag: PressureEvent, write_cb: Callable, accumulate_raw_data_cb: Callable, channels: int = 1, -) -> bool: + previous: Optional[List[List[float]]] = None, +) -> Tuple[bool, List[List[float]]]: pressure_event_config: PressureEventConfig = PRESSURE_CFG[tag] if not api.is_simulator: await asyncio.sleep(pressure_event_config.stability_delay) @@ -455,15 +510,32 @@ async def _read_pressure_and_check_results( and delay_time > 0 ): await asyncio.sleep(pressure_event_config.sample_delay) - _samples_channel_1 = [s[c] for s in _samples for c in range(channels)] - _samples_channel_1.sort() - _samples_clipped = _samples_channel_1[1:-1] - _samples_min = min(_samples_clipped) - _samples_max = max(_samples_clipped) - if _samples_max - _samples_min > pressure_event_config.stability_threshold: - test_pass_stability = False - else: - test_pass_stability = True + _samples_per_channel = [[s[c] for s in _samples] for c in range(channels)] + _average_per_channel = [sum(s) / len(s) for s in _samples_per_channel] + test_pass_stability = True + for c in range(channels): + _samples_per_channel[c].sort() + _c_min = min(_samples_per_channel[c][1:]) + _c_max = max(_samples_per_channel[c][1:]) + csv_data_min = [f"pressure-{tag.value}-channel-{c + 1}", "min", _c_min] + print(csv_data_min) + write_cb(csv_data_min) + csv_data_max = [f"pressure-{tag.value}-channel-{c + 1}", "max", _c_max] + print(csv_data_max) + write_cb(csv_data_max) + csv_data_avg = [ + f"pressure-{tag.value}-channel-{c + 1}", + "average", + _average_per_channel[c], + ] + print(csv_data_avg) + write_cb(csv_data_avg) + if _c_max - _c_min > pressure_event_config.stability_threshold: + print( + f"ERROR: channel {c + 1} samples are too far apart, " + f"max={round(_c_max, 2)} and min={round(_c_min, 2)}" + ) + test_pass_stability = False csv_data_stability = [ f"pressure-{tag.value}", "stability", @@ -471,10 +543,18 @@ async def _read_pressure_and_check_results( ] print(csv_data_stability) write_cb(csv_data_stability) + _all_samples = [s[c] for s in _samples for c in range(channels)] + _all_samples.sort() + _samples_min = min(_all_samples[1:-1]) + _samples_max = max(_all_samples[1:-1]) if ( _samples_min < pressure_event_config.min or _samples_max > pressure_event_config.max ): + print( + f"ERROR: samples are out of range, " + f"max={round(_samples_max, 2)} and min={round(_samples_min, 2)}" + ) test_pass_accuracy = False else: test_pass_accuracy = True @@ -485,7 +565,34 @@ async def _read_pressure_and_check_results( ] print(csv_data_accuracy) write_cb(csv_data_accuracy) - return test_pass_stability and test_pass_accuracy + test_pass_delta = True + if previous: + assert len(previous[-1]) >= len(_average_per_channel) + for c in range(channels): + _delta_target = PRESSURE_ASPIRATE_DELTA_SPEC[pipette_channels][ + pipette_volume + ]["delta"] + _delta_margin = PRESSURE_ASPIRATE_DELTA_SPEC[pipette_channels][ + pipette_volume + ]["margin"] + _delta_min = _delta_target - (_delta_target * _delta_margin) + _delta_max = _delta_target + (_delta_target * _delta_margin) + _delta = abs(_average_per_channel[c] - previous[-1][c]) # absolute value + if _delta < _delta_min or _delta > _delta_max: + print( + f"ERROR: channel {c + 1} pressure delta ({_delta}) " + f"out of range: max={_delta_max}, min={_delta_min}" + ) + test_pass_delta = False + csv_data_delta = [ + f"pressure-{tag.value}", + "delta", + _bool_to_pass_fail(test_pass_delta), + ] + print(csv_data_delta) + write_cb(csv_data_delta) + _passed = test_pass_stability and test_pass_accuracy and test_pass_delta + return _passed, _samples async def _fixture_check_pressure( @@ -502,16 +609,25 @@ async def _fixture_check_pressure( pip_vol = int(pip.working_volume) pip_channels = int(pip.channels) # above the fixture - r = await _read_pressure_and_check_results( - api, fixture, PressureEvent.PRE, write_cb, accumulate_raw_data_cb, pip_channels + r, _ = await _read_pressure_and_check_results( + api, + pip_channels, + pip_vol, + fixture, + PressureEvent.PRE, + write_cb, + accumulate_raw_data_cb, + pip_channels, ) results.append(r) # insert into the fixture # NOTE: unknown amount of pressure here (depends on where Z was calibrated) fixture_depth = PRESSURE_FIXTURE_INSERT_DEPTH[pip_vol] await api.move_rel(mount, Point(z=-fixture_depth)) - r = await _read_pressure_and_check_results( + r, inserted_pressure_data = await _read_pressure_and_check_results( api, + pip_channels, + pip_vol, fixture, PressureEvent.INSERT, write_cb, @@ -525,14 +641,24 @@ async def _fixture_check_pressure( asp_evt = PressureEvent.ASPIRATE_P50 else: asp_evt = PressureEvent.ASPIRATE_P1000 - r = await _read_pressure_and_check_results( - api, fixture, asp_evt, write_cb, accumulate_raw_data_cb, pip_channels + r, _ = await _read_pressure_and_check_results( + api, + pip_channels, + pip_vol, + fixture, + asp_evt, + write_cb, + accumulate_raw_data_cb, + pip_channels, + previous=inserted_pressure_data, ) results.append(r) # dispense await api.dispense(mount, PRESSURE_FIXTURE_ASPIRATE_VOLUME[pip_vol]) - r = await _read_pressure_and_check_results( + r, _ = await _read_pressure_and_check_results( api, + pip_channels, + pip_vol, fixture, PressureEvent.DISPENSE, write_cb, @@ -542,8 +668,15 @@ async def _fixture_check_pressure( results.append(r) # retract out of fixture await api.move_rel(mount, Point(z=fixture_depth)) - r = await _read_pressure_and_check_results( - api, fixture, PressureEvent.POST, write_cb, accumulate_raw_data_cb, pip_channels + r, _ = await _read_pressure_and_check_results( + api, + pip_channels, + pip_vol, + fixture, + PressureEvent.POST, + write_cb, + accumulate_raw_data_cb, + pip_channels, ) results.append(r) return False not in results @@ -599,7 +732,11 @@ async def _test_for_leak_by_eye( async def _read_pipette_sensor_repeatedly_and_average( - api: OT3API, mount: OT3Mount, sensor_type: SensorType, num_readings: int + api: OT3API, + mount: OT3Mount, + sensor_type: SensorType, + num_readings: int, + sensor_id: SensorId, ) -> float: # FIXME: this while loop is required b/c the command does not always # return a value, not sure what's the source of this issue @@ -607,14 +744,18 @@ async def _read_pipette_sensor_repeatedly_and_average( while len(readings) < num_readings: try: if sensor_type == SensorType.capacitive: - r = await helpers_ot3.get_capacitance_ot3(api, mount) + r = await helpers_ot3.get_capacitance_ot3(api, mount, sensor_id) elif sensor_type == SensorType.pressure: - r = await helpers_ot3.get_pressure_ot3(api, mount) + r = await helpers_ot3.get_pressure_ot3(api, mount, sensor_id) elif sensor_type == SensorType.temperature: - res = await helpers_ot3.get_temperature_humidity_ot3(api, mount) + res = await helpers_ot3.get_temperature_humidity_ot3( + api, mount, sensor_id + ) r = res[0] elif sensor_type == SensorType.humidity: - res = await helpers_ot3.get_temperature_humidity_ot3(api, mount) + res = await helpers_ot3.get_temperature_humidity_ot3( + api, mount, sensor_id + ) r = res[1] else: raise ValueError(f"unexpected sensor type: {sensor_type}") @@ -660,7 +801,7 @@ def _get_room_humidity() -> float: # CELSIUS celsius = await _read_pipette_sensor_repeatedly_and_average( - api, mount, SensorType.temperature, 10 + api, mount, SensorType.temperature, 10, SensorId.S0 ) print(f"celsius: {celsius} C") if celsius < TEMP_THRESH[0] or celsius > TEMP_THRESH[1]: @@ -670,7 +811,7 @@ def _get_room_humidity() -> float: # HUMIDITY humidity = await _read_pipette_sensor_repeatedly_and_average( - api, mount, SensorType.humidity, 10 + api, mount, SensorType.humidity, 10, SensorId.S0 ) print(f"humidity: {humidity} C") if humidity < HUMIDITY_THRESH[0] or humidity > HUMIDITY_THRESH[1]: @@ -719,215 +860,279 @@ async def _get_plunger_pos_and_encoder() -> Tuple[float, float]: return encoder_home_pass and encoder_move_pass and encoder_stall_pass -async def _test_diagnostics_capacitive( +async def _test_diagnostics_capacitive( # noqa: C901 api: OT3API, mount: OT3Mount, write_cb: Callable ) -> bool: print("testing capacitance") - capacitive_open_air_pass = True - capacitive_probe_attached_pass = True - capacitive_square_pass = True - capacitive_probing_pass = True + results: List[bool] = [] pip = api.hardware_pipettes[mount.to_mount()] assert pip - - async def _read_cap() -> float: + sensor_ids = [SensorId.S0] + if pip.channels == 8: + sensor_ids.append(SensorId.S1) + sensor_to_probe = { + SensorId.S0: InstrumentProbeType.PRIMARY, + SensorId.S1: InstrumentProbeType.SECONDARY, + } + + async def _read_cap(_sensor_id: SensorId) -> float: return await _read_pipette_sensor_repeatedly_and_average( - api, mount, SensorType.capacitive, 10 + api, mount, SensorType.capacitive, 10, _sensor_id ) - capacitance_open_air = await _read_cap() - print(f"open-air capacitance: {capacitance_open_air}") - if ( - capacitance_open_air < CAP_THRESH_OPEN_AIR[pip.channels][0] - or capacitance_open_air > CAP_THRESH_OPEN_AIR[pip.channels][1] - ): - capacitive_open_air_pass = False - print(f"FAIL: open-air capacitance ({capacitance_open_air}) is not correct") - write_cb( - [ - "capacitive-open-air", - capacitance_open_air, - _bool_to_pass_fail(capacitive_open_air_pass), - ] - ) - - if not api.is_simulator: - _get_operator_answer_to_question('ATTACH the probe, enter "y" when attached') - capacitance_with_probe = await _read_cap() - print(f"probe capacitance: {capacitance_with_probe}") - if ( - capacitance_with_probe < CAP_THRESH_PROBE[pip.channels][0] - or capacitance_with_probe > CAP_THRESH_PROBE[pip.channels][1] - ): - capacitive_probe_attached_pass = False - print(f"FAIL: probe capacitance ({capacitance_with_probe}) is not correct") - write_cb( - [ - "capacitive-probe", - capacitance_with_probe, - _bool_to_pass_fail(capacitive_probe_attached_pass), - ] - ) + for sensor_id in sensor_ids: + capacitance = await _read_cap(sensor_id) + print(f"open-air {sensor_id.name} capacitance: {capacitance}") + if ( + capacitance < CAP_THRESH_OPEN_AIR[pip.channels][0] + or capacitance > CAP_THRESH_OPEN_AIR[pip.channels][1] + ): + results.append(False) + print( + f"FAIL: open-air {sensor_id.name} capacitance ({capacitance}) is not correct" + ) + else: + results.append(True) + write_cb( + [ + f"capacitive-open-air-{sensor_id.name}", + capacitance, + _bool_to_pass_fail(results[-1]), + ] + ) - offsets: List[Point] = [] - for trial in range(2): - print("probing deck slot #5") - if trial > 0 and not api.is_simulator: - input("`REINSTALL` the probe, press ENTER when ready: ") - await api.home() - if api.is_simulator: - pass - try: - await calibrate_pipette(api, mount, slot=5) # type: ignore[arg-type] - except ( - EdgeNotFoundError, - EarlyCapacitiveSenseTrigger, - CalibrationStructureNotFoundError, - ) as e: - print(f"ERROR: {e}") - write_cb([f"probe-slot-{trial}", None, None, None]) + for sensor_id in sensor_ids: + if not api.is_simulator: + if pip.channels == 1: + _get_operator_answer_to_question( + 'ATTACH the probe, enter "y" when attached' + ) + elif sensor_id == SensorId.S0: + _get_operator_answer_to_question( + 'ATTACH the REAR probe, enter "y" when attached' + ) + else: + _get_operator_answer_to_question( + 'ATTACH the FRONT probe, enter "y" when attached' + ) + capacitance = await _read_cap(sensor_id) + print(f"probe {sensor_id.name} capacitance: {capacitance}") + if ( + capacitance < CAP_THRESH_PROBE[pip.channels][0] + or capacitance > CAP_THRESH_PROBE[pip.channels][1] + ): + results.append(False) + print(f"FAIL: probe capacitance ({capacitance}) is not correct") else: - pip = api.hardware_pipettes[mount.to_mount()] - assert pip - o = pip.pipette_offset.offset - print(f"found offset: {o}") - write_cb( - [f"probe-slot-{trial}", round(o.x, 2), round(o.y, 2), round(o.z, 2)] + results.append(True) + write_cb( + [ + f"capacitive-probe-{sensor_id.name}", + capacitance, + _bool_to_pass_fail(results[-1]), + ] + ) + + offsets: List[Point] = [] + for trial in range(2): + print("probing deck slot #5") + if trial > 0 and not api.is_simulator: + input("`REINSTALL` the probe, press ENTER when ready: ") + await api.home() + if api.is_simulator: + pass + try: + probe = sensor_to_probe[sensor_id] + await calibrate_pipette(api, mount, slot=5, probe=probe) # type: ignore[arg-type] + except ( + EdgeNotFoundError, + EarlyCapacitiveSenseTrigger, + CalibrationStructureNotFoundError, + ) as e: + print(f"ERROR: {e}") + write_cb([f"probe-slot-{sensor_id.name}-{trial}", None, None, None]) + else: + pip = api.hardware_pipettes[mount.to_mount()] + assert pip + o = pip.pipette_offset.offset + print(f"found offset: {o}") + write_cb( + [ + f"probe-slot-{sensor_id.name}-{trial}", + round(o.x, 2), + round(o.y, 2), + round(o.z, 2), + ] + ) + offsets.append(o) + await api.retract(mount) + if ( + not api.is_simulator + and len(offsets) > 1 + and ( + abs(offsets[0].x - offsets[1].x) <= PROBING_DECK_PRECISION_MM + and abs(offsets[0].y - offsets[1].y) <= PROBING_DECK_PRECISION_MM + and abs(offsets[0].z - offsets[1].z) <= PROBING_DECK_PRECISION_MM ) - offsets.append(o) - await api.retract(mount) - if ( - not api.is_simulator - and len(offsets) > 1 - and ( - abs(offsets[0].x - offsets[1].x) < PROBING_DECK_PRECISION_MM - and abs(offsets[0].x - offsets[1].x) < PROBING_DECK_PRECISION_MM - and abs(offsets[0].x - offsets[1].x) < PROBING_DECK_PRECISION_MM + ): + results.append(True) + else: + results.append(False) + probe_slot_result = _bool_to_pass_fail(results[-1]) + print(f"probe-slot-{sensor_id.name}-result: {probe_slot_result}") + write_cb([f"capacitive-probe-{sensor_id.name}-slot-result", probe_slot_result]) + + if len(offsets) > 1: + probe_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) + probe_pos += Point(13, 13, 0) + if sensor_id == SensorId.S1: + probe_pos += Point(x=0, y=9 * 7, z=0) + await api.add_tip(mount, api.config.calibration.probe_length) + print(f"Moving to: {probe_pos}") + # start probe 5mm above deck + _probe_start_mm = probe_pos.z + 5 + current_pos = await api.gantry_position(mount) + if current_pos.z < _probe_start_mm: + await api.move_to(mount, current_pos._replace(z=_probe_start_mm)) + current_pos = await api.gantry_position(mount) + await api.move_to(mount, probe_pos._replace(z=current_pos.z)) + await api.move_to(mount, probe_pos) + capacitance = await _read_cap(sensor_id) + print(f"square capacitance {sensor_id.name}: {capacitance}") + if ( + capacitance < CAP_THRESH_SQUARE[pip.channels][0] + or capacitance > CAP_THRESH_SQUARE[pip.channels][1] + ): + results.append(False) + print(f"FAIL: square capacitance ({capacitance}) is not correct") + else: + results.append(True) + else: + results.append(False) + write_cb( + [ + f"capacitive-square-{sensor_id.name}", + capacitance, + _bool_to_pass_fail(results[-1]), + ] ) - ): - probe_slot_result = _bool_to_pass_fail(True) - else: - probe_slot_result = _bool_to_pass_fail(False) - print(f"probe-slot-result: {probe_slot_result}") - - probe_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) - probe_pos += Point(13, 13, 0) - await api.add_tip(mount, api.config.calibration.probe_length) - print(f"Moving to: {probe_pos}") - # start probe 5mm above deck - _probe_start_mm = probe_pos.z + 5 - current_pos = await api.gantry_position(mount) - if current_pos.z < _probe_start_mm: - await api.move_to(mount, current_pos._replace(z=_probe_start_mm)) - current_pos = await api.gantry_position(mount) - await api.move_to(mount, probe_pos._replace(z=current_pos.z)) - await api.move_to(mount, probe_pos) - capacitance_with_square = await _read_cap() - print(f"square capacitance: {capacitance_with_square}") - if ( - capacitance_with_square < CAP_THRESH_SQUARE[pip.channels][0] - or capacitance_with_square > CAP_THRESH_SQUARE[pip.channels][1] - ): - capacitive_square_pass = False - print(f"FAIL: square capacitance ({capacitance_with_square}) is not correct") - write_cb( - [ - "capacitive-square", - capacitance_with_square, - _bool_to_pass_fail(capacitive_square_pass), - ] - ) - await api.home_z(mount) - await api.remove_tip(mount) + await api.home_z(mount) + if not api.is_simulator: + _get_operator_answer_to_question('REMOVE the probe, enter "y" when removed') + await api.remove_tip(mount) - if not api.is_simulator: - _get_operator_answer_to_question('REMOVE the probe, enter "y" when removed') - return ( - capacitive_open_air_pass - and capacitive_probe_attached_pass - and capacitive_probing_pass - and capacitive_square_pass - ) + return all(results) async def _test_diagnostics_pressure( api: OT3API, mount: OT3Mount, write_cb: Callable ) -> bool: + print("testing pressure") + results: List[bool] = [] + pip = api.hardware_pipettes[mount.to_mount()] + assert pip + pip_channels = int(pip.channels) + sensor_ids = [SensorId.S0] + if pip.channels == 8: + sensor_ids.append(SensorId.S1) await api.add_tip(mount, 0.1) await api.prepare_for_aspirate(mount) await api.remove_tip(mount) - async def _read_pressure() -> float: + async def _read_pressure(_sensor_id: SensorId) -> float: return await _read_pipette_sensor_repeatedly_and_average( - api, mount, SensorType.pressure, 10 + api, mount, SensorType.pressure, 10, _sensor_id ) - print("testing pressure") - pressure_open_air_pass = True - pressure_open_air = await _read_pressure() - print(f"pressure-open-air: {pressure_open_air}") - if ( - pressure_open_air < PRESSURE_THRESH_OPEN_AIR[0] - or pressure_open_air > PRESSURE_THRESH_OPEN_AIR[1] - ): - pressure_open_air_pass = False - print(f"FAIL: open-air pressure ({pressure_open_air}) is not correct") - write_cb( - [ - "pressure-open-air", - pressure_open_air, - _bool_to_pass_fail(pressure_open_air_pass), - ] - ) + for sensor_id in sensor_ids: + pressure = await _read_pressure(sensor_id) + print(f"pressure-open-air-{sensor_id.name}: {pressure}") + if ( + pressure < PRESSURE_THRESH_OPEN_AIR[pip_channels][0] + or pressure > PRESSURE_THRESH_OPEN_AIR[pip_channels][1] + ): + results.append(False) + print( + f"FAIL: open-air {sensor_id.name} pressure ({pressure}) is not correct" + ) + else: + results.append(True) + write_cb( + [ + f"pressure-open-air-{sensor_id.name}", + pressure, + _bool_to_pass_fail(results[-1]), + ] + ) + # PICK-UP TIP(S) _, bottom, _, _ = helpers_ot3.get_plunger_positions_ot3(api, mount) print("moving plunger to bottom") await helpers_ot3.move_plunger_absolute_ot3(api, mount, bottom) await _pick_up_tip_for_tip_volume(api, mount, tip_volume=50) await api.retract(mount) + + # SEALED PRESSURE if not api.is_simulator: - _get_operator_answer_to_question('COVER tip with finger, enter "y" when ready') - pressure_sealed = await _read_pressure() - pressure_sealed_pass = True - print(f"pressure-sealed: {pressure_sealed}") - if ( - pressure_sealed < PRESSURE_THRESH_SEALED[0] - or pressure_sealed > PRESSURE_THRESH_SEALED[1] - ): - pressure_sealed_pass = False - print(f"FAIL: sealed pressure ({pressure_sealed}) is not correct") - write_cb( - ["pressure-sealed", pressure_sealed, _bool_to_pass_fail(pressure_sealed_pass)] - ) + _get_operator_answer_to_question( + 'COVER tip(s) with finger(s), enter "y" when ready' + ) + for sensor_id in sensor_ids: + pressure = await _read_pressure(sensor_id) + print(f"pressure-sealed: {pressure}") + if ( + pressure < PRESSURE_THRESH_SEALED[pip_channels][0] + or pressure > PRESSURE_THRESH_SEALED[pip_channels][1] + ): + results.append(False) + print(f"FAIL: sealed {sensor_id.name} pressure ({pressure}) is not correct") + else: + results.append(True) + write_cb( + [ + f"pressure-sealed-{sensor_id.name}", + pressure, + _bool_to_pass_fail(results[-1]), + ] + ) + + # COMPRESSED pip = api.hardware_pipettes[mount.to_mount()] assert pip pip_vol = int(pip.working_volume) - plunger_aspirate_ul = PRESSURE_ASPIRATE_VOL[pip_vol] + pip_channels = int(pip.channels) + plunger_aspirate_ul = PRESSURE_ASPIRATE_VOL[pip_channels][pip_vol] print(f"aspirate {plunger_aspirate_ul} ul") await api.aspirate(mount, plunger_aspirate_ul) - pressure_compress = await _read_pressure() - print(f"pressure-compressed: {pressure_compress}") - pressure_compress_pass = True - if ( - pressure_compress < PRESSURE_THRESH_COMPRESS[0] - or pressure_compress > PRESSURE_THRESH_COMPRESS[1] - ): - pressure_compress_pass = False - print(f"FAIL: sealed pressure ({pressure_compress}) is not correct") - write_cb( - [ - "pressure-compressed", - pressure_compress, - _bool_to_pass_fail(pressure_compress_pass), - ] - ) + for sensor_id in sensor_ids: + pressure = await _read_pressure(sensor_id) + print(f"pressure-compressed-{sensor_id.name}: {pressure}") + if ( + pressure < PRESSURE_THRESH_COMPRESS[pip_channels][0] + or pressure > PRESSURE_THRESH_COMPRESS[pip_channels][1] + ): + results.append(False) + print( + f"FAIL: compressed {sensor_id.name} pressure ({pressure}) is not correct" + ) + else: + results.append(True) + write_cb( + [ + f"pressure-compressed-{sensor_id.name}", + pressure, + _bool_to_pass_fail(results[-1]), + ] + ) if not api.is_simulator: _get_operator_answer_to_question('REMOVE your finger, enter "y" when ready') print("moving plunger back down to BOTTOM position") await api.dispense(mount) + await api.prepare_for_aspirate(mount) + await _drop_tip_in_trash(api, mount) - return pressure_open_air_pass and pressure_sealed_pass and pressure_compress_pass + return all(results) async def _test_diagnostics(api: OT3API, mount: OT3Mount, write_cb: Callable) -> bool: @@ -940,6 +1145,9 @@ async def _test_diagnostics(api: OT3API, mount: OT3Mount, write_cb: Callable) -> print(f"encoder: {_bool_to_pass_fail(encoder_pass)}") write_cb(["diagnostics-encoder", _bool_to_pass_fail(encoder_pass)]) # CAPACITIVE SENSOR + print("SKIPPING CAPACITIVE TESTS") + pip = api.hardware_pipettes[mount.to_mount()] + assert pip capacitance_pass = await _test_diagnostics_capacitive(api, mount, write_cb) print(f"capacitance: {_bool_to_pass_fail(capacitance_pass)}") write_cb(["diagnostics-capacitance", _bool_to_pass_fail(capacitance_pass)]) @@ -994,7 +1202,7 @@ async def _jog(_step: float) -> None: async def _matches_state(_state: TipStateType) -> bool: try: await asyncio.sleep(0.2) - await api._backend.get_tip_present(mount, _state) + await api._backend.check_for_tip_presence(mount, _state) return True except FailedTipStateCheck: return False @@ -1012,6 +1220,9 @@ async def _matches_state(_state: TipStateType) -> bool: async def _test_tip_presence_flag( api: OT3API, mount: OT3Mount, write_cb: Callable ) -> bool: + pip = api.hardware_pipettes[mount.to_mount()] + assert pip + pip_channels = pip.channels.value await api.retract(mount) slot_5_pos = helpers_ot3.get_slot_calibration_square_position_ot3(5) current_pos = await api.gantry_position(mount) @@ -1021,7 +1232,10 @@ async def _test_tip_presence_flag( if not api.is_simulator: input("press ENTER to continue") - offset_from_a1 = Point(x=9 * 11, y=9 * -7, z=-5) + if pip_channels == 1: + offset_from_a1 = Point(x=9 * 11, y=9 * -7, z=-5) + else: + offset_from_a1 = Point(x=9 * 11, y=0, z=-5) nominal_test_pos = ( IDEAL_LABWARE_LOCATIONS.tip_rack_50 + offset_from_a1 # type: ignore[operator] ) @@ -1031,15 +1245,15 @@ async def _test_tip_presence_flag( await helpers_ot3.jog_mount_ot3(api, mount) nozzle_pos = await api.gantry_position(mount) print(f"nozzle: {nozzle_pos.z}") - await api.move_rel(mount, Point(z=-6)) + if pip_channels == 1: + await api.move_rel(mount, Point(z=-6)) + else: + await api.move_rel(mount, Point(z=-2)) print("align EJECTOR with tip-rack HOLE:") await helpers_ot3.jog_mount_ot3(api, mount) ejector_pos = await api.gantry_position(mount) ejector_rel_pos = round(ejector_pos.z - nozzle_pos.z, 2) - pip = api.hardware_pipettes[mount.to_mount()] - assert pip - pip_channels = pip.channels.value pick_up_criteria = { 1: ( ejector_rel_pos + -1.3, @@ -1047,7 +1261,7 @@ async def _test_tip_presence_flag( ), 8: ( ejector_rel_pos + -1.9, - ejector_rel_pos + -3.2, + ejector_rel_pos + -4.0, ), }[pip_channels] @@ -1071,7 +1285,7 @@ async def _test_tip_presence_flag( ), 8: ( -10.5 + 1.9, - -10.5 + 3.5, + -10.5 + 4.0, ), }[pip_channels] drop_result = await _jog_for_tip_state( @@ -1141,45 +1355,53 @@ class _LiqProbeCfg: async def _test_liquid_probe( - api: OT3API, mount: OT3Mount, tip_volume: int, trials: int -) -> List[float]: + api: OT3API, + mount: OT3Mount, + tip_volume: int, + trials: int, + probes: List[InstrumentProbeType], +) -> Dict[InstrumentProbeType, List[float]]: pip = api.hardware_pipettes[mount.to_mount()] assert pip pip_vol = int(pip.working_volume) - # force the operator to re-calibrate the liquid every time - CALIBRATED_LABWARE_LOCATIONS.plate = None - await _pick_up_tip_for_tip_volume(api, mount, tip_volume) - await _move_to_plate_liquid(api, mount) - await _drop_tip_in_trash(api, mount) - trial_results: List[float] = [] + trial_results: Dict[InstrumentProbeType, List[float]] = { + probe: [] for probe in probes + } hover_mm = 3 max_submerge_mm = -3 max_z_distance_machine_coords = hover_mm - max_submerge_mm - assert CALIBRATED_LABWARE_LOCATIONS.plate is not None - target_z = CALIBRATED_LABWARE_LOCATIONS.plate.z + assert CALIBRATED_LABWARE_LOCATIONS.plate_primary is not None + if InstrumentProbeType.SECONDARY in probes: + assert CALIBRATED_LABWARE_LOCATIONS.plate_secondary is not None for trial in range(trials): + await api.home() await _pick_up_tip_for_tip_volume(api, mount, tip_volume) - await _move_to_above_plate_liquid(api, mount, height_mm=hover_mm) - start_pos = await api.gantry_position(mount) - probe_cfg = PROBE_SETTINGS[pip_vol][tip_volume] - probe_settings = LiquidProbeSettings( - starting_mount_height=start_pos.z, - max_z_distance=max_z_distance_machine_coords, # FIXME: deck coords - min_z_distance=0, # FIXME: remove - mount_speed=probe_cfg.mount_speed, - plunger_speed=probe_cfg.plunger_speed, - sensor_threshold_pascals=probe_cfg.sensor_threshold_pascals, - expected_liquid_height=0, # FIXME: remove - log_pressure=False, # FIXME: remove - aspirate_while_sensing=False, # FIXME: I heard this doesn't work - auto_zero_sensor=True, # TODO: when would we want to adjust this? - num_baseline_reads=10, # TODO: when would we want to adjust this? - data_file="", # FIXME: remove - ) - end_z = await api.liquid_probe(mount, probe_settings) - error_mm = end_z - target_z - print(f"liquid-probe error: {error_mm}") - trial_results.append(end_z - target_z) # store the mm error from target + for probe in probes: + await _move_to_above_plate_liquid(api, mount, probe, height_mm=hover_mm) + start_pos = await api.gantry_position(mount) + probe_cfg = PROBE_SETTINGS[pip_vol][tip_volume] + probe_settings = LiquidProbeSettings( + starting_mount_height=start_pos.z, + max_z_distance=max_z_distance_machine_coords, # FIXME: deck coords + min_z_distance=0, # FIXME: remove + mount_speed=probe_cfg.mount_speed, + plunger_speed=probe_cfg.plunger_speed, + sensor_threshold_pascals=probe_cfg.sensor_threshold_pascals, + expected_liquid_height=0, # FIXME: remove + log_pressure=False, # FIXME: remove + aspirate_while_sensing=False, # FIXME: I heard this doesn't work + auto_zero_sensor=True, # TODO: when would we want to adjust this? + num_baseline_reads=10, # TODO: when would we want to adjust this? + data_file="", # FIXME: remove + ) + end_z = await api.liquid_probe(mount, probe_settings, probe=probe) + if probe == InstrumentProbeType.PRIMARY: + pz = CALIBRATED_LABWARE_LOCATIONS.plate_primary.z + else: + pz = CALIBRATED_LABWARE_LOCATIONS.plate_secondary.z # type: ignore[union-attr] + error_mm = end_z - pz + print(f"liquid-probe error: {error_mm}") + trial_results[probe].append(error_mm) # store the mm error from target await _drop_tip_in_trash(api, mount) return trial_results @@ -1208,8 +1430,9 @@ def _create_csv_and_get_callbacks( run_id = data.create_run_id() test_name = Path(__file__).parent.name.replace("_", "-") folder_path = data.create_folder_for_test_data(test_name) + run_path = data.create_folder_for_test_data(folder_path / run_id) file_name = data.create_file_name(test_name, run_id, pipette_sn) - csv_display_name = os.path.join(folder_path, file_name) + csv_display_name = os.path.join(run_path, file_name) print(f"CSV: {csv_display_name}") start_time = time() @@ -1226,9 +1449,11 @@ def _append_csv_data( data_list = [first_row_value] + data_list data_str = ",".join([str(d) for d in data_list]) if line_number is None: - data.append_data_to_file(test_name, file_name, data_str + "\n") + data.append_data_to_file(test_name, run_id, file_name, data_str + "\n") else: - data.insert_data_to_file(test_name, file_name, data_str + "\n", line_number) + data.insert_data_to_file( + test_name, run_id, file_name, data_str + "\n", line_number + ) def _cache_pressure_data_callback( d: List[Any], first_row_value: Optional[str] = None @@ -1315,7 +1540,7 @@ async def _main(test_config: TestConfig) -> None: # noqa: C901 # create API instance, and get Pipette serial number api = await helpers_ot3.build_async_ot3_hardware_api( is_simulating=test_config.simulate, - pipette_left="p1000_single_v3.4", + # pipette_left="p1000_single_v3.4", pipette_right="p1000_multi_v3.4", ) @@ -1348,7 +1573,8 @@ async def _main(test_config: TestConfig) -> None: # noqa: C901 tip_rack_200=None, tip_rack_50=None, reservoir=None, - plate=None, + plate_primary=None, + plate_secondary=None, fixture=None, ) @@ -1359,6 +1585,13 @@ async def _main(test_config: TestConfig) -> None: # noqa: C901 # cache the pressure-data header csv_cb.pressure(PRESSURE_DATA_HEADER, first_row_value="") + if api.is_simulator: + pcba_version = "C2" + else: + subsystem = SubSystem.of_mount(mount) + pcba_version = api.attached_subsystems[subsystem].pcba_revision + + print(f"PCBA version: {pcba_version}") # add metadata to CSV # FIXME: create a set of CSV helpers, such that you can define a test-report # schema/format/line-length/etc., before having to fill its contents. @@ -1373,6 +1606,7 @@ async def _main(test_config: TestConfig) -> None: # noqa: C901 csv_cb.write(["simulating" if test_config.simulate else "live"]) csv_cb.write(["version", data.get_git_description()]) csv_cb.write(["firmware", api.fw_version]) + csv_cb.write(["pcba-revision", pcba_version]) # add test configurations to CSV csv_cb.write(["-------------------"]) csv_cb.write(["TEST-CONFIGURATIONS"]) @@ -1394,12 +1628,22 @@ async def _main(test_config: TestConfig) -> None: # noqa: C901 + [str(t) for t in CAP_THRESH_SQUARE[pipette_channels]] ) csv_cb.write( - ["pressure-microliters-aspirated", PRESSURE_ASPIRATE_VOL[pipette_volume]] + [ + "pressure-microliters-aspirated", + PRESSURE_ASPIRATE_VOL[pipette_channels][pipette_volume], + ] + ) + csv_cb.write( + ["pressure-open-air"] + + [str(t) for t in PRESSURE_THRESH_OPEN_AIR[pipette_channels]] + ) + csv_cb.write( + ["pressure-sealed"] + + [str(t) for t in PRESSURE_THRESH_SEALED[pipette_channels]] ) - csv_cb.write(["pressure-open-air"] + [str(t) for t in PRESSURE_THRESH_OPEN_AIR]) - csv_cb.write(["pressure-sealed"] + [str(t) for t in PRESSURE_THRESH_SEALED]) csv_cb.write( - ["pressure-compressed"] + [str(t) for t in PRESSURE_THRESH_COMPRESS] + ["pressure-compressed"] + + [str(t) for t in PRESSURE_THRESH_COMPRESS[pipette_channels]] ) csv_cb.write(["probe-deck", PROBING_DECK_PRECISION_MM]) csv_cb.write( @@ -1430,53 +1674,72 @@ async def _main(test_config: TestConfig) -> None: # noqa: C901 _bool_to_pass_fail(barcode_passed), ] ) + pos_slot_3 = helpers_ot3.get_slot_calibration_square_position_ot3(3) + current_pos = await api.gantry_position(mount) + hover_over_slot_3 = pos_slot_3._replace(z=current_pos.z) if not test_config.skip_plunger or not test_config.skip_diagnostics: - print("moving over slot 3") - pos_slot_3 = helpers_ot3.get_slot_calibration_square_position_ot3(3) - current_pos = await api.gantry_position(mount) - hover_over_slot_3 = pos_slot_3._replace(z=current_pos.z) - await api.move_to(mount, hover_over_slot_3) - await api.move_rel(mount, Point(z=-20)) if not test_config.skip_diagnostics: + await api.move_to(mount, hover_over_slot_3) + await api.move_rel(mount, Point(z=-20)) test_passed = await _test_diagnostics(api, mount, csv_cb.write) + await api.retract(mount) csv_cb.results("diagnostics", test_passed) if not test_config.skip_plunger: + await api.move_to(mount, hover_over_slot_3) + await api.move_rel(mount, Point(z=-20)) test_passed = await _test_plunger_positions(api, mount, csv_cb.write) csv_cb.results("plunger", test_passed) if not test_config.skip_liquid_probe: tip_vols = [50] if pipette_volume == 50 else [50, 200, 1000] + probes = [InstrumentProbeType.PRIMARY] + if pipette_channels > 1: + probes.append(InstrumentProbeType.SECONDARY) test_passed = True for tip_vol in tip_vols: - probe_data = await _test_liquid_probe( - api, mount, tip_volume=tip_vol, trials=3 - ) - for trial, found_height in enumerate(probe_data): - csv_label = f"liquid-probe-{tip_vol}-tip-trial-{trial}" - csv_cb.write([csv_label, round(found_height, 2)]) - precision = abs(max(probe_data) - min(probe_data)) * 0.5 - accuracy = sum(probe_data) / len(probe_data) - prec_tag = f"liquid-probe-{tip_vol}-tip-precision" - acc_tag = f"liquid-probe-{tip_vol}-tip-accuracy" - tip_tag = f"liquid-probe-{tip_vol}-tip" - precision_passed = bool( - precision < LIQUID_PROBE_ERROR_THRESHOLD_PRECISION_MM - ) - accuracy_passed = bool( - abs(accuracy) < LIQUID_PROBE_ERROR_THRESHOLD_ACCURACY_MM - ) - tip_passed = precision_passed and accuracy_passed - print(prec_tag, precision, _bool_to_pass_fail(precision_passed)) - print(acc_tag, accuracy, _bool_to_pass_fail(accuracy_passed)) - print(tip_tag, _bool_to_pass_fail(tip_passed)) - csv_cb.write( - [prec_tag, precision, _bool_to_pass_fail(precision_passed)] + # force the operator to re-calibrate the liquid for each tip-type + CALIBRATED_LABWARE_LOCATIONS.plate_primary = None + CALIBRATED_LABWARE_LOCATIONS.plate_secondary = None + await _pick_up_tip_for_tip_volume(api, mount, tip_vol) + for probe in probes: + await _move_to_plate_liquid(api, mount, probe=probe) + await _drop_tip_in_trash(api, mount) + probes_data = await _test_liquid_probe( + api, mount, tip_volume=tip_vol, trials=3, probes=probes ) - csv_cb.write([acc_tag, accuracy, _bool_to_pass_fail(accuracy_passed)]) - csv_cb.write([tip_tag, _bool_to_pass_fail(tip_passed)]) - if not tip_passed: - test_passed = False + for probe in probes: + probe_data = probes_data[probe] + for trial, found_height in enumerate(probe_data): + csv_label = ( + f"liquid-probe-{tip_vol}-" + f"tip-{probe.name.lower()}-probe-trial-{trial}" + ) + csv_cb.write([csv_label, round(found_height, 2)]) + precision = abs(max(probe_data) - min(probe_data)) * 0.5 + accuracy = sum(probe_data) / len(probe_data) + prec_tag = f"liquid-probe-{tip_vol}-tip-{probe.name.lower()}-probe-precision" + acc_tag = f"liquid-probe-{tip_vol}-tip-{probe.name.lower()}-probe-accuracy" + tip_tag = f"liquid-probe-{tip_vol}-tip-{probe.name.lower()}-probe" + precision_passed = bool( + precision < LIQUID_PROBE_ERROR_THRESHOLD_PRECISION_MM + ) + accuracy_passed = bool( + abs(accuracy) < LIQUID_PROBE_ERROR_THRESHOLD_ACCURACY_MM + ) + tip_passed = precision_passed and accuracy_passed + print(prec_tag, precision, _bool_to_pass_fail(precision_passed)) + print(acc_tag, accuracy, _bool_to_pass_fail(accuracy_passed)) + print(tip_tag, _bool_to_pass_fail(tip_passed)) + csv_cb.write( + [prec_tag, precision, _bool_to_pass_fail(precision_passed)] + ) + csv_cb.write( + [acc_tag, accuracy, _bool_to_pass_fail(accuracy_passed)] + ) + csv_cb.write([tip_tag, _bool_to_pass_fail(tip_passed)]) + if not tip_passed: + test_passed = False csv_cb.results("liquid-probe", test_passed) if not test_config.skip_liquid: diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/pressure.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/pressure.py index d279a50d279..9b696971b0f 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/pressure.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/pressure.py @@ -6,7 +6,7 @@ from hardware_testing.opentrons_api.types import Point -LOCATION_A1_LEFT = Point(x=14.4, y=74.5, z=96) +LOCATION_A1_LEFT = Point(x=14.4, y=74.5, z=100) LOCATION_A1_RIGHT = LOCATION_A1_LEFT._replace(x=128 - LOCATION_A1_LEFT.x) PRESSURE_FIXTURE_TIP_VOLUME = 50 # always 50ul @@ -36,7 +36,18 @@ class PressureEventConfig: PRESSURE_FIXTURE_ASPIRATE_VOLUME = {50: 11.0, 1000: 12.0} -PRESSURE_FIXTURE_INSERT_DEPTH = {50: 28.5, 1000: 33.0} +PRESSURE_FIXTURE_INSERT_DEPTH = {50: 30.0, 1000: 30.0} + +PRESSURE_ASPIRATE_DELTA_SPEC = { + 1: { + 50: {"delta": 1350.0, "margin": 0.1}, # absolute value # percent of delta + 1000: {"delta": 1000.0, "margin": 0.1}, # absolute value # percent of delta + }, + 8: { + 50: {"delta": 4000.0, "margin": 0.99}, # absolute value # percent of delta + 1000: {"delta": 4000.0, "margin": 0.99}, # absolute value # percent of delta + }, +} DEFAULT_PRESSURE_SAMPLE_DELAY = 0.25 DEFAULT_PRESSURE_SAMPLE_COUNT = 10 @@ -49,15 +60,15 @@ class PressureEventConfig: (1 * 60) / DEFAULT_PRESSURE_SAMPLE_DELAY ) PRESSURE_NONE = PressureEventConfig( - min=-10.0, - max=10.0, + min=-8000.0, + max=8000.0, stability_delay=DEFAULT_STABILIZE_SECONDS, stability_threshold=2.0, sample_count=DEFAULT_PRESSURE_SAMPLE_COUNT, sample_delay=DEFAULT_PRESSURE_SAMPLE_DELAY, ) PRESSURE_INSERTED = PressureEventConfig( - min=3000.0, + min=-8000.0, max=8000.0, stability_delay=DEFAULT_STABILIZE_SECONDS, stability_threshold=50.0, @@ -65,16 +76,16 @@ class PressureEventConfig: sample_delay=DEFAULT_PRESSURE_SAMPLE_DELAY, ) PRESSURE_ASPIRATED_P50 = PressureEventConfig( - min=2000.0, - max=7000.0, + min=-8000.0, + max=8000.0, stability_delay=DEFAULT_STABILIZE_SECONDS, stability_threshold=200.0, sample_count=DEFAULT_PRESSURE_SAMPLE_COUNT_DURING_ASPIRATE, sample_delay=DEFAULT_PRESSURE_SAMPLE_DELAY, ) PRESSURE_ASPIRATED_P1000 = PressureEventConfig( - min=2000.0, - max=7000.0, + min=-8000.0, + max=8000.0, stability_delay=DEFAULT_STABILIZE_SECONDS, stability_threshold=200.0, sample_count=DEFAULT_PRESSURE_SAMPLE_COUNT_DURING_ASPIRATE, diff --git a/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py b/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py index 1aa0782c297..e821ae6bc9f 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_current_speed_qc_ot3.py @@ -20,52 +20,74 @@ from hardware_testing.opentrons_api import helpers_ot3 from hardware_testing.data import ui -TEST_TAG = "CURRENTS-SPEEDS" +DEFAULT_TRIALS = 5 +STALL_THRESHOLD_MM = 0.1 +TEST_ACCELERATION = 1500 # used during gravimetric tests DEFAULT_ACCELERATION = DEFAULT_ACCELERATIONS.low_throughput[types.OT3AxisKind.P] DEFAULT_CURRENT = DEFAULT_RUN_CURRENT.low_throughput[types.OT3AxisKind.P] DEFAULT_SPEED = DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P] -MUST_PASS_CURRENT = DEFAULT_CURRENT * 0.6 # the target spec (must pass here) -STALL_THRESHOLD_MM = 0.1 -TEST_SPEEDS = [DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P]] +MUST_PASS_CURRENT = round(DEFAULT_CURRENT * 0.75, 2) # the target spec (must pass here) +assert ( + MUST_PASS_CURRENT < DEFAULT_CURRENT +), "must-pass current must be less than default current" +TEST_SPEEDS = [ + DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P] - 20, + DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P], + DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P] + 10, + DEFAULT_MAX_SPEEDS.low_throughput[types.OT3AxisKind.P] + 20, +] PLUNGER_CURRENTS_SPEED = { - round(MUST_PASS_CURRENT - 0.3, 1): TEST_SPEEDS, - round(MUST_PASS_CURRENT - 0.2, 1): TEST_SPEEDS, - round(MUST_PASS_CURRENT - 0.1, 1): TEST_SPEEDS, - round(MUST_PASS_CURRENT, 1): TEST_SPEEDS, + MUST_PASS_CURRENT - 0.45: TEST_SPEEDS, + MUST_PASS_CURRENT - 0.35: TEST_SPEEDS, + MUST_PASS_CURRENT - 0.25: TEST_SPEEDS, + MUST_PASS_CURRENT: TEST_SPEEDS, DEFAULT_CURRENT: TEST_SPEEDS, } -TEST_ACCELERATION = 1500 # used during gravimetric tests -MAX_CURRENT = max(max(list(PLUNGER_CURRENTS_SPEED.keys())), 1.0) MAX_SPEED = max(TEST_SPEEDS) +MAX_CURRENT = max(max(list(PLUNGER_CURRENTS_SPEED.keys())), 1.0) +assert MAX_CURRENT == DEFAULT_CURRENT, ( + f"do not test current ({MAX_CURRENT}) " + f"above the software's default current ({DEFAULT_CURRENT})" +) + + +def _get_test_tag( + current: float, speed: float, trial: int, direction: str, pos: str +) -> str: + return f"current-{current}-speed-trial-{trial}-{speed}-{direction}-{pos}" + + +def _get_section_tag(current: float) -> str: + return f"CURRENT-{current}-AMPS" -def _get_test_tag(current: float, speed: float, direction: str, pos: str) -> str: - return f"current-{current}-speed-{speed}-{direction}-{pos}" +def _includes_result(current: float, speed: float) -> bool: + return current >= MUST_PASS_CURRENT -def _build_csv_report() -> CSVReport: +def _build_csv_report(trials: int) -> CSVReport: _report = CSVReport( test_name="pipette-current-speed-qc-ot3", sections=[ CSVSection( - title="OVERALL", lines=[CSVLine("failing-current", [float, CSVResult])] - ), - CSVSection( - title=TEST_TAG, + title=_get_section_tag(current), lines=[ CSVLine( - _get_test_tag(current, speed, direction, pos), - [float, float, float, float, CSVResult], + _get_test_tag(current, speed, trial, direction, pos), + [float, float, float, float, CSVResult] + if _includes_result(current, speed) + else [float, float, float, float], ) - for current, speeds in PLUNGER_CURRENTS_SPEED.items() - for speed in speeds + for speed in sorted(PLUNGER_CURRENTS_SPEED[current], reverse=False) + for trial in range(trials) for direction in ["down", "up"] for pos in ["start", "end"] ], - ), + ) + for current in sorted(list(PLUNGER_CURRENTS_SPEED.keys()), reverse=False) ], ) return _report @@ -75,10 +97,13 @@ async def _home_plunger(api: OT3API, mount: types.OT3Mount) -> None: # restore default current/speed before homing pipette_ax = types.Axis.of_main_tool_actuator(mount) await helpers_ot3.set_gantry_load_per_axis_current_settings_ot3( - api, pipette_ax, run_current=DEFAULT_CURRENT + api, pipette_ax, run_current=1.0 ) await helpers_ot3.set_gantry_load_per_axis_motion_settings_ot3( - api, pipette_ax, default_max_speed=DEFAULT_SPEED + api, + pipette_ax, + default_max_speed=DEFAULT_SPEED / 2, + acceleration=DEFAULT_ACCELERATION, ) await api.home([pipette_ax]) @@ -94,7 +119,7 @@ async def _move_plunger( # set max currents/speeds, to make sure we're not accidentally limiting ourselves pipette_ax = types.Axis.of_main_tool_actuator(mount) await helpers_ot3.set_gantry_load_per_axis_current_settings_ot3( - api, pipette_ax, run_current=MAX_CURRENT + api, pipette_ax, run_current=c ) await helpers_ot3.set_gantry_load_per_axis_motion_settings_ot3( api, @@ -112,6 +137,7 @@ async def _record_plunger_alignment( api: OT3API, mount: types.OT3Mount, report: CSVReport, + trial: int, current: float, speed: float, direction: str, @@ -126,13 +152,16 @@ async def _record_plunger_alignment( else: enc = est _stalled_mm = est - enc - print(f"{position}: motor={est}, encoder={enc}") + print(f"{position}: motor={round(est, 2)}, encoder={round(enc, 2)}") _did_pass = abs(_stalled_mm) < STALL_THRESHOLD_MM - _tag = _get_test_tag(current, speed, direction, position) + # NOTE: only tests that are required to PASS need to show a results in the file + data = [round(current, 2), round(speed, 2), round(est, 2), round(enc, 2)] + if _includes_result(current, speed): + data.append(CSVResult.from_bool(_did_pass)) # type: ignore[arg-type] report( - TEST_TAG, - _tag, - [current, speed, est, enc, CSVResult.from_bool(_did_pass)], + _get_section_tag(current), + _get_test_tag(current, speed, trial, direction, position), + data, ) return _did_pass @@ -141,26 +170,28 @@ async def _test_direction( api: OT3API, mount: types.OT3Mount, report: CSVReport, + trial: int, current: float, speed: float, acceleration: float, direction: str, ) -> bool: plunger_poses = helpers_ot3.get_plunger_positions_ot3(api, mount) - top, bottom, blowout, drop_tip = plunger_poses + top, _, bottom, _ = plunger_poses # check that encoder/motor align aligned = await _record_plunger_alignment( - api, mount, report, current, speed, direction, "start" + api, mount, report, trial, current, speed, direction, "start" ) if not aligned: + print("ERROR: unable to align at the start") return False # move the plunger - _plunger_target = {"down": blowout, "up": top}[direction] + _plunger_target = {"down": bottom, "up": top + 1.0}[direction] try: await _move_plunger(api, mount, _plunger_target, speed, current, acceleration) # check that encoder/motor still align aligned = await _record_plunger_alignment( - api, mount, report, current, speed, direction, "end" + api, mount, report, trial, current, speed, direction, "end" ) except StallOrCollisionDetectedError as e: print(e) @@ -169,34 +200,49 @@ async def _test_direction( return aligned -async def _unstick_plunger(api: OT3API, mount: types.OT3Mount) -> None: - plunger_poses = helpers_ot3.get_plunger_positions_ot3(api, mount) - top, bottom, blowout, drop_tip = plunger_poses - await _move_plunger(api, mount, bottom, 10, 1.0, DEFAULT_ACCELERATION) - await _home_plunger(api, mount) - - -async def _test_plunger(api: OT3API, mount: types.OT3Mount, report: CSVReport) -> float: - ui.print_header("UNSTICK PLUNGER") - await _unstick_plunger(api, mount) +async def _test_plunger( + api: OT3API, + mount: types.OT3Mount, + report: CSVReport, + trials: int, + continue_after_stall: bool, +) -> float: # start at HIGHEST (easiest) current - currents = sorted(list(PLUNGER_CURRENTS_SPEED.keys()), reverse=True) + currents = sorted(list(PLUNGER_CURRENTS_SPEED.keys()), reverse=False) + max_failed_current = 0.0 for current in currents: + ui.print_title(f"CURRENT = {current}") # start at LOWEST (easiest) speed speeds = sorted(PLUNGER_CURRENTS_SPEED[current], reverse=False) for speed in speeds: - ui.print_header(f"CURRENT = {current}; SPEED = {speed}") - await _home_plunger(api, mount) - for direction in ["down", "up"]: - _pass = await _test_direction( - api, mount, report, current, speed, TEST_ACCELERATION, direction + for trial in range(trials): + ui.print_header( + f"CURRENT = {current}: " + f"SPEED = {speed}: " + f"TRIAL = {trial + 1}/{trials}" ) - if not _pass: - ui.print_error( - f"failed moving {direction} at {current} amps and {speed} mm/sec" + await _home_plunger(api, mount) + for direction in ["down", "up"]: + _pass = await _test_direction( + api, + mount, + report, + trial, + current, + speed, + TEST_ACCELERATION, + direction, ) - return current - return 0.0 + if not _pass: + ui.print_error( + f"failed moving {direction} at {current} amps and {speed} mm/sec" + ) + max_failed_current = max(max_failed_current, current) + if continue_after_stall: + break + else: + return max_failed_current + return max_failed_current async def _get_next_pipette_mount(api: OT3API) -> types.OT3Mount: @@ -213,7 +259,14 @@ async def _get_next_pipette_mount(api: OT3API) -> types.OT3Mount: async def _reset_gantry(api: OT3API) -> None: - await api.home() + await api.home( + [ + types.Axis.Z_L, + types.Axis.Z_R, + types.Axis.X, + types.Axis.Y, + ] + ) home_pos = await api.gantry_position( types.OT3Mount.RIGHT, types.CriticalPoint.MOUNT ) @@ -224,7 +277,7 @@ async def _reset_gantry(api: OT3API) -> None: ) -async def _main(is_simulating: bool) -> None: +async def _main(is_simulating: bool, trials: int, continue_after_stall: bool) -> None: api = await helpers_ot3.build_async_ot3_hardware_api( is_simulating=is_simulating, pipette_left="p1000_single_v3.4", @@ -239,16 +292,16 @@ async def _main(is_simulating: bool) -> None: if not api.is_simulator and not ui.get_user_answer(f"QC {mount.name} pipette"): continue - report = _build_csv_report() + report = _build_csv_report(trials=trials) dut = helpers_ot3.DeviceUnderTest.by_mount(mount) helpers_ot3.set_csv_report_meta_data_ot3(api, report, dut) - failing_current = await _test_plunger(api, mount, report) - report( - "OVERALL", - "failing-current", - [failing_current, CSVResult.from_bool(failing_current < MUST_PASS_CURRENT)], + await _test_plunger( + api, mount, report, trials=trials, continue_after_stall=continue_after_stall ) + ui.print_title("DONE") + report.save_to_disk() + report.print_results() if api.is_simulator: break @@ -256,5 +309,7 @@ async def _main(is_simulating: bool) -> None: if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--simulate", action="store_true") + parser.add_argument("--trials", type=int, default=DEFAULT_TRIALS) + parser.add_argument("--continue-after-stall", action="store_true") args = parser.parse_args() - asyncio.run(_main(args.simulate)) + asyncio.run(_main(args.simulate, args.trials, args.continue_after_stall)) diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_connectivity.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_connectivity.py index 31537c3fd74..8e8beb4b9e4 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_connectivity.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_connectivity.py @@ -185,7 +185,7 @@ async def _test_ethernet(api: OT3API, report: CSVReport, section: str) -> None: async def _test_wifi(report: CSVReport, section: str) -> None: ssid = "" password: Optional[str] = None - result = CSVResult.FAIL + result: Optional[CSVResult] = CSVResult.FAIL wifi_ip: Optional[str] = None def _finish() -> None: @@ -336,7 +336,7 @@ async def _test_aux(api: OT3API, report: CSVReport, section: str) -> None: inp = ui.get_user_answer( f"does {test_name.upper()} count TRANSMIT = RECEIVE" ) - result = CSVResult.from_bool(inp) + result = CSVResult.from_bool(inp) # type: ignore[assignment] report(section, test_name, [result]) else: if api.is_simulator: diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py index 2d5533c12ae..65d4618ff74 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_instruments.py @@ -108,7 +108,7 @@ async def _probe_mount_and_record_result( if not api.is_simulator: ui.get_user_ready("about to probe DOWN") print("touch with your finger to stop the probing motion") - height_of_probe_stopped = await api.capacitive_probe( + height_of_probe_stopped, _ = await api.capacitive_probe( mount, z_ax, height_of_probe_full_travel, PROBE_SETTINGS ) diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py index d702cc7f9ab..b7dcdd9d3bc 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_peripherals.py @@ -111,7 +111,7 @@ async def _take_picture(api: OT3API, report: CSVReport, section: str) -> Optiona async def _run_image_check_server( api: OT3API, report: CSVReport, section: str, file_path: Path ) -> None: - result = CSVResult.FAIL + result: Optional[CSVResult] = CSVResult.FAIL server_process: Optional[Popen] = None async def _run_check() -> None: diff --git a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_signals.py b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_signals.py index b7a386ac140..bfbddc6fe53 100644 --- a/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_signals.py +++ b/hardware-testing/hardware_testing/production_qc/robot_assembly_qc_ot3/test_signals.py @@ -117,7 +117,6 @@ async def _do_the_moving() -> None: move_coro = _do_the_moving() stop_coro = _sleep_then_activate_stop_signal() await asyncio.gather(stop_coro, move_coro) - await api.refresh_positions() async def run(api: OT3API, report: CSVReport, section: str) -> None: @@ -152,7 +151,9 @@ async def _home() -> None: await _move_and_interrupt_with_signal(api, sig_name) if not api.is_simulator and "external" in sig_name: ui.get_user_ready("release the E-STOP") - stop_pos = await api.gantry_position(mount) + if "external" in sig_name or "estop" in sig_name: + await api._update_position_estimation([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R]) + stop_pos = await api.gantry_position(mount, refresh=True) report( section, f"{sig_name}-stop-pos", diff --git a/hardware-testing/hardware_testing/production_qc/stress_test_qc_ot3.py b/hardware-testing/hardware_testing/production_qc/stress_test_qc_ot3.py index cb162b5f413..1ca0c7625a8 100644 --- a/hardware-testing/hardware_testing/production_qc/stress_test_qc_ot3.py +++ b/hardware-testing/hardware_testing/production_qc/stress_test_qc_ot3.py @@ -125,9 +125,10 @@ def _create_csv_and_get_callbacks(sn: str) -> Tuple[CSVProperties, CSVCallbacks] """Create CSV and get callback functions.""" run_id = data.create_run_id() test_name = data.create_test_name_from_file(__file__) - folder_path = data.create_folder_for_test_data(test_name) + test_path = data.create_folder_for_test_data(test_name) + run_path = data.create_folder_for_test_data(test_path / run_id) file_name = data.create_file_name(test_name=test_name, run_id=run_id, tag=sn) - csv_display_name = os.path.join(folder_path, file_name) + csv_display_name = os.path.join(run_path, file_name) print(f"CSV: {csv_display_name}") start_time = time.time() @@ -144,9 +145,11 @@ def _append_csv_data( data_list = [first_row_value] + data_list data_str = ",".join([str(d) for d in data_list]) if line_number is None: - data.append_data_to_file(test_name, file_name, data_str + "\n") + data.append_data_to_file(test_name, run_id, file_name, data_str + "\n") else: - data.insert_data_to_file(test_name, file_name, data_str + "\n", line_number) + data.insert_data_to_file( + test_name, run_id, file_name, data_str + "\n", line_number + ) return ( CSVProperties(id=run_id, name=test_name, path=csv_display_name), @@ -648,6 +651,7 @@ async def _run_xy_motion( XY_AXIS_SETTINGS = _creat_xy_axis_settings(arguments) LOG.info(XY_AXIS_SETTINGS) for setting in XY_AXIS_SETTINGS: + await api.home() print_motion_settings( "X", setting[Axis.X].max_speed, @@ -796,7 +800,7 @@ async def get_test_metadata( ) -> Tuple[str, str]: """Get the operator name and robot serial number.""" if arguments.no_input: - _operator = "None" + _operator = args.operator if isinstance(args.operator, str) else "None" _robot_id = api._backend.eeprom_data.serial_number if not _robot_id: ui.print_error("no serial number saved on this robot") @@ -810,7 +814,10 @@ async def get_test_metadata( if not _robot_id: ui.print_error("no serial number saved on this robot") _robot_id = input("enter ROBOT SERIAL number: ").strip() - _operator = input("enter OPERATOR name: ") + if isinstance(args.operator, str): + _operator = args.operator + else: + _operator = input("enter OPERATOR name: ") return (_operator, _robot_id) @@ -927,6 +934,7 @@ async def _main(arguments: argparse.Namespace) -> None: if __name__ == "__main__": parser = argparse.ArgumentParser() + parser.add_argument("--operator", type=str, default=None) parser.add_argument("--simulate", action="store_true") parser.add_argument("--cycles", type=int, default=20) parser.add_argument("--skip_bowtie", action="store_true") diff --git a/hardware-testing/hardware_testing/production_qc/z_stage_qc_ot3.py b/hardware-testing/hardware_testing/production_qc/z_stage_qc_ot3.py index 8861d1c590f..9f604674d8f 100644 --- a/hardware-testing/hardware_testing/production_qc/z_stage_qc_ot3.py +++ b/hardware-testing/hardware_testing/production_qc/z_stage_qc_ot3.py @@ -22,6 +22,8 @@ ) from opentrons_shared_data.errors.exceptions import MoveConditionNotMetError +from opentrons.config.advanced_settings import get_adv_setting, set_adv_setting +from opentrons_shared_data.robot.dev_types import RobotTypeEnum import logging @@ -58,7 +60,7 @@ force_output = [] -def _connect_to_mark10_fixture(simulate: bool) -> Mark10: +def _connect_to_mark10_fixture(simulate: bool) -> Union[Mark10, SimMark10]: """Connect to the force Gauge.""" if not simulate: fixture = Mark10.create(port="/dev/ttyUSB0") @@ -116,6 +118,8 @@ def _record_force(mark10: Mark10) -> None: """Record force in a separate thread.""" global thread_sensor global force_output + if mark10.is_simulator(): + force_output.append(0.0) # to make it pass analysis try: while thread_sensor: force = mark10.read_force() @@ -134,7 +138,7 @@ def analyze_force(force_output: List) -> Tuple[bool, float, float]: LOG.debug(f"analyze_force: {force_output}") # Check for first 0 to ensure gauge is zeroed - if not force_output[0] == 0: + if not force_output[0] == 0.0: return (False, 0, 0) max_force = max(force_output) @@ -145,7 +149,7 @@ def analyze_force(force_output: List) -> Tuple[bool, float, float]: return (False, 0, 0) count = 0 - sum = 0 + sum = 0.0 for force in force_output: if force > max_force / 2: count += 1 @@ -219,9 +223,9 @@ async def _force_gauge( await api.home([z_ax]) home_pos = await api.gantry_position(mount) LOG.info(f"Home Position: {home_pos}") - pre_test_pos = home_pos._replace(z=home_pos.z - 10) + pre_test_pos = home_pos._replace(z=home_pos.z - 110) LOG.info(f"Pre-Test Position: {pre_test_pos}") - press_pos = home_pos._replace(z=pre_test_pos.z - 12) + press_pos = home_pos._replace(z=pre_test_pos.z - 113) LOG.info(f"Press Position: {press_pos}") qc_pass = True @@ -241,6 +245,8 @@ async def _force_gauge( await api.move_to(mount=mount, abs_position=pre_test_pos) ui.print_header(f"Cycle {i+1}: Testing Current = {test_current}") + if mark10.is_simulator(): + mark10.set_simulation_force(test["F_MAX"]) # type: ignore[union-attr] TH = Thread(target=_record_force, args=(mark10,)) thread_sensor = True force_output = [] @@ -273,6 +279,7 @@ async def _force_gauge( # we expect a stall has happened during pick up, so we want to # update the motor estimation await api._update_position_estimation([Axis.by_mount(mount)]) + await api.refresh_positions() await api.move_to(mount=mount, abs_position=pre_test_pos) @@ -316,7 +323,8 @@ async def _main(arguments: argparse.Namespace) -> None: await api.set_gantry_load(api.gantry_load) report = _build_csv_report() - helpers_ot3.set_csv_report_meta_data_ot3(api, report) + dut = helpers_ot3.DeviceUnderTest.OTHER + helpers_ot3.set_csv_report_meta_data_ot3(api, report, dut=dut) for k, v in TEST_PARAMETERS.items(): report("TEST_PARAMETERS", k, [v]) @@ -355,6 +363,8 @@ async def _main(arguments: argparse.Namespace) -> None: ui.print_title("Test Done - PASSED") else: ui.print_title("Test Done - FAILED") + report.save_to_disk() + report.print_results() if __name__ == "__main__": @@ -362,4 +372,14 @@ async def _main(arguments: argparse.Namespace) -> None: arg_parser.add_argument("--simulate", action="store_true") arg_parser.add_argument("--skip_left", action="store_true") arg_parser.add_argument("--skip_right", action="store_true") - asyncio.run(_main(arg_parser.parse_args())) + old_stall_setting = get_adv_setting("disableStallDetection", RobotTypeEnum.FLEX) + try: + asyncio.run(set_adv_setting("disableStallDetection", True)) + asyncio.run(_main(arg_parser.parse_args())) + finally: + asyncio.run( + set_adv_setting( + "disableStallDetection", + False if old_stall_setting is None else old_stall_setting.value, + ) + ) diff --git a/hardware-testing/hardware_testing/protocols/check_by_eye.py b/hardware-testing/hardware_testing/protocols/check_by_eye.py new file mode 100644 index 00000000000..5839b195f40 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/check_by_eye.py @@ -0,0 +1,106 @@ +"""Check by Eye dot Py.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "check-by-eye-dot-py"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +PIP_CHANNELS = 8 +PIP_VOLUME = 50 +PIP_MOUNT = "left" +PIP_PUSH_OUT = 6 + +TIP_VOLUME = 50 + +# NOTE: pipette will loop through volumes +# circling back to the first, regardless of which well it is at +# so number of volumes can be any length you like (example: [1]) +TEST_VOLUMES = [1, 2, 3, 4] + +# FIXME: operator must LPC to liquid-surface in reservoir in order for this to work +# need to get liquid-probing working ASAP to fix this hack +ASPIRATE_DEPTH = -3.0 +DISPENSE_DEPTH = -1.5 + +ASPIRATE_FLOW_RATE = 35 # default for P50S and P50M is 35ul/sec +DISPENSE_FLOW_RATE = 57 # default for P50S and P50M is 57ul/sec + +ASPIRATE_PRE_DELAY = 1.0 +ASPIRATE_POST_DELAY = 1.0 +DISPENSE_PRE_DELAY = 0.0 +DISPENSE_POST_DELAY = 0.5 + +RESERVOIR_SLOT = "D1" +RESERVOIR_NAME = "nest_1_reservoir_195ml" +RESERVOIR_WELL = "A1" + +PLATE_NAME = "corning_96_wellplate_360ul_flat" + +RACK_AND_PLATE_SLOTS = [ # [rack, plate] + ["B1", "C1"], + # ["B2", "C2"], + # ["B3", "C3"], + # ["A1", "D2"], + # ["A2", "D3"] +] + +HEIGHT_OF_200UL_IN_PLATE_MM = 6.04 # height of 200ul in a Corning 96-well flat-bottom + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + pipette = ctx.load_instrument(f"flex_{PIP_CHANNELS}channel_{PIP_VOLUME}", PIP_MOUNT) + reservoir = ctx.load_labware(RESERVOIR_NAME, RESERVOIR_SLOT) + + combos = [ + { + "rack": ctx.load_labware( + f"opentrons_flex_96_tiprack_{TIP_VOLUME}uL", pair[0] + ), + "plate": ctx.load_labware(PLATE_NAME, pair[1]), + } + for pair in RACK_AND_PLATE_SLOTS + ] + + pipette.flow_rate.aspirate = ASPIRATE_FLOW_RATE + pipette.flow_rate.dispense = DISPENSE_FLOW_RATE + vol_cnt = 0 + for combo in combos: + plate = combo["plate"] + rack = combo["rack"] + num_trials = 12 if PIP_CHANNELS == 8 else 96 + for trial in range(num_trials): + # CHOOSE VOLUME + volume = TEST_VOLUMES[vol_cnt % len(TEST_VOLUMES)] + vol_cnt += 1 + + # CHOOSE WELL + column = (trial % 12) + 1 + row = "ABCDEFGH"[int(trial / 12)] + well_name = f"{row}{column}" + + # PICK-UP TIP + pipette.configure_for_volume(volume) + pipette.pick_up_tip(rack[well_name]) + + # ASPIRATE + aspirate_pos = reservoir[RESERVOIR_WELL].top(ASPIRATE_DEPTH) + pipette.move_to(aspirate_pos) + ctx.delay(seconds=ASPIRATE_PRE_DELAY) + pipette.aspirate(volume, aspirate_pos) + ctx.delay(seconds=ASPIRATE_POST_DELAY) + pipette.move_to(plate[well_name].top(5)) + ctx.pause() # visual check + + # DISPENSE + dispense_pos = plate[well_name].bottom( + HEIGHT_OF_200UL_IN_PLATE_MM + DISPENSE_DEPTH + ) + pipette.move_to(dispense_pos) + ctx.delay(seconds=DISPENSE_PRE_DELAY) + pipette.dispense(volume, dispense_pos, push_out=PIP_PUSH_OUT) + ctx.delay(seconds=DISPENSE_POST_DELAY) + pipette.move_to(plate[well_name].top(5)) + ctx.pause() # visual check + + # DROP TIP + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/__init__.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/__init__.py new file mode 100644 index 00000000000..ae3eb9f71b7 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/__init__.py @@ -0,0 +1 @@ +"""Volumetric LPC.""" diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/__init__.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/__init__.py new file mode 100644 index 00000000000..afcf01e5ad2 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/__init__.py @@ -0,0 +1 @@ +"""Gravimetric.""" diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_50ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_96.py similarity index 76% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_50ul_tip.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_96.py index 7a255d50749..cbf1b8f5e5a 100644 --- a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_50ul_tip.py +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_96.py @@ -1,13 +1,15 @@ """Photometric OT3 P1000.""" from opentrons.protocol_api import ProtocolContext -metadata = {"protocolName": "gravimetric-ot3-p1000-96-50ul-tip"} +metadata = {"protocolName": "gravimetric-ot3-p1000-96"} requirements = {"robotType": "Flex", "apiLevel": "2.15"} SLOT_SCALE = 4 SLOTS_TIPRACK = { # TODO: add slot 12 when tipracks are disposable 50: [2, 3, 5, 6, 7, 8, 9, 10, 11], + 200: [2, 3, 5, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration + 1000: [2, 3, 5, 6, 7, 8, 9, 10, 11], # NOTE: ignored during calibration } LABWARE_ON_SCALE = "nest_1_reservoir_195ml" @@ -18,6 +20,7 @@ def run(ctx: ProtocolContext) -> None: ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL_adp", slot) for size, slots in SLOTS_TIPRACK.items() for slot in slots + if size == 50 # only calibrate 50ul tip-racks ] scale_labware = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) pipette = ctx.load_instrument("p1000_96", "left") diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_200ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi.py similarity index 86% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_200ul_tip.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi.py index aa774a610bf..81075a111ed 100644 --- a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_200ul_tip.py +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi.py @@ -1,12 +1,14 @@ """Gravimetric OT3 P1000.""" from opentrons.protocol_api import ProtocolContext -metadata = {"protocolName": "gravimetric-ot3-p1000-multi-200ul-tip"} +metadata = {"protocolName": "gravimetric-ot3-p1000-multi"} requirements = {"robotType": "Flex", "apiLevel": "2.15"} SLOT_SCALE = 4 SLOTS_TIPRACK = { - 200: [5, 6, 8, 9], + 50: [2, 6, 7, 8], + 200: [10, 5], + 1000: [3, 9], } LABWARE_ON_SCALE = "radwag_pipette_calibration_vial" diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_1000ul_tip_increment.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi_1000ul_tip_increment.py similarity index 100% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_1000ul_tip_increment.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi_1000ul_tip_increment.py diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_200ul_tip_increment.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi_200ul_tip_increment.py similarity index 100% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_200ul_tip_increment.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi_200ul_tip_increment.py diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_50ul_tip_increment.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi_50ul_tip_increment.py similarity index 100% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_50ul_tip_increment.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_multi_50ul_tip_increment.py diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_single.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_single.py similarity index 100% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_single.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p1000_single.py diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p50_multi_50ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p50_multi.py similarity index 96% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p50_multi_50ul_tip.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p50_multi.py index 1ed27784f1f..bfd97c6a669 100644 --- a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p50_multi_50ul_tip.py +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p50_multi.py @@ -6,7 +6,7 @@ SLOT_SCALE = 4 SLOTS_TIPRACK = { - 50: [5, 6, 8, 9], + 50: [2, 3, 5, 6, 8, 9], } LABWARE_ON_SCALE = "radwag_pipette_calibration_vial" diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p50_multi_50ul_tip_increment.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p50_multi_50ul_tip_increment.py similarity index 100% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p50_multi_50ul_tip_increment.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p50_multi_50ul_tip_increment.py diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p50_single.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p50_single.py similarity index 100% rename from hardware-testing/hardware_testing/protocols/gravimetric_ot3_p50_single.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/gravimetric/gravimetric_ot3_p50_single.py diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/__init__.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/__init__.py new file mode 100644 index 00000000000..9c95b7ba14a --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/__init__.py @@ -0,0 +1 @@ +"""Photometric.""" diff --git a/hardware-testing/hardware_testing/protocols/photometric_ot3_p1000_96_50ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_96.py similarity index 78% rename from hardware-testing/hardware_testing/protocols/photometric_ot3_p1000_96_50ul_tip.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_96.py index 11140a065f9..4be97d86289 100644 --- a/hardware-testing/hardware_testing/protocols/photometric_ot3_p1000_96_50ul_tip.py +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_96.py @@ -1,11 +1,12 @@ """Photometric OT3 P1000.""" from opentrons.protocol_api import ProtocolContext -metadata = {"protocolName": "photometric-ot3-p1000-96-50ul-tip"} +metadata = {"protocolName": "photometric-ot3-p1000-96"} requirements = {"robotType": "Flex", "apiLevel": "2.15"} SLOTS_TIPRACK = { 50: [5, 6, 8, 9, 11], + 200: [5, 6, 8, 9, 11], # NOTE: ignoring this tip-rack during run() method } SLOT_PLATE = 3 SLOT_RESERVOIR = 2 @@ -23,10 +24,11 @@ def run(ctx: ProtocolContext) -> None: ) for size, slots in SLOTS_TIPRACK.items() for slot in slots + if size == 50 # only calibrate 50ul tips for 96ch test ] reservoir = ctx.load_labware(RESERVOIR_LABWARE, SLOT_RESERVOIR) plate = ctx.load_labware(PHOTOPLATE_LABWARE, SLOT_PLATE) - pipette = ctx.load_instrument("p1000_96", "left") + pipette = ctx.load_instrument("flex_96channel_1000", "left") for rack in tipracks: pipette.pick_up_tip(rack["A1"]) pipette.aspirate(10, reservoir["A1"].top()) diff --git a/hardware-testing/hardware_testing/protocols/photometric_ot3_p1000_96_200ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_multi.py similarity index 66% rename from hardware-testing/hardware_testing/protocols/photometric_ot3_p1000_96_200ul_tip.py rename to hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_multi.py index 02b66459ae0..99ec976f7d9 100644 --- a/hardware-testing/hardware_testing/protocols/photometric_ot3_p1000_96_200ul_tip.py +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_multi.py @@ -1,16 +1,18 @@ -"""Photometric OT3 P1000.""" +"""Photometric OT3 P50.""" from opentrons.protocol_api import ProtocolContext -metadata = {"protocolName": "photometric-ot3-p1000-96-200ul-tip"} +metadata = {"protocolName": "photometric-ot3-p50-multi"} requirements = {"robotType": "Flex", "apiLevel": "2.15"} SLOTS_TIPRACK = { - 200: [5, 6, 8, 9, 11], + 50: [3], + 200: [6], + 1000: [9], } -SLOT_PLATE = 3 -SLOT_RESERVOIR = 2 +SLOT_PLATE = 2 +SLOT_RESERVOIR = 5 -RESERVOIR_LABWARE = "nest_1_reservoir_195ml" +RESERVOIR_LABWARE = "nest_12_reservoir_15ml" PHOTOPLATE_LABWARE = "corning_96_wellplate_360ul_flat" @@ -18,15 +20,13 @@ def run(ctx: ProtocolContext) -> None: """Run.""" tipracks = [ # FIXME: use official tip-racks once available - ctx.load_labware( - f"opentrons_flex_96_tiprack_{size}uL_adp", slot, namespace="custom_beta" - ) + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) for size, slots in SLOTS_TIPRACK.items() for slot in slots ] reservoir = ctx.load_labware(RESERVOIR_LABWARE, SLOT_RESERVOIR) plate = ctx.load_labware(PHOTOPLATE_LABWARE, SLOT_PLATE) - pipette = ctx.load_instrument("p1000_96", "left") + pipette = ctx.load_instrument("p1000_multi_gen3", "left") for rack in tipracks: pipette.pick_up_tip(rack["A1"]) pipette.aspirate(10, reservoir["A1"].top()) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_single.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_single.py new file mode 100644 index 00000000000..448c737d306 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p1000_single.py @@ -0,0 +1,34 @@ +"""Photometric OT3 P50.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "photometric-ot3-p50-single"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOTS_TIPRACK = { + 50: [3], + 200: [6], + 1000: [9], +} +SLOT_PLATE = 2 +SLOT_RESERVOIR = 5 + +RESERVOIR_LABWARE = "nest_96_wellplate_2ml_deep" +PHOTOPLATE_LABWARE = "corning_96_wellplate_360ul_flat" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + # FIXME: use official tip-racks once available + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + reservoir = ctx.load_labware(RESERVOIR_LABWARE, SLOT_RESERVOIR) + plate = ctx.load_labware(PHOTOPLATE_LABWARE, SLOT_PLATE) + pipette = ctx.load_instrument("p1000_single_gen3", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, reservoir["A1"].top()) + pipette.dispense(10, plate["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p50_multi.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p50_multi.py new file mode 100644 index 00000000000..73cc08e07e2 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p50_multi.py @@ -0,0 +1,30 @@ +"""Photometric OT3 P50.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "photometric-ot3-p50-multi"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOTS_TIPRACK = {50: [3]} +SLOT_PLATE = 2 +SLOT_RESERVOIR = 5 + +RESERVOIR_LABWARE = "nest_12_reservoir_15ml" +PHOTOPLATE_LABWARE = "corning_96_wellplate_360ul_flat" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + # FIXME: use official tip-racks once available + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + reservoir = ctx.load_labware(RESERVOIR_LABWARE, SLOT_RESERVOIR) + plate = ctx.load_labware(PHOTOPLATE_LABWARE, SLOT_PLATE) + pipette = ctx.load_instrument("p50_multi_gen3", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, reservoir["A1"].top()) + pipette.dispense(10, plate["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p50_single.py b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p50_single.py new file mode 100644 index 00000000000..ad646d88b0b --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/gravimetric_lpc/photometric/photometric_ot3_p50_single.py @@ -0,0 +1,32 @@ +"""Photometric OT3 P50.""" +from opentrons.protocol_api import ProtocolContext + +metadata = {"protocolName": "photometric-ot3-p50-single"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +SLOTS_TIPRACK = { + 50: [3], +} +SLOT_PLATE = 2 +SLOT_RESERVOIR = 5 + +RESERVOIR_LABWARE = "nest_96_wellplate_2ml_deep" +PHOTOPLATE_LABWARE = "corning_96_wellplate_360ul_flat" + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + tipracks = [ + # FIXME: use official tip-racks once available + ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) + for size, slots in SLOTS_TIPRACK.items() + for slot in slots + ] + reservoir = ctx.load_labware(RESERVOIR_LABWARE, SLOT_RESERVOIR) + plate = ctx.load_labware(PHOTOPLATE_LABWARE, SLOT_PLATE) + pipette = ctx.load_instrument("p50_single_gen3", "left") + for rack in tipracks: + pipette.pick_up_tip(rack["A1"]) + pipette.aspirate(10, reservoir["A1"].top()) + pipette.dispense(10, plate["A1"].top()) + pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_1000ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_1000ul_tip.py deleted file mode 100644 index 992fe0c86fe..00000000000 --- a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_1000ul_tip.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Photometric OT3 P1000.""" -from opentrons.protocol_api import ProtocolContext - -metadata = {"protocolName": "gravimetric-ot3-p1000-96-1000ul-tip"} -requirements = {"robotType": "Flex", "apiLevel": "2.15"} - -SLOT_SCALE = 4 -SLOTS_TIPRACK = { - # TODO: add slot 12 when tipracks are disposable - 1000: [2, 3, 5, 6, 7, 8, 9, 10, 11], -} -LABWARE_ON_SCALE = "nest_1_reservoir_195ml" - - -def run(ctx: ProtocolContext) -> None: - """Run.""" - tipracks = [ - ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL_adp", slot) - for size, slots in SLOTS_TIPRACK.items() - for slot in slots - ] - scale_labware = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) - pipette = ctx.load_instrument("p1000_96", "left") - for rack in tipracks: - pipette.pick_up_tip(rack["A1"]) - pipette.aspirate(10, scale_labware["A1"].top()) - pipette.dispense(10, scale_labware["A1"].top()) - pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_200ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_200ul_tip.py deleted file mode 100644 index 9ee28db7248..00000000000 --- a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_96_200ul_tip.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Photometric OT3 P1000.""" -from opentrons.protocol_api import ProtocolContext - -metadata = {"protocolName": "gravimetric-ot3-p1000-96-200ul-tip"} -requirements = {"robotType": "OT-3", "apiLevel": "2.15"} - -SLOT_SCALE = 4 -SLOTS_TIPRACK = { - # TODO: add slot 12 when tipracks are disposable - 200: [2, 3, 5, 6, 7, 8, 9, 10, 11], -} -LABWARE_ON_SCALE = "nest_1_reservoir_195ml" - - -def run(ctx: ProtocolContext) -> None: - """Run.""" - tipracks = [ - ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL_adp", slot) - for size, slots in SLOTS_TIPRACK.items() - for slot in slots - ] - scale_labware = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) - pipette = ctx.load_instrument("p1000_96", "left") - for rack in tipracks: - pipette.pick_up_tip(rack["A1"]) - pipette.aspirate(10, scale_labware["A1"].top()) - pipette.dispense(10, scale_labware["A1"].top()) - pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_1000ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_1000ul_tip.py deleted file mode 100644 index efdd9b2901d..00000000000 --- a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_1000ul_tip.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Gravimetric OT3 P1000.""" -from opentrons.protocol_api import ProtocolContext - -metadata = {"protocolName": "gravimetric-ot3-p1000-multi-1000ul-tip"} -requirements = {"robotType": "Flex", "apiLevel": "2.15"} - -SLOT_SCALE = 4 -SLOTS_TIPRACK = { - 1000: [5, 6, 8, 9], -} -LABWARE_ON_SCALE = "radwag_pipette_calibration_vial" - - -def run(ctx: ProtocolContext) -> None: - """Run.""" - tipracks = [ - ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) - for size, slots in SLOTS_TIPRACK.items() - for slot in slots - ] - vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) - pipette = ctx.load_instrument("flex_8channel_1000", "left") - for rack in tipracks: - pipette.pick_up_tip(rack["A1"]) - pipette.aspirate(10, vial["A1"].top()) - pipette.dispense(10, vial["A1"].top()) - pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_50ul_tip.py b/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_50ul_tip.py deleted file mode 100644 index 6d690890b79..00000000000 --- a/hardware-testing/hardware_testing/protocols/gravimetric_ot3_p1000_multi_50ul_tip.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Gravimetric OT3 P1000.""" -from opentrons.protocol_api import ProtocolContext - -metadata = {"protocolName": "gravimetric-ot3-p1000-multi-50ul-tip"} -requirements = {"robotType": "Flex", "apiLevel": "2.15"} - -SLOT_SCALE = 4 -SLOTS_TIPRACK = { - 50: [5, 6, 8, 9], -} -LABWARE_ON_SCALE = "radwag_pipette_calibration_vial" - - -def run(ctx: ProtocolContext) -> None: - """Run.""" - tipracks = [ - ctx.load_labware(f"opentrons_flex_96_tiprack_{size}uL", slot) - for size, slots in SLOTS_TIPRACK.items() - for slot in slots - ] - vial = ctx.load_labware(LABWARE_ON_SCALE, SLOT_SCALE) - pipette = ctx.load_instrument("flex_8channel_1000", "left") - for rack in tipracks: - pipette.pick_up_tip(rack["A1"]) - pipette.aspirate(10, vial["A1"].top()) - pipette.dispense(10, vial["A1"].top()) - pipette.drop_tip(home_after=False) diff --git a/hardware-testing/hardware_testing/protocols/installation_qualification/__init__.py b/hardware-testing/hardware_testing/protocols/installation_qualification/__init__.py new file mode 100644 index 00000000000..3b1a2088920 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/installation_qualification/__init__.py @@ -0,0 +1 @@ +"""Installation Qualification.""" diff --git a/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p1000_multi_200ul.py b/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p1000_multi_200ul.py new file mode 100644 index 00000000000..1adc97a6a28 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p1000_multi_200ul.py @@ -0,0 +1,398 @@ +"""Flex IQ: P1000 Multi 200ul.""" +from math import pi, ceil +from typing import List, Optional, Dict, Tuple + +from opentrons.protocol_api import ProtocolContext, InstrumentContext, Labware + +############################################## +# EDIT - START # +############################################## + +metadata = {"protocolName": "Flex IQ: P1000 Multi 200ul"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +ALLOW_TEST_PIPETTE_TO_TRANSFER_DILUENT = False +RETURN_TIP = False +DILUENT_WELLS = ["A11", "A12"] + +TEST_VOLUME = 200 +TEST_PUSH_OUT = 15 +TEST_PIPETTE = "flex_8channel_1000" +TEST_TIPS = "opentrons_flex_96_tiprack_200uL" +TEST_SOURCES = [ + { + "source": "A1", + "destinations": ["A1", "A2", "A3", "A4", "A5", "A6"], + }, + { + "source": "A2", + "destinations": ["A7", "A8", "A9", "A10", "A11", "A12"], + }, +] + +############################################## +# EDIT - END # +############################################## + +SUBMERGE_MM = { + "aspirate": 3.0, + "dispense": 1.0, +} +RETRACT_MM = 5.0 +MIN_MM_FROM_BOTTOM = 1.0 + +DILUENT_PUSH_OUT = 15 + +TOUCH_TIP_SPEED = 30 +TOUCH_TIP_DEPTH = -1 + +DELAY_ASPIRATE = 1.0 +DELAY_DISPENSE = 0.5 + +_DYE_MAP: Dict[str, Dict[str, float]] = { + "Range-HV": {"min": 200.1, "max": 350}, + "Range-A": {"min": 50, "max": 200}, + "Range-B": {"min": 10, "max": 49.99}, + "Range-C": {"min": 2, "max": 9.999}, + "Range-D": {"min": 1, "max": 1.999}, + "Range-E": {"min": 0.1, "max": 0.99}, +} + + +def _get_dye_type(volume: float) -> str: + for dye in _DYE_MAP.keys(): + if _DYE_MAP[dye]["min"] <= volume <= _DYE_MAP[dye]["max"]: + return dye + raise ValueError(f"volume {volume} is outside of the available dye range") + + +def _get_diluent_volume() -> float: + return max(200 - TEST_VOLUME, 0) + + +SRC_LABWARE_BY_CHANNELS = { + 1: "nest_12_reservoir_15ml", + 8: "nest_12_reservoir_15ml", + 96: "nest_1_reservoir_195ml", +} + +MIN_VOL_SRC = { + "nest_96_wellplate_2ml_deep": 500, + "nest_12_reservoir_15ml": 3000, + "nest_1_reservoir_195ml": 30000, +} + + +class _LiquidHeightInFlatBottomWell: + def __init__( + self, + bottom_diameter: float, + top_diameter: float, + height: float, + resolution_mm: float = 0.1, + ) -> None: + self._bottom_radius = bottom_diameter / 2 + self._top_radius = top_diameter / 2 + self._height = height + self._resolution_mm = resolution_mm + + def _volume_of_frustum(self, surface_height: float, surface_radius: float) -> float: + """Calculate the volume of a frustum given its height and radii.""" + a = pi * self._bottom_radius * surface_radius + b = pi * surface_radius**2 + c = pi * self._bottom_radius**2 + return (a + b + c) * (surface_height / 3) + + def height_from_volume(self, volume: float) -> float: + """Given the volume, compute the height of the liquid in the well.""" + _rad_diff = self._top_radius - self._bottom_radius + low, high = 0.0, self._height + while high - low > self._resolution_mm: + mid = (low + high) / 2 + r_mid = self._bottom_radius + (mid / self._height) * _rad_diff + if self._volume_of_frustum(mid, r_mid) < volume: + low = mid + else: + high = mid + return (low + high) / 2 + + def volume_from_height(self, height: float) -> float: + """Given the height, compute the volume of the liquid in the well.""" + _rel_height = height / self._height + _rad_diff = self._top_radius - self._bottom_radius + surface_radius = self._bottom_radius + _rad_diff * _rel_height + return self._volume_of_frustum(height, surface_radius) + + +LIQUID_HEIGHT_LOOKUP: Dict[str, List[Tuple[float, float]]] = { + "nest_1_reservoir_195ml": [(0, 0), (195000, 25)], + "nest_12_reservoir_15ml": [ + (0, 0), + (3000, 6.0), + (3500, 7.0), + (4000, 8.0), + (5500, 10.5), + (8000, 14.7), + (10000, 18.0), + (12600, 22.5), + (15000, 26.85), # full depth of well + ], + "nest_96_wellplate_2ml_deep": [ + (0, 0), + (2000, 38), # FIXME: create real lookup table + ], +} + + +def _convert_ul_in_well_to_height_in_well(load_name: str, ul: float) -> float: + if load_name in LIQUID_HEIGHT_LOOKUP: + lookup = LIQUID_HEIGHT_LOOKUP[load_name] + for i in range(len(lookup) - 1): + low = lookup[i] + high = lookup[i + 1] + if low[0] <= ul <= high[0]: + ul_scale = (ul - low[0]) / (high[0] - low[0]) + return (ul_scale * (high[1] - low[1])) + low[1] + elif load_name == "corning_96_wellplate_360ul_flat": + well = _LiquidHeightInFlatBottomWell( + bottom_diameter=6.35, top_diameter=6.858, height=10.668 + ) + return well.height_from_volume(ul) + raise ValueError(f"unable to find height of {ul} ul in {load_name}") + + +def _build_diluent_info() -> Optional[Tuple[float, Dict[str, List[str]]]]: + diluent_vol = _get_diluent_volume() + if diluent_vol <= 0: + return None + target_cols = set( + [int(dst[1:]) for test in TEST_SOURCES for dst in test["destinations"]] + ) + dest = [f"A{col}" for col in sorted(list(target_cols))] + num_dest_per_src = ceil(len(dest) / len(DILUENT_WELLS)) + src_to_dest = { + src: dest[i * num_dest_per_src : i * num_dest_per_src + num_dest_per_src] + for i, src in enumerate(DILUENT_WELLS) + } + return diluent_vol, src_to_dest + + +def _start_volumes_per_trial( + volume: float, load_name: str, channels: int, trials: int +) -> List[float]: + ul_per_aspirate = volume * channels + ul_per_run = ul_per_aspirate * trials + ul_at_start = ul_per_run + MIN_VOL_SRC[load_name] + return [ul_at_start - (ul_per_aspirate * i) for i in range(trials)] + + +def _end_volumes_per_trial( + volume: float, load_name: str, channels: int, trials: int +) -> List[float]: + return [ + ul - (volume * channels) + for ul in _start_volumes_per_trial(volume, load_name, channels, trials) + ] + + +def _dye_start_volumes_per_trial( + load_name: str, channels: int, trials: int +) -> List[float]: + return _start_volumes_per_trial(TEST_VOLUME, load_name, channels, trials) + + +def _diluent_start_volumes_per_trial(load_name: str, trials: int) -> List[float]: + return _start_volumes_per_trial(_get_diluent_volume(), load_name, 8, trials) + + +def _assign_starting_volumes_dye( + ctx: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, +) -> None: + dye = ctx.define_liquid( + name=_get_dye_type(TEST_VOLUME), + description=f"Artel MVS Dye: {_get_dye_type(TEST_VOLUME)}", + display_color="#FF0000", + ) + for test in TEST_SOURCES: + src_ul_per_trial = _dye_start_volumes_per_trial( + reservoir.load_name, pipette.channels, len(test["destinations"]) + ) + first_trial_ul = src_ul_per_trial[0] + reservoir[str(test["source"])].load_liquid(dye, first_trial_ul) + + +def _assign_starting_volumes_diluent( + ctx: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, +) -> None: + diluent_info = _build_diluent_info() + if not diluent_info: + return + diluent_vol, src_to_dst = diluent_info + diluent = ctx.define_liquid( + name="Diluent", + description="Diluent", + display_color="#0000FF", + ) + for source, destinations in src_to_dst.items(): + src_ul_per_trial = _diluent_start_volumes_per_trial( + reservoir.load_name, len(destinations) + ) + if not src_ul_per_trial: + continue + first_trial_ul = src_ul_per_trial[0] + reservoir[source].load_liquid(diluent, first_trial_ul) + + +def _transfer( + ctx: ProtocolContext, + volume: float, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, + source: str, + destinations: List[str], + same_tip: bool = False, + push_out: Optional[float] = None, + touch_tip: bool = False, + volume_already_in_plate: float = 0, +) -> None: + end_volumes = _end_volumes_per_trial( + volume, reservoir.load_name, pipette.channels, len(destinations) + ) + src_heights = [ + _convert_ul_in_well_to_height_in_well(reservoir.load_name, ul) + for ul in end_volumes + ] + volume_in_plate = volume + volume_already_in_plate + dst_height = _convert_ul_in_well_to_height_in_well(plate.load_name, volume_in_plate) + if same_tip and not pipette.has_tip: + pipette.configure_for_volume(volume) + pipette.pick_up_tip() + for dst_name, height_src in zip(destinations, src_heights): + # calculate pipetting positions + aspirate_pos = reservoir[source].bottom( + max(height_src - SUBMERGE_MM["aspirate"], MIN_MM_FROM_BOTTOM) + ) + dispense_pos = plate[dst_name].bottom( + max(dst_height - SUBMERGE_MM["dispense"], MIN_MM_FROM_BOTTOM) + ) + blow_out_pos = plate[dst_name].bottom( + max(dst_height + RETRACT_MM, MIN_MM_FROM_BOTTOM) + ) + # transfer + if not same_tip: + pipette.configure_for_volume(volume) + pipette.pick_up_tip(tips.next_tip(pipette.channels)) + if pipette.current_volume > 0: + pipette.dispense(pipette.current_volume, reservoir[source].top()) + pipette.aspirate(volume, aspirate_pos) + ctx.delay(seconds=DELAY_ASPIRATE) + pipette.dispense(volume, dispense_pos, push_out=push_out) + ctx.delay(seconds=DELAY_DISPENSE) + pipette.blow_out(blow_out_pos) + if touch_tip: + pipette.touch_tip(speed=TOUCH_TIP_SPEED, v_offset=TOUCH_TIP_DEPTH) + pipette.aspirate(1, blow_out_pos) # trailing air-gap to avoid droplets + if not same_tip: + if RETURN_TIP: + pipette.return_tip() + else: + pipette.drop_tip() + + +def _transfer_diluent( + ctx: ProtocolContext, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, +) -> None: + diluent_info = _build_diluent_info() + if not diluent_info: + return + diluent_vol, dest_per_src = diluent_info + pipette.configure_for_volume(diluent_vol) + pipette.pick_up_tip(tips.next_tip(pipette.channels)) + for src, destinations in dest_per_src.items(): + _transfer( + ctx, + diluent_vol, + pipette, + tips, + reservoir, + plate, + src, # type: ignore[arg-type] + destinations, # type: ignore[arg-type] + same_tip=True, + push_out=DILUENT_PUSH_OUT, + touch_tip=False, + volume_already_in_plate=0, + ) + if RETURN_TIP: + pipette.return_tip() + else: + pipette.drop_tip() + + +def _transfer_dye( + ctx: ProtocolContext, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, +) -> None: + for test in TEST_SOURCES: + _transfer( + ctx, + TEST_VOLUME, + pipette, + tips, + reservoir, + plate, + test["source"], # type: ignore[arg-type] + test["destinations"], # type: ignore[arg-type] + push_out=TEST_PUSH_OUT, + touch_tip=True, + volume_already_in_plate=_get_diluent_volume(), + ) + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + # the target plate, handle with great care + plate = ctx.load_labware("corning_96_wellplate_360ul_flat", "D2") + + # dye tips, pipette, and reservoir + dye_tips = ctx.load_labware(TEST_TIPS, "B2") + dye_pipette = ctx.load_instrument(TEST_PIPETTE, "left") + dye_reservoir = ctx.load_labware( + SRC_LABWARE_BY_CHANNELS[dye_pipette.channels], "C2" + ) + _assign_starting_volumes_dye(ctx, dye_pipette, dye_reservoir) + + # diluent tips, pipette, and reservoir + if _get_diluent_volume(): + diluent_tips = ctx.load_labware("opentrons_flex_96_tiprack_200uL", "B3") + if "p1000_multi" in TEST_PIPETTE and ALLOW_TEST_PIPETTE_TO_TRANSFER_DILUENT: + diluent_pipette = dye_pipette # share the 8ch pipette + else: + diluent_pipette = ctx.load_instrument("flex_8channel_1000", "right") + diluent_labware = SRC_LABWARE_BY_CHANNELS[diluent_pipette.channels] + if dye_reservoir.load_name == diluent_labware: + reservoir_diluent = dye_reservoir # share the 12-row reservoir + else: + reservoir_diluent = ctx.load_labware( + SRC_LABWARE_BY_CHANNELS[diluent_pipette.channels], "C3" + ) + _assign_starting_volumes_diluent(ctx, dye_pipette, reservoir_diluent) + + # transfer diluent + _transfer_diluent(ctx, diluent_pipette, diluent_tips, reservoir_diluent, plate) + + # transfer dye + _transfer_dye(ctx, dye_pipette, dye_tips, dye_reservoir, plate) diff --git a/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p50_multi_1ul.py b/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p50_multi_1ul.py new file mode 100644 index 00000000000..0237ea188d1 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p50_multi_1ul.py @@ -0,0 +1,407 @@ +"""Flex IQ: P50 Multi 1ul.""" +from math import pi, ceil +from typing import List, Optional, Dict, Tuple + +from opentrons.protocol_api import ProtocolContext, InstrumentContext, Labware + +############################################## +# EDIT - START # +############################################## + +metadata = {"protocolName": "Flex IQ: P50 Multi 1ul"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +ALLOW_TEST_PIPETTE_TO_TRANSFER_DILUENT = False +RETURN_TIP = False +DILUENT_WELLS = ["A11", "A12"] + +TEST_VOLUME = 1 +TEST_PUSH_OUT = 6 +TEST_PIPETTE = "flex_8channel_50" +TEST_TIPS = "opentrons_flex_96_tiprack_50uL" +TEST_SOURCES = [ + { + "source": "A4", + "destinations": [ + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "A10", + "A11", + "A12", + ], + }, +] + +############################################## +# EDIT - END # +############################################## + +SUBMERGE_MM = { + "aspirate": 3.0, + "dispense": 1.0, +} +RETRACT_MM = 5.0 +MIN_MM_FROM_BOTTOM = 1.0 + +DILUENT_PUSH_OUT = 15 + +TOUCH_TIP_SPEED = 30 +TOUCH_TIP_DEPTH = -1 + +DELAY_ASPIRATE = 1.0 +DELAY_DISPENSE = 0.5 + +_DYE_MAP: Dict[str, Dict[str, float]] = { + "Range-HV": {"min": 200.1, "max": 350}, + "Range-A": {"min": 50, "max": 200}, + "Range-B": {"min": 10, "max": 49.99}, + "Range-C": {"min": 2, "max": 9.999}, + "Range-D": {"min": 1, "max": 1.999}, + "Range-E": {"min": 0.1, "max": 0.99}, +} + + +def _get_dye_type(volume: float) -> str: + for dye in _DYE_MAP.keys(): + if _DYE_MAP[dye]["min"] <= volume <= _DYE_MAP[dye]["max"]: + return dye + raise ValueError(f"volume {volume} is outside of the available dye range") + + +def _get_diluent_volume() -> float: + return max(200 - TEST_VOLUME, 0) + + +SRC_LABWARE_BY_CHANNELS = { + 1: "nest_12_reservoir_15ml", + 8: "nest_12_reservoir_15ml", + 96: "nest_1_reservoir_195ml", +} + +MIN_VOL_SRC = { + "nest_96_wellplate_2ml_deep": 500, + "nest_12_reservoir_15ml": 3000, + "nest_1_reservoir_195ml": 30000, +} + + +class _LiquidHeightInFlatBottomWell: + def __init__( + self, + bottom_diameter: float, + top_diameter: float, + height: float, + resolution_mm: float = 0.1, + ) -> None: + self._bottom_radius = bottom_diameter / 2 + self._top_radius = top_diameter / 2 + self._height = height + self._resolution_mm = resolution_mm + + def _volume_of_frustum(self, surface_height: float, surface_radius: float) -> float: + """Calculate the volume of a frustum given its height and radii.""" + a = pi * self._bottom_radius * surface_radius + b = pi * surface_radius**2 + c = pi * self._bottom_radius**2 + return (a + b + c) * (surface_height / 3) + + def height_from_volume(self, volume: float) -> float: + """Given the volume, compute the height of the liquid in the well.""" + _rad_diff = self._top_radius - self._bottom_radius + low, high = 0.0, self._height + while high - low > self._resolution_mm: + mid = (low + high) / 2 + r_mid = self._bottom_radius + (mid / self._height) * _rad_diff + if self._volume_of_frustum(mid, r_mid) < volume: + low = mid + else: + high = mid + return (low + high) / 2 + + def volume_from_height(self, height: float) -> float: + """Given the height, compute the volume of the liquid in the well.""" + _rel_height = height / self._height + _rad_diff = self._top_radius - self._bottom_radius + surface_radius = self._bottom_radius + _rad_diff * _rel_height + return self._volume_of_frustum(height, surface_radius) + + +LIQUID_HEIGHT_LOOKUP: Dict[str, List[Tuple[float, float]]] = { + "nest_1_reservoir_195ml": [(0, 0), (195000, 25)], + "nest_12_reservoir_15ml": [ + (0, 0), + (3000, 6.0), + (3500, 7.0), + (4000, 8.0), + (5500, 10.5), + (8000, 14.7), + (10000, 18.0), + (12600, 22.5), + (15000, 26.85), # full depth of well + ], + "nest_96_wellplate_2ml_deep": [ + (0, 0), + (2000, 38), # FIXME: create real lookup table + ], +} + + +def _convert_ul_in_well_to_height_in_well(load_name: str, ul: float) -> float: + if load_name in LIQUID_HEIGHT_LOOKUP: + lookup = LIQUID_HEIGHT_LOOKUP[load_name] + for i in range(len(lookup) - 1): + low = lookup[i] + high = lookup[i + 1] + if low[0] <= ul <= high[0]: + ul_scale = (ul - low[0]) / (high[0] - low[0]) + return (ul_scale * (high[1] - low[1])) + low[1] + elif load_name == "corning_96_wellplate_360ul_flat": + well = _LiquidHeightInFlatBottomWell( + bottom_diameter=6.35, top_diameter=6.858, height=10.668 + ) + return well.height_from_volume(ul) + raise ValueError(f"unable to find height of {ul} ul in {load_name}") + + +def _build_diluent_info() -> Optional[Tuple[float, Dict[str, List[str]]]]: + diluent_vol = _get_diluent_volume() + if diluent_vol <= 0: + return None + target_cols = set( + [int(dst[1:]) for test in TEST_SOURCES for dst in test["destinations"]] + ) + dest = [f"A{col}" for col in sorted(list(target_cols))] + num_dest_per_src = ceil(len(dest) / len(DILUENT_WELLS)) + src_to_dest = { + src: dest[i * num_dest_per_src : i * num_dest_per_src + num_dest_per_src] + for i, src in enumerate(DILUENT_WELLS) + } + return diluent_vol, src_to_dest + + +def _start_volumes_per_trial( + volume: float, load_name: str, channels: int, trials: int +) -> List[float]: + ul_per_aspirate = volume * channels + ul_per_run = ul_per_aspirate * trials + ul_at_start = ul_per_run + MIN_VOL_SRC[load_name] + return [ul_at_start - (ul_per_aspirate * i) for i in range(trials)] + + +def _end_volumes_per_trial( + volume: float, load_name: str, channels: int, trials: int +) -> List[float]: + return [ + ul - (volume * channels) + for ul in _start_volumes_per_trial(volume, load_name, channels, trials) + ] + + +def _dye_start_volumes_per_trial( + load_name: str, channels: int, trials: int +) -> List[float]: + return _start_volumes_per_trial(TEST_VOLUME, load_name, channels, trials) + + +def _diluent_start_volumes_per_trial(load_name: str, trials: int) -> List[float]: + return _start_volumes_per_trial(_get_diluent_volume(), load_name, 8, trials) + + +def _assign_starting_volumes_dye( + ctx: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, +) -> None: + dye = ctx.define_liquid( + name=_get_dye_type(TEST_VOLUME), + description=f"Artel MVS Dye: {_get_dye_type(TEST_VOLUME)}", + display_color="#FF0000", + ) + for test in TEST_SOURCES: + src_ul_per_trial = _dye_start_volumes_per_trial( + reservoir.load_name, pipette.channels, len(test["destinations"]) + ) + first_trial_ul = src_ul_per_trial[0] + reservoir[str(test["source"])].load_liquid(dye, first_trial_ul) + + +def _assign_starting_volumes_diluent( + ctx: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, +) -> None: + diluent_info = _build_diluent_info() + if not diluent_info: + return + diluent_vol, src_to_dst = diluent_info + diluent = ctx.define_liquid( + name="Diluent", + description="Diluent", + display_color="#0000FF", + ) + for source, destinations in src_to_dst.items(): + src_ul_per_trial = _diluent_start_volumes_per_trial( + reservoir.load_name, len(destinations) + ) + if not src_ul_per_trial: + continue + first_trial_ul = src_ul_per_trial[0] + reservoir[source].load_liquid(diluent, first_trial_ul) + + +def _transfer( + ctx: ProtocolContext, + volume: float, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, + source: str, + destinations: List[str], + same_tip: bool = False, + push_out: Optional[float] = None, + touch_tip: bool = False, + volume_already_in_plate: float = 0, +) -> None: + end_volumes = _end_volumes_per_trial( + volume, reservoir.load_name, pipette.channels, len(destinations) + ) + src_heights = [ + _convert_ul_in_well_to_height_in_well(reservoir.load_name, ul) + for ul in end_volumes + ] + volume_in_plate = volume + volume_already_in_plate + dst_height = _convert_ul_in_well_to_height_in_well(plate.load_name, volume_in_plate) + if same_tip and not pipette.has_tip: + pipette.configure_for_volume(volume) + pipette.pick_up_tip() + for dst_name, height_src in zip(destinations, src_heights): + # calculate pipetting positions + aspirate_pos = reservoir[source].bottom( + max(height_src - SUBMERGE_MM["aspirate"], MIN_MM_FROM_BOTTOM) + ) + dispense_pos = plate[dst_name].bottom( + max(dst_height - SUBMERGE_MM["dispense"], MIN_MM_FROM_BOTTOM) + ) + blow_out_pos = plate[dst_name].bottom( + max(dst_height + RETRACT_MM, MIN_MM_FROM_BOTTOM) + ) + # transfer + if not same_tip: + pipette.configure_for_volume(volume) + pipette.pick_up_tip(tips.next_tip(pipette.channels)) + if pipette.current_volume > 0: + pipette.dispense(pipette.current_volume, reservoir[source].top()) + pipette.aspirate(volume, aspirate_pos) + ctx.delay(seconds=DELAY_ASPIRATE) + pipette.dispense(volume, dispense_pos, push_out=push_out) + ctx.delay(seconds=DELAY_DISPENSE) + pipette.blow_out(blow_out_pos) + if touch_tip: + pipette.touch_tip(speed=TOUCH_TIP_SPEED, v_offset=TOUCH_TIP_DEPTH) + pipette.aspirate(1, blow_out_pos) # trailing air-gap to avoid droplets + if not same_tip: + if RETURN_TIP: + pipette.return_tip() + else: + pipette.drop_tip() + + +def _transfer_diluent( + ctx: ProtocolContext, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, +) -> None: + diluent_info = _build_diluent_info() + if not diluent_info: + return + diluent_vol, dest_per_src = diluent_info + pipette.configure_for_volume(diluent_vol) + pipette.pick_up_tip(tips.next_tip(pipette.channels)) + for src, destinations in dest_per_src.items(): + _transfer( + ctx, + diluent_vol, + pipette, + tips, + reservoir, + plate, + src, # type: ignore[arg-type] + destinations, # type: ignore[arg-type] + same_tip=True, + push_out=DILUENT_PUSH_OUT, + touch_tip=False, + volume_already_in_plate=0, + ) + if RETURN_TIP: + pipette.return_tip() + else: + pipette.drop_tip() + + +def _transfer_dye( + ctx: ProtocolContext, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, +) -> None: + for test in TEST_SOURCES: + _transfer( + ctx, + TEST_VOLUME, + pipette, + tips, + reservoir, + plate, + test["source"], # type: ignore[arg-type] + test["destinations"], # type: ignore[arg-type] + push_out=TEST_PUSH_OUT, + touch_tip=True, + volume_already_in_plate=_get_diluent_volume(), + ) + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + # the target plate, handle with great care + plate = ctx.load_labware("corning_96_wellplate_360ul_flat", "D2") + + # dye tips, pipette, and reservoir + dye_tips = ctx.load_labware(TEST_TIPS, "B2") + dye_pipette = ctx.load_instrument(TEST_PIPETTE, "left") + dye_reservoir = ctx.load_labware( + SRC_LABWARE_BY_CHANNELS[dye_pipette.channels], "C2" + ) + _assign_starting_volumes_dye(ctx, dye_pipette, dye_reservoir) + + # diluent tips, pipette, and reservoir + if _get_diluent_volume(): + diluent_tips = ctx.load_labware("opentrons_flex_96_tiprack_200uL", "B3") + if "p1000_multi" in TEST_PIPETTE and ALLOW_TEST_PIPETTE_TO_TRANSFER_DILUENT: + diluent_pipette = dye_pipette # share the 8ch pipette + else: + diluent_pipette = ctx.load_instrument("flex_8channel_1000", "right") + diluent_labware = SRC_LABWARE_BY_CHANNELS[diluent_pipette.channels] + if dye_reservoir.load_name == diluent_labware: + reservoir_diluent = dye_reservoir # share the 12-row reservoir + else: + reservoir_diluent = ctx.load_labware( + SRC_LABWARE_BY_CHANNELS[diluent_pipette.channels], "C3" + ) + _assign_starting_volumes_diluent(ctx, dye_pipette, reservoir_diluent) + + # transfer diluent + _transfer_diluent(ctx, diluent_pipette, diluent_tips, reservoir_diluent, plate) + + # transfer dye + _transfer_dye(ctx, dye_pipette, dye_tips, dye_reservoir, plate) diff --git a/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p50_single_1ul.py b/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p50_single_1ul.py new file mode 100644 index 00000000000..6ddfae0a45f --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/installation_qualification/flex_iq_p50_single_1ul.py @@ -0,0 +1,394 @@ +"""Flex IQ: P50 Single 1ul.""" +from math import pi, ceil +from typing import List, Optional, Dict, Tuple, Any + +from opentrons.protocol_api import ProtocolContext, InstrumentContext, Labware + +############################################## +# EDIT - START # +############################################## + +metadata = {"protocolName": "Flex IQ: P50 Single 1ul"} +requirements = {"robotType": "Flex", "apiLevel": "2.15"} + +ALLOW_TEST_PIPETTE_TO_TRANSFER_DILUENT = False +RETURN_TIP = False +DILUENT_WELLS = ["A11", "A12"] + +TEST_VOLUME = 1 +TEST_PUSH_OUT = 6 +TEST_PIPETTE = "flex_1channel_50" +TEST_TIPS = "opentrons_flex_96_tiprack_50uL" +TEST_SOURCES: List[Dict[str, Any]] = [ + { + "source": "A4", + "destinations": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + }, +] + +############################################## +# EDIT - END # +############################################## + +SUBMERGE_MM = { + "aspirate": 3.0, + "dispense": 1.0, +} +RETRACT_MM = 5.0 +MIN_MM_FROM_BOTTOM = 1.0 + +DILUENT_PUSH_OUT = 15 + +TOUCH_TIP_SPEED = 30 +TOUCH_TIP_DEPTH = -1 + +DELAY_ASPIRATE = 1.0 +DELAY_DISPENSE = 0.5 + +_DYE_MAP: Dict[str, Dict[str, float]] = { + "Range-HV": {"min": 200.1, "max": 350}, + "Range-A": {"min": 50, "max": 200}, + "Range-B": {"min": 10, "max": 49.99}, + "Range-C": {"min": 2, "max": 9.999}, + "Range-D": {"min": 1, "max": 1.999}, + "Range-E": {"min": 0.1, "max": 0.99}, +} + + +def _get_dye_type(volume: float) -> str: + for dye in _DYE_MAP.keys(): + if _DYE_MAP[dye]["min"] <= volume <= _DYE_MAP[dye]["max"]: + return dye + raise ValueError(f"volume {volume} is outside of the available dye range") + + +def _get_diluent_volume() -> float: + return max(200 - TEST_VOLUME, 0) + + +SRC_LABWARE_BY_CHANNELS = { + 1: "nest_12_reservoir_15ml", + 8: "nest_12_reservoir_15ml", + 96: "nest_1_reservoir_195ml", +} + +MIN_VOL_SRC = { + "nest_96_wellplate_2ml_deep": 500, + "nest_12_reservoir_15ml": 3000, + "nest_1_reservoir_195ml": 30000, +} + + +class _LiquidHeightInFlatBottomWell: + def __init__( + self, + bottom_diameter: float, + top_diameter: float, + height: float, + resolution_mm: float = 0.1, + ) -> None: + self._bottom_radius = bottom_diameter / 2 + self._top_radius = top_diameter / 2 + self._height = height + self._resolution_mm = resolution_mm + + def _volume_of_frustum(self, surface_height: float, surface_radius: float) -> float: + """Calculate the volume of a frustum given its height and radii.""" + a = pi * self._bottom_radius * surface_radius + b = pi * surface_radius**2 + c = pi * self._bottom_radius**2 + return (a + b + c) * (surface_height / 3) + + def height_from_volume(self, volume: float) -> float: + """Given the volume, compute the height of the liquid in the well.""" + _rad_diff = self._top_radius - self._bottom_radius + low, high = 0.0, self._height + while high - low > self._resolution_mm: + mid = (low + high) / 2 + r_mid = self._bottom_radius + (mid / self._height) * _rad_diff + if self._volume_of_frustum(mid, r_mid) < volume: + low = mid + else: + high = mid + return (low + high) / 2 + + def volume_from_height(self, height: float) -> float: + """Given the height, compute the volume of the liquid in the well.""" + _rel_height = height / self._height + _rad_diff = self._top_radius - self._bottom_radius + surface_radius = self._bottom_radius + _rad_diff * _rel_height + return self._volume_of_frustum(height, surface_radius) + + +LIQUID_HEIGHT_LOOKUP: Dict[str, List[Tuple[float, float]]] = { + "nest_1_reservoir_195ml": [(0, 0), (195000, 25)], + "nest_12_reservoir_15ml": [ + (0, 0), + (3000, 6.0), + (3500, 7.0), + (4000, 8.0), + (5500, 10.5), + (8000, 14.7), + (10000, 18.0), + (12600, 22.5), + (15000, 26.85), # full depth of well + ], + "nest_96_wellplate_2ml_deep": [ + (0, 0), + (2000, 38), # FIXME: create real lookup table + ], +} + + +def _convert_ul_in_well_to_height_in_well(load_name: str, ul: float) -> float: + if load_name in LIQUID_HEIGHT_LOOKUP: + lookup = LIQUID_HEIGHT_LOOKUP[load_name] + for i in range(len(lookup) - 1): + low = lookup[i] + high = lookup[i + 1] + if low[0] <= ul <= high[0]: + ul_scale = (ul - low[0]) / (high[0] - low[0]) + return (ul_scale * (high[1] - low[1])) + low[1] + elif load_name == "corning_96_wellplate_360ul_flat": + well = _LiquidHeightInFlatBottomWell( + bottom_diameter=6.35, top_diameter=6.858, height=10.668 + ) + return well.height_from_volume(ul) + raise ValueError(f"unable to find height of {ul} ul in {load_name}") + + +def _build_diluent_info() -> Optional[Tuple[float, Dict[str, List[str]]]]: + diluent_vol = _get_diluent_volume() + if diluent_vol <= 0: + return None + target_cols = set( + [int(dst[1:]) for test in TEST_SOURCES for dst in test["destinations"]] + ) + dest = [f"A{col}" for col in sorted(list(target_cols))] + num_dest_per_src = ceil(len(dest) / len(DILUENT_WELLS)) + src_to_dest = { + src: dest[i * num_dest_per_src : i * num_dest_per_src + num_dest_per_src] + for i, src in enumerate(DILUENT_WELLS) + } + return diluent_vol, src_to_dest + + +def _start_volumes_per_trial( + volume: float, load_name: str, channels: int, trials: int +) -> List[float]: + ul_per_aspirate = volume * channels + ul_per_run = ul_per_aspirate * trials + ul_at_start = ul_per_run + MIN_VOL_SRC[load_name] + return [ul_at_start - (ul_per_aspirate * i) for i in range(trials)] + + +def _end_volumes_per_trial( + volume: float, load_name: str, channels: int, trials: int +) -> List[float]: + return [ + ul - (volume * channels) + for ul in _start_volumes_per_trial(volume, load_name, channels, trials) + ] + + +def _dye_start_volumes_per_trial( + load_name: str, channels: int, trials: int +) -> List[float]: + return _start_volumes_per_trial(TEST_VOLUME, load_name, channels, trials) + + +def _diluent_start_volumes_per_trial(load_name: str, trials: int) -> List[float]: + return _start_volumes_per_trial(_get_diluent_volume(), load_name, 8, trials) + + +def _assign_starting_volumes_dye( + ctx: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, +) -> None: + dye = ctx.define_liquid( + name=_get_dye_type(TEST_VOLUME), + description=f"Artel MVS Dye: {_get_dye_type(TEST_VOLUME)}", + display_color="#FF0000", + ) + for test in TEST_SOURCES: + src_ul_per_trial = _dye_start_volumes_per_trial( + reservoir.load_name, pipette.channels, len(test["destinations"]) + ) + first_trial_ul = src_ul_per_trial[0] + reservoir[str(test["source"])].load_liquid(dye, first_trial_ul) + + +def _assign_starting_volumes_diluent( + ctx: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, +) -> None: + diluent_info = _build_diluent_info() + if not diluent_info: + return + diluent_vol, src_to_dst = diluent_info + diluent = ctx.define_liquid( + name="Diluent", + description="Diluent", + display_color="#0000FF", + ) + for source, destinations in src_to_dst.items(): + src_ul_per_trial = _diluent_start_volumes_per_trial( + reservoir.load_name, len(destinations) + ) + if not src_ul_per_trial: + continue + first_trial_ul = src_ul_per_trial[0] + reservoir[source].load_liquid(diluent, first_trial_ul) + + +def _transfer( + ctx: ProtocolContext, + volume: float, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, + source: str, + destinations: List[str], + same_tip: bool = False, + push_out: Optional[float] = None, + touch_tip: bool = False, + volume_already_in_plate: float = 0, +) -> None: + end_volumes = _end_volumes_per_trial( + volume, reservoir.load_name, pipette.channels, len(destinations) + ) + src_heights = [ + _convert_ul_in_well_to_height_in_well(reservoir.load_name, ul) + for ul in end_volumes + ] + volume_in_plate = volume + volume_already_in_plate + dst_height = _convert_ul_in_well_to_height_in_well(plate.load_name, volume_in_plate) + if same_tip and not pipette.has_tip: + pipette.configure_for_volume(volume) + pipette.pick_up_tip() + for dst_name, height_src in zip(destinations, src_heights): + # calculate pipetting positions + aspirate_pos = reservoir[source].bottom( + max(height_src - SUBMERGE_MM["aspirate"], MIN_MM_FROM_BOTTOM) + ) + dispense_pos = plate[dst_name].bottom( + max(dst_height - SUBMERGE_MM["dispense"], MIN_MM_FROM_BOTTOM) + ) + blow_out_pos = plate[dst_name].bottom( + max(dst_height + RETRACT_MM, MIN_MM_FROM_BOTTOM) + ) + # transfer + if not same_tip: + pipette.configure_for_volume(volume) + pipette.pick_up_tip(tips.next_tip(pipette.channels)) + if pipette.current_volume > 0: + pipette.dispense(pipette.current_volume, reservoir[source].top()) + pipette.aspirate(volume, aspirate_pos) + ctx.delay(seconds=DELAY_ASPIRATE) + pipette.dispense(volume, dispense_pos, push_out=push_out) + ctx.delay(seconds=DELAY_DISPENSE) + pipette.blow_out(blow_out_pos) + if touch_tip: + pipette.touch_tip(speed=TOUCH_TIP_SPEED, v_offset=TOUCH_TIP_DEPTH) + pipette.aspirate(1, blow_out_pos) # trailing air-gap to avoid droplets + if not same_tip: + if RETURN_TIP: + pipette.return_tip() + else: + pipette.drop_tip() + + +def _transfer_diluent( + ctx: ProtocolContext, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, +) -> None: + diluent_info = _build_diluent_info() + if not diluent_info: + return + diluent_vol, dest_per_src = diluent_info + pipette.configure_for_volume(diluent_vol) + pipette.pick_up_tip(tips.next_tip(pipette.channels)) + for src, destinations in dest_per_src.items(): + _transfer( + ctx, + diluent_vol, + pipette, + tips, + reservoir, + plate, + src, # type: ignore[arg-type] + destinations, # type: ignore[arg-type] + same_tip=True, + push_out=DILUENT_PUSH_OUT, + touch_tip=False, + volume_already_in_plate=0, + ) + if RETURN_TIP: + pipette.return_tip() + else: + pipette.drop_tip() + + +def _transfer_dye( + ctx: ProtocolContext, + pipette: InstrumentContext, + tips: Labware, + reservoir: Labware, + plate: Labware, +) -> None: + for test in TEST_SOURCES: + _transfer( + ctx, + TEST_VOLUME, + pipette, + tips, + reservoir, + plate, + test["source"], # type: ignore[arg-type] + test["destinations"], # type: ignore[arg-type] + push_out=TEST_PUSH_OUT, + touch_tip=True, + volume_already_in_plate=_get_diluent_volume(), + ) + + +def run(ctx: ProtocolContext) -> None: + """Run.""" + # the target plate, handle with great care + plate = ctx.load_labware("corning_96_wellplate_360ul_flat", "D2") + + # dye tips, pipette, and reservoir + dye_tips = ctx.load_labware(TEST_TIPS, "B2") + dye_pipette = ctx.load_instrument(TEST_PIPETTE, "left") + dye_reservoir = ctx.load_labware( + SRC_LABWARE_BY_CHANNELS[dye_pipette.channels], "C2" + ) + _assign_starting_volumes_dye(ctx, dye_pipette, dye_reservoir) + + # diluent tips, pipette, and reservoir + if _get_diluent_volume(): + diluent_tips = ctx.load_labware("opentrons_flex_96_tiprack_200uL", "B3") + if "p1000_multi" in TEST_PIPETTE and ALLOW_TEST_PIPETTE_TO_TRANSFER_DILUENT: + diluent_pipette = dye_pipette # share the 8ch pipette + else: + diluent_pipette = ctx.load_instrument("flex_8channel_1000", "right") + diluent_labware = SRC_LABWARE_BY_CHANNELS[diluent_pipette.channels] + if dye_reservoir.load_name == diluent_labware: + reservoir_diluent = dye_reservoir # share the 12-row reservoir + else: + reservoir_diluent = ctx.load_labware( + SRC_LABWARE_BY_CHANNELS[diluent_pipette.channels], "C3" + ) + _assign_starting_volumes_diluent(ctx, dye_pipette, reservoir_diluent) + + # transfer diluent + _transfer_diluent(ctx, diluent_pipette, diluent_tips, reservoir_diluent, plate) + + # transfer dye + _transfer_dye(ctx, dye_pipette, dye_tips, dye_reservoir, plate) diff --git a/hardware-testing/hardware_testing/scripts/gravimetric_rnd.py b/hardware-testing/hardware_testing/scripts/gravimetric_rnd.py index a5cec740817..1b352e26384 100644 --- a/hardware-testing/hardware_testing/scripts/gravimetric_rnd.py +++ b/hardware-testing/hardware_testing/scripts/gravimetric_rnd.py @@ -7,6 +7,7 @@ GravimetricRecorder, GravimetricRecorderConfig, ) +from hardware_testing.gravimetric.measurement.scale import Scale # type: ignore[import] metadata = {"protocolName": "gravimetric-rnd", "apiLevel": "2.12"} CALIBRATE_SCALE = False @@ -26,6 +27,7 @@ def _run(is_simulating: bool) -> None: frequency=5, stable=False, ), + scale=Scale.build(simulate=is_simulating), simulate=is_simulating, ) if CALIBRATE_SCALE: diff --git a/hardware-testing/hardware_testing/scripts/gripper_ot3.py b/hardware-testing/hardware_testing/scripts/gripper_ot3.py index bcdf7a63bea..de2578c9ff8 100644 --- a/hardware-testing/hardware_testing/scripts/gripper_ot3.py +++ b/hardware-testing/hardware_testing/scripts/gripper_ot3.py @@ -291,7 +291,7 @@ async def _probe_labware_corners( for corner in nominal_corners: current_pos = await api.gantry_position(PROBE_MOUNT) await api.move_to(PROBE_MOUNT, corner._replace(z=current_pos.z)) - found_z = await api.capacitive_probe( + found_z, _ = await api.capacitive_probe( PROBE_MOUNT, types.Axis.by_mount(PROBE_MOUNT), corner.z, diff --git a/hardware-testing/hardware_testing/scripts/speed_accel_profile.py b/hardware-testing/hardware_testing/scripts/speed_accel_profile.py index 28900de5012..c00a68038cd 100644 --- a/hardware-testing/hardware_testing/scripts/speed_accel_profile.py +++ b/hardware-testing/hardware_testing/scripts/speed_accel_profile.py @@ -8,7 +8,7 @@ from typing import Tuple, Dict from opentrons.hardware_control.ot3api import OT3API -from opentrons.hardware_control.errors import MustHomeError +from opentrons_shared_data.errors.exceptions import PositionUnknownError from hardware_testing.opentrons_api.types import GantryLoad, OT3Mount, Axis, Point from hardware_testing.opentrons_api.helpers_ot3 import ( @@ -247,7 +247,7 @@ async def _single_axis_move( await api.move_rel( mount=MOUNT, delta=move_error_correction, speed=35 ) - except MustHomeError: + except PositionUnknownError: await api.home([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R]) if DELAY > 0: diff --git a/hardware-testing/hardware_testing/tools/plot/server.py b/hardware-testing/hardware_testing/tools/plot/server.py index db676db5716..b3bda88917f 100644 --- a/hardware-testing/hardware_testing/tools/plot/server.py +++ b/hardware-testing/hardware_testing/tools/plot/server.py @@ -54,60 +54,49 @@ def _respond_to_frontend_file_request(self) -> None: raise ValueError(f'Unexpected file type for file "{_file_name}"') self._send_response_bytes(file, content_type=c_type) - def _list_files_in_directory(self, includes: str = "") -> List[Path]: - _file_list = [ - Path(f).resolve() for f in self.plot_directory.iterdir() if f.is_file() - ] - if includes: - _file_list = [f for f in _file_list if includes in f.stem] - _file_list.sort(key=lambda f: f.stat().st_mtime) - _file_list.reverse() - return _file_list - - def _get_file_name_list(self, substring: str = "") -> List[str]: - return [f.stem for f in self._list_files_in_directory(substring)] - - def _get_file_contents(self, file_name: str) -> str: - req_file_name = f"{file_name}.csv" - req_file_path = self.plot_directory / req_file_name - with open(req_file_path.resolve(), "r") as f: - return f.read() + def _list_file_paths_in_directory( + self, directory: Path, includes: str = "" + ) -> List[Path]: + _ret: List[Path] = [] + for p in [Path(f) for f in directory.iterdir()]: + if p.is_file() and includes in p.stem: + _ret.append(p.absolute()) # found a file, get absolute path + elif p.is_dir(): + # recursively look for files + sub_dir_paths = self._list_file_paths_in_directory(p, includes) + for sub_p in sub_dir_paths: + _ret.append(sub_p) + # sort newest to oldest + _ret.sort(key=lambda f: f.stem) # name includes timestamp + _ret.reverse() + return _ret def _respond_to_data_request(self) -> None: req_cmd = self.path_elements[1] + if req_cmd != "latest": + raise NotImplementedError(f"unable to process command: {req_cmd}") + path_list_grav = self._list_file_paths_in_directory( + self.plot_directory, "GravimetricRecorder" + ) + path_list_pip = self._list_file_paths_in_directory( + self.plot_directory, "CSVReport" + ) response_data = { "directory": str(self.plot_directory.resolve()), "files": [], - "name": None, - "csv": None, - "csvPipette": None, + "name": "", + "csv": "", + "csvPipette": "", } - _grav_name = "GravimetricRecorder" - _pip_name = "PipetteLiquidClass" - if req_cmd == "list": - f = self._get_file_name_list(_grav_name) # type: ignore[assignment] - response_data["files"] = f - else: - file_name_grav = "" - file_name_pip = "" - if req_cmd == "latest": - file_list_grav = self._get_file_name_list(_grav_name) - file_list_pip = self._get_file_name_list(_pip_name) - if file_list_grav: - file_name_grav = file_list_grav[0] - if file_list_pip: - file_name_pip = file_list_pip[0] - else: - raise ValueError(f"Unable to find response for request: {self.path}") - response_data["name"] = file_name_grav - response_data["csv"] = ( - self._get_file_contents(file_name_grav) if file_name_grav else "" - ) - response_data["csvPipette"] = "" - if file_name_pip: - response_data["csvPipette"] = ( - self._get_file_contents(file_name_pip) if file_name_pip else "" - ) + if path_list_grav: + file_name_grav = path_list_grav[0] + response_data["name"] = str(file_name_grav.stem) + with open(file_name_grav, "r") as f: + response_data["csv"] = f.read() + if path_list_pip: + file_name_pip = path_list_pip[0] + with open(file_name_pip, "r") as f: + response_data["csvPipette"] = f.read() response_str = json.dumps({req_cmd: response_data}) self._send_response_bytes(response_str.encode("utf-8")) diff --git a/hardware-testing/tests/hardware_testing/drivers/radwag/test_driver.py b/hardware-testing/tests/hardware_testing/drivers/radwag/test_driver.py index 784c96fac7e..c0ceac8fbab 100644 --- a/hardware-testing/tests/hardware_testing/drivers/radwag/test_driver.py +++ b/hardware-testing/tests/hardware_testing/drivers/radwag/test_driver.py @@ -1,7 +1,7 @@ """Radwag driver tests.""" from typing import List -from unittest.mock import MagicMock +from unittest.mock import MagicMock, mock_open, patch import pytest @@ -23,8 +23,8 @@ def _write(b: bytes) -> int: @pytest.fixture def subject(scale_connection: MagicMock) -> RadwagScale: """Test subject.""" - r = RadwagScale(connection=scale_connection) - return r + with patch("builtins.open", mock_open()): + return RadwagScale(connection=scale_connection) def create_radwag_result_line( diff --git a/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py b/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py index 7480b6665a6..55ac2f0e99f 100644 --- a/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py +++ b/hardware-testing/tests/hardware_testing/drivers/test_asair_sensor.py @@ -23,8 +23,8 @@ def test_reading(subject: AsairSensor, connection: MagicMock) -> None: connection.read.return_value = data connection.inWaiting.return_value = len(data) assert subject.get_reading() == Reading( - 1.0, 3.0, + 1.0, ) connection.write.assert_called_once_with(b"\x01\x03\x00\x00\x00\x02\xC4\x0b") diff --git a/hardware/opentrons_hardware/drivers/eeprom/eeprom.py b/hardware/opentrons_hardware/drivers/eeprom/eeprom.py index 4437158c50a..be2e28694d6 100644 --- a/hardware/opentrons_hardware/drivers/eeprom/eeprom.py +++ b/hardware/opentrons_hardware/drivers/eeprom/eeprom.py @@ -128,6 +128,7 @@ def close(self) -> bool: logger.debug("Closing eeprom file descriptor") os.close(self._eeprom_fd) self._eeprom_fd = -1 + self._gpio.__del__() return True def property_read(self, prop_ids: Optional[Set[PropId]] = None) -> Set[Property]: diff --git a/hardware/opentrons_hardware/drivers/gpio/__init__.py b/hardware/opentrons_hardware/drivers/gpio/__init__.py index bd02bec3adc..bb54878b4b6 100644 --- a/hardware/opentrons_hardware/drivers/gpio/__init__.py +++ b/hardware/opentrons_hardware/drivers/gpio/__init__.py @@ -63,6 +63,14 @@ def __init__(self, consumer_name: Optional[str] = None) -> None: self.deactivate_eeprom_wp() sleep(1) + def __del__(self) -> None: + try: + self._estop_out_line.release() + self._nsync_out_line.release() + self._eeprom_wp_out_line.release() + except Exception: + pass + def activate_estop(self) -> None: """Assert the emergency stop, which will disable all motors.""" self._estop_out_line.set_value(0) diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py b/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py index 2342a84d098..8525b04459a 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/payloads.py @@ -229,8 +229,8 @@ class MoveCompletedPayload(MoveGroupResponsePayload): class MotorPositionResponse(EmptyPayload): """Read Encoder Position.""" - current_position: utils.UInt32Field - encoder_position: utils.Int32Field + current_position_um: utils.UInt32Field + encoder_position_um: utils.Int32Field position_flags: MotorPositionFlagsField @@ -545,40 +545,10 @@ class GripperErrorTolerancePayload(EmptyPayload): @dataclass(eq=False) -class _PushTipPresenceNotificationPayloadBase(EmptyPayload): - ejector_flag_status: utils.UInt8Field - - -@dataclass(eq=False) -class PushTipPresenceNotificationPayload(_PushTipPresenceNotificationPayloadBase): +class PushTipPresenceNotificationPayload(EmptyPayload): """A notification that the ejector flag status has changed.""" - @classmethod - def build(cls, data: bytes) -> "PushTipPresenceNotificationPayload": - """Build a response payload from incoming bytes.""" - consumed_by_super = _PushTipPresenceNotificationPayloadBase.get_size() - superdict = asdict(_PushTipPresenceNotificationPayloadBase.build(data)) - message_index = superdict.pop("message_index") - - # we want to parse this by adding extra 0s that may not be necessary, - # which is annoying and complex, so let's wrap it in an iterator - def _data_for_optionals(consumed: int, buf: bytes) -> Iterator[bytes]: - extended = buf + b"\x00\x00" - yield extended[consumed:] - consumed += 2 - extended = extended + b"\x00" - yield extended[consumed : consumed + 1] - - optionals_yielder = _data_for_optionals(consumed_by_super, data) - inst = cls( - **superdict, - sensor_id=SensorIdField.build( - int.from_bytes(next(optionals_yielder), "big") - ), - ) - inst.message_index = message_index - return inst - + ejector_flag_status: utils.UInt8Field sensor_id: SensorIdField diff --git a/hardware/opentrons_hardware/hardware_control/motor_position_status.py b/hardware/opentrons_hardware/hardware_control/motor_position_status.py index faa51d236b7..90319764922 100644 --- a/hardware/opentrons_hardware/hardware_control/motor_position_status.py +++ b/hardware/opentrons_hardware/hardware_control/motor_position_status.py @@ -1,6 +1,6 @@ """Utilities for gathering motor position/status for an OT3 axis.""" import asyncio -from typing import Set, Tuple +from typing import Set, Union, Optional import logging from opentrons_shared_data.errors.exceptions import ( @@ -15,6 +15,8 @@ from opentrons_hardware.firmware_bindings.messages.message_definitions import ( MotorPositionRequest, MotorPositionResponse, + MoveCompleted, + TipActionResponse, UpdateMotorPositionEstimationRequest, UpdateMotorPositionEstimationResponse, ) @@ -25,46 +27,56 @@ MotorPositionFlags, ) -from .types import NodeMap +from .types import NodeMap, MotorPositionStatus, MoveCompleteAck log = logging.getLogger(__name__) -MotorPositionStatus = NodeMap[Tuple[float, float, bool, bool]] +_MotorStatusMoves = Union[ + MoveCompleted, + TipActionResponse, + MotorPositionResponse, + UpdateMotorPositionEstimationResponse, +] + + +def extract_motor_status_info(msg: _MotorStatusMoves) -> MotorPositionStatus: + """Extract motor position status from CAN responses.""" + move_ack: Optional[MoveCompleteAck] = None + if isinstance(msg, MoveCompleted) or isinstance(msg, TipActionResponse): + move_ack = MoveCompleteAck(msg.payload.ack_id.value) + return MotorPositionStatus( + motor_position=float(msg.payload.current_position_um.value / 1000.0), + encoder_position=float(msg.payload.encoder_position_um.value) / 1000.0, + motor_ok=bool( + msg.payload.position_flags.value + & MotorPositionFlags.stepper_position_ok.value + ), + encoder_ok=bool( + msg.payload.position_flags.value + & MotorPositionFlags.encoder_position_ok.value + ), + move_ack=move_ack, + ) async def _parser_motor_position_response( reader: WaitableCallback, -) -> MotorPositionStatus: +) -> NodeMap[MotorPositionStatus]: data = {} async for response, arb_id in reader: assert isinstance(response, MotorPositionResponse) node = NodeId(arb_id.parts.originating_node_id) - data.update( - { - node: ( - float(response.payload.current_position.value / 1000.0), - float(response.payload.encoder_position.value) / 1000.0, - bool( - response.payload.position_flags.value - & MotorPositionFlags.stepper_position_ok.value - ), - bool( - response.payload.position_flags.value - & MotorPositionFlags.encoder_position_ok.value - ), - ) - } - ) + data.update({node: extract_motor_status_info(response)}) return data async def get_motor_position( can_messenger: CanMessenger, nodes: Set[NodeId], timeout: float = 1.0 -) -> MotorPositionStatus: +) -> NodeMap[MotorPositionStatus]: """Request node to respond with motor and encoder status.""" - data: MotorPositionStatus = {} + data: NodeMap[MotorPositionStatus] = {} def _listener_filter(arbitration_id: ArbitrationId) -> bool: return (NodeId(arbitration_id.parts.originating_node_id) in nodes) and ( @@ -92,29 +104,18 @@ def _listener_filter(arbitration_id: ArbitrationId) -> bool: async def _parser_update_motor_position_response( reader: WaitableCallback, expected: NodeId -) -> Tuple[float, float, bool, bool]: +) -> MotorPositionStatus: async for response, arb_id in reader: assert isinstance(response, UpdateMotorPositionEstimationResponse) node = NodeId(arb_id.parts.originating_node_id) if node == expected: - return ( - float(response.payload.current_position.value / 1000.0), - float(response.payload.encoder_position.value) / 1000.0, - bool( - response.payload.position_flags.value - & MotorPositionFlags.stepper_position_ok.value - ), - bool( - response.payload.position_flags.value - & MotorPositionFlags.encoder_position_ok.value - ), - ) + return extract_motor_status_info(response) raise StopAsyncIteration async def update_motor_position_estimation( can_messenger: CanMessenger, nodes: Set[NodeId], timeout: float = 1.0 -) -> MotorPositionStatus: +) -> NodeMap[MotorPositionStatus]: """Updates the estimation of motor position on selected nodes. Request node to update motor position from its encoder and respond @@ -138,7 +139,7 @@ def _listener_filter(arbitration_id: ArbitrationId) -> bool: data[node] = await asyncio.wait_for( _parser_update_motor_position_response(reader, node), timeout ) - if not data[node][2]: + if not data[node].motor_ok: # If the stepper_ok flag isn't set, that means the node didn't update position. # This probably is because the motor is off. It's rare. raise RoboticsControlError( diff --git a/hardware/opentrons_hardware/hardware_control/move_group_runner.py b/hardware/opentrons_hardware/hardware_control/move_group_runner.py index a7965df2d47..29b6aa89267 100644 --- a/hardware/opentrons_hardware/hardware_control/move_group_runner.py +++ b/hardware/opentrons_hardware/hardware_control/move_group_runner.py @@ -19,7 +19,6 @@ from opentrons_hardware.firmware_bindings.constants import ( NodeId, ErrorCode, - MotorPositionFlags, ErrorSeverity, GearMotorId, MoveAckId, @@ -72,8 +71,11 @@ MoveStopConditionField, ) from opentrons_hardware.hardware_control.motion import MoveStopCondition +from opentrons_hardware.hardware_control.motor_position_status import ( + extract_motor_status_info, +) -from .types import NodeDict +from .types import NodeDict, MotorPositionStatus log = logging.getLogger(__name__) @@ -127,7 +129,7 @@ async def prep(self, can_messenger: CanMessenger) -> None: async def execute( self, can_messenger: CanMessenger - ) -> NodeDict[Tuple[float, float, bool, bool]]: + ) -> NodeDict[MotorPositionStatus]: """Execute a pre-prepared move group. The second thing that run() does. prep() and execute() can be used to replace a single call to run() to @@ -144,9 +146,7 @@ async def execute( move_completion_data = await self._move(can_messenger, self._start_at_index) return self._accumulate_move_completions(move_completion_data) - async def run( - self, can_messenger: CanMessenger - ) -> NodeDict[Tuple[float, float, bool, bool]]: + async def run(self, can_messenger: CanMessenger) -> NodeDict[MotorPositionStatus]: """Run the move group. Args: @@ -170,54 +170,29 @@ async def run( @staticmethod def _accumulate_move_completions( completions: _Completions, - ) -> NodeDict[Tuple[float, float, bool, bool]]: + ) -> NodeDict[MotorPositionStatus]: position: NodeDict[ - List[Tuple[Tuple[int, int], float, float, bool, bool]] + List[Tuple[Tuple[int, int], MotorPositionStatus]] ] = defaultdict(list) gear_motor_position: NodeDict[ - List[Tuple[Tuple[int, int], float, float, bool, bool]] + List[Tuple[Tuple[int, int], MotorPositionStatus]] ] = defaultdict(list) for arbid, completion in completions: + move_info = ( + ( + completion.payload.group_id.value, + completion.payload.seq_id.value, + ), + extract_motor_status_info(completion), + ) if isinstance(completion, TipActionResponse): # if any completions are TipActionResponses, separate them from the 'positions' # dict so the left pipette's position doesn't get overwritten gear_motor_position[NodeId(arbid.parts.originating_node_id)].append( - ( - ( - completion.payload.group_id.value, - completion.payload.seq_id.value, - ), - float(completion.payload.current_position_um.value) / 1000.0, - float(completion.payload.encoder_position_um.value) / 1000.0, - bool( - completion.payload.position_flags.value - & MotorPositionFlags.stepper_position_ok.value - ), - bool( - completion.payload.position_flags.value - & MotorPositionFlags.encoder_position_ok.value - ), - ) + move_info ) else: - position[NodeId(arbid.parts.originating_node_id)].append( - ( - ( - completion.payload.group_id.value, - completion.payload.seq_id.value, - ), - float(completion.payload.current_position_um.value) / 1000.0, - float(completion.payload.encoder_position_um.value) / 1000.0, - bool( - completion.payload.position_flags.value - & MotorPositionFlags.stepper_position_ok.value - ), - bool( - completion.payload.position_flags.value - & MotorPositionFlags.encoder_position_ok.value - ), - ) - ) + position[NodeId(arbid.parts.originating_node_id)].append(move_info) # for each node, pull the position from the completion with the largest # combination of group id and sequence id if any(gear_motor_position): @@ -228,7 +203,7 @@ def _accumulate_move_completions( poslist, key=lambda position_element: position_element[0] ) ) - )[1:] + )[1] for node, poslist in gear_motor_position.items() } return { @@ -236,7 +211,7 @@ def _accumulate_move_completions( reversed( sorted(poslist, key=lambda position_element: position_element[0]) ) - )[1:] + )[1] for node, poslist in position.items() } diff --git a/hardware/opentrons_hardware/hardware_control/tip_presence.py b/hardware/opentrons_hardware/hardware_control/tip_presence.py index ced9037d1b6..404bd8a3e71 100644 --- a/hardware/opentrons_hardware/hardware_control/tip_presence.py +++ b/hardware/opentrons_hardware/hardware_control/tip_presence.py @@ -3,17 +3,23 @@ import logging from typing_extensions import Literal +from typing import Union, Dict + +from opentrons_shared_data.errors.exceptions import CommandTimedOutError from opentrons_hardware.firmware_bindings.arbitration_id import ArbitrationId -from opentrons_hardware.firmware_bindings.messages.messages import MessageDefinition -from opentrons_hardware.drivers.can_bus.can_messenger import CanMessenger +from opentrons_hardware.drivers.can_bus.can_messenger import ( + CanMessenger, + MultipleMessagesWaitableCallback, + WaitableCallback, +) from opentrons_hardware.firmware_bindings.messages.message_definitions import ( TipStatusQueryRequest, PushTipPresenceNotification, ) -from opentrons_hardware.firmware_bindings.constants import MessageId, NodeId +from opentrons_hardware.firmware_bindings.constants import MessageId, NodeId, SensorId log = logging.getLogger(__name__) @@ -21,21 +27,14 @@ async def get_tip_ejector_state( can_messenger: CanMessenger, node: Literal[NodeId.pipette_left, NodeId.pipette_right], -) -> int: + expected_responses: Union[Literal[1], Literal[2]], + timeout: float = 1.0, +) -> Dict[SensorId, int]: """Get the state of the tip presence interrupter. When the tip ejector flag is occuluded, then we know that there is a tip on the pipette. """ - tip_ejector_state = 0 - - event = asyncio.Event() - - def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None: - nonlocal tip_ejector_state - if isinstance(message, PushTipPresenceNotification): - event.set() - tip_ejector_state = message.payload.ejector_flag_status.value def _filter(arbitration_id: ArbitrationId) -> bool: return (NodeId(arbitration_id.parts.originating_node_id) == node) and ( @@ -43,13 +42,30 @@ def _filter(arbitration_id: ArbitrationId) -> bool: == MessageId.tip_presence_notification ) - can_messenger.add_listener(_listener, _filter) - await can_messenger.send(node_id=node, message=TipStatusQueryRequest()) + async def gather_responses( + reader: WaitableCallback, + ) -> Dict[SensorId, int]: + data: Dict[SensorId, int] = {} + async for response, _ in reader: + assert isinstance(response, PushTipPresenceNotification) + tip_ejector_state = response.payload.ejector_flag_status.value + data[SensorId(response.payload.sensor_id.value)] = tip_ejector_state + return data + + with MultipleMessagesWaitableCallback( + can_messenger, + _filter, + number_of_messages=expected_responses, + ) as _reader: + await can_messenger.send(node_id=node, message=TipStatusQueryRequest()) + try: - try: - await asyncio.wait_for(event.wait(), 1.0) - except asyncio.TimeoutError: - log.error("tip ejector state request timed out before expected nodes responded") - finally: - can_messenger.remove_listener(_listener) - return tip_ejector_state + data_dict = await asyncio.wait_for( + gather_responses(_reader), + timeout, + ) + except asyncio.TimeoutError as te: + msg = f"Tip presence poll of {node} timed out" + log.warning(msg) + raise CommandTimedOutError(message=msg) from te + return data_dict diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index a4417fe225b..34f18b87542 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -32,6 +32,7 @@ MoveGroupStep, ) from opentrons_hardware.hardware_control.move_group_runner import MoveGroupRunner +from opentrons_hardware.hardware_control.types import MotorPositionStatus LOG = getLogger(__name__) PipetteProbeTarget = Literal[NodeId.pipette_left, NodeId.pipette_right] @@ -73,7 +74,7 @@ async def liquid_probe( auto_zero_sensor: bool = True, num_baseline_reads: int = 10, sensor_id: SensorId = SensorId.S0, -) -> Dict[NodeId, Tuple[float, float, bool, bool]]: +) -> Dict[NodeId, MotorPositionStatus]: """Move the mount and pipette simultaneously while reading from the pressure sensor.""" sensor_driver = SensorDriver() threshold_fixed_point = threshold_pascals * sensor_fixed_point_conversion @@ -160,7 +161,7 @@ async def capacitive_probe( sensor_id: SensorId = SensorId.S0, relative_threshold_pf: float = 1.0, log_sensor_values: bool = False, -) -> Tuple[float, float]: +) -> MotorPositionStatus: """Move the specified tool down until its capacitive sensor triggers. Moves down by the specified distance at the specified speed until the @@ -197,7 +198,7 @@ async def capacitive_probe( do_log=log_sensor_values, ): position = await runner.run(can_messenger=messenger) - return position[mover][:2] + return position[mover] async def capacitive_pass( diff --git a/hardware/opentrons_hardware/hardware_control/types.py b/hardware/opentrons_hardware/hardware_control/types.py index b1cb0677360..bd91f0562d9 100644 --- a/hardware/opentrons_hardware/hardware_control/types.py +++ b/hardware/opentrons_hardware/hardware_control/types.py @@ -1,8 +1,9 @@ """Types and definitions for hardware bindings.""" -from typing import Mapping, TypeVar, Dict, List, Optional +from typing import Mapping, TypeVar, Dict, List, Optional, Tuple from dataclasses import dataclass +from enum import Enum -from opentrons_hardware.firmware_bindings.constants import NodeId +from opentrons_hardware.firmware_bindings.constants import NodeId, MoveAckId MapPayload = TypeVar("MapPayload") @@ -21,3 +22,30 @@ class PCBARevision: #: A combination of primary and secondary used for looking up firmware tertiary: Optional[str] = None #: An often-not-present tertiary + + +class MoveCompleteAck(Enum): + """Move Complete Ack.""" + + complete_without_condition = MoveAckId.complete_without_condition.value + stopped_by_condition = MoveAckId.stopped_by_condition.value + timeout = MoveAckId.timeout.value + position_error = MoveAckId.position_error.value + + +@dataclass +class MotorPositionStatus: + """Motor Position Status information.""" + + motor_position: float + encoder_position: float + motor_ok: bool + encoder_ok: bool + move_ack: Optional[MoveCompleteAck] = None + + def positions_only(self) -> Tuple[float, float]: + """Returns motor and encoder positions as a tuple.""" + return ( + self.motor_position, + self.encoder_position, + ) diff --git a/hardware/tests/firmware_integration/test_move_groups.py b/hardware/tests/firmware_integration/test_move_groups.py index 2089a0374cb..8f33c5f0701 100644 --- a/hardware/tests/firmware_integration/test_move_groups.py +++ b/hardware/tests/firmware_integration/test_move_groups.py @@ -25,6 +25,10 @@ from opentrons_hardware.drivers.can_bus import CanMessenger, WaitableCallback from opentrons_hardware.hardware_control.move_group_runner import MoveGroupRunner from opentrons_hardware.hardware_control.motion import create_step, create_home_step +from opentrons_hardware.hardware_control.types import ( + MotorPositionStatus, + MoveCompleteAck, +) @pytest.fixture( @@ -125,7 +129,8 @@ async def test_move_integration( home_runner = MoveGroupRunner([home_move]) position = await home_runner.run(can_messenger) assert position == { - motor_node: (0.0, 0.0, False, False) for motor_node in all_motor_nodes + motor_node: MotorPositionStatus(0.0, 0.0, False, False, MoveCompleteAck(2)) + for motor_node in all_motor_nodes } # these moves test position accumulation to reasonably realistic values # and have to do it over a kind of long time so that the velocities are low @@ -188,7 +193,7 @@ async def test_move_integration( # Also mypy doesn't like pytest.approx so we have to type ignore it # We now store the position as a tuple of assumed position + encoder value. - assert {k: v[0] for k, v in position.items()} == { # type: ignore[comparison-overlap] + assert {k: v.motor_position for k, v in position.items()} == { # type: ignore[comparison-overlap] motor_node: pytest.approx( motor_node.value, abs=all_motor_node_step_sizes[motor_node] * 3 ) diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_motor_position_status.py b/hardware/tests/opentrons_hardware/hardware_control/test_motor_position_status.py index 46215bbc864..9760d910756 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_motor_position_status.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_motor_position_status.py @@ -21,6 +21,7 @@ ) from opentrons_hardware.hardware_control import motor_position_status +from opentrons_hardware.hardware_control.types import MotorPositionStatus def motor_position_response() -> md.MotorPositionResponse: @@ -78,6 +79,6 @@ async def test_parse_motor_position(waitable_reader: AsyncIter) -> None: ), 1, ) - expected = (0.123, 0.123, True, False) + expected = MotorPositionStatus(0.123, 0.123, True, False) for n in nodes: assert data.get(n) == expected diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py b/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py index a81d091bc40..0a189f2289d 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py @@ -1,6 +1,6 @@ """Tests for the move scheduler.""" import pytest -from typing import List, Any, Tuple +from typing import List, Any from numpy import float64, float32, int32 from mock import AsyncMock, call, MagicMock, patch from opentrons_shared_data.errors.exceptions import ( @@ -60,7 +60,11 @@ _CompletionPacket, ) -from opentrons_hardware.hardware_control.types import NodeMap +from opentrons_hardware.hardware_control.types import ( + NodeMap, + MotorPositionStatus, + MoveCompleteAck, +) from opentrons_hardware.firmware_bindings.messages import ( message_definitions as md, MessageDefinition, @@ -1020,7 +1024,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: _build_arb(NodeId.gantry_x), MoveCompleted( payload=MoveCompletedPayload( - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), group_id=UInt8Field(2), seq_id=UInt8Field(2), current_position_um=UInt32Field(10000), @@ -1033,7 +1037,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: _build_arb(NodeId.gantry_x), MoveCompleted( payload=MoveCompletedPayload( - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), group_id=UInt8Field(2), seq_id=UInt8Field(1), current_position_um=UInt32Field(20000), @@ -1046,7 +1050,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: _build_arb(NodeId.gantry_x), MoveCompleted( payload=MoveCompletedPayload( - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), group_id=UInt8Field(1), seq_id=UInt8Field(2), current_position_um=UInt32Field(30000), @@ -1056,7 +1060,11 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: ), ), ], - {NodeId.gantry_x: (10, 40, False, False)}, + { + NodeId.gantry_x: MotorPositionStatus( + 10, 40, False, False, MoveCompleteAck(1) + ) + }, ), ( # multiple axes with different numbers of completions @@ -1065,7 +1073,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: _build_arb(NodeId.gantry_x), MoveCompleted( payload=MoveCompletedPayload( - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), group_id=UInt8Field(2), seq_id=UInt8Field(2), current_position_um=UInt32Field(10000), @@ -1078,7 +1086,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: _build_arb(NodeId.gantry_x), MoveCompleted( payload=MoveCompletedPayload( - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), group_id=UInt8Field(2), seq_id=UInt8Field(1), current_position_um=UInt32Field(20000), @@ -1091,7 +1099,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: _build_arb(NodeId.gantry_y), MoveCompleted( payload=MoveCompletedPayload( - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), group_id=UInt8Field(1), seq_id=UInt8Field(2), current_position_um=UInt32Field(30000), @@ -1102,8 +1110,12 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: ), ], { - NodeId.gantry_x: (10, 40, False, False), - NodeId.gantry_y: (30, 40, False, False), + NodeId.gantry_x: MotorPositionStatus( + 10, 40, False, False, MoveCompleteAck(1) + ), + NodeId.gantry_y: MotorPositionStatus( + 30, 40, False, False, MoveCompleteAck(1) + ), }, ), ( @@ -1112,7 +1124,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: _build_arb(NodeId.pipette_left), md.TipActionResponse( payload=TipActionResponsePayload( - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), group_id=UInt8Field(2), seq_id=UInt8Field(2), current_position_um=UInt32Field(10000), @@ -1125,7 +1137,11 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: ), ), ], - {NodeId.pipette_left: (10, 0, False, False)}, + { + NodeId.pipette_left: MotorPositionStatus( + 10, 0, False, False, MoveCompleteAck(1) + ) + }, ), ( # empty base case @@ -1136,7 +1152,7 @@ def _build_arb(from_node: NodeId) -> ArbitrationId: ) def test_accumulate_move_completions( completions: List[_CompletionPacket], - position_map: NodeMap[Tuple[float, float, bool, bool]], + position_map: NodeMap[MotorPositionStatus], ) -> None: """Build correct move results.""" assert MoveGroupRunner._accumulate_move_completions(completions) == position_map diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_tip_presence.py b/hardware/tests/opentrons_hardware/hardware_control/test_tip_presence.py index 22a0ad84a68..bce2af29619 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_tip_presence.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_tip_presence.py @@ -1,4 +1,5 @@ """Tests for reading the current status of the tip presence photointerrupter.""" +import pytest from mock import AsyncMock from typing import List, Tuple, cast @@ -15,6 +16,7 @@ from opentrons_hardware.firmware_bindings.messages.fields import SensorIdField from opentrons_hardware.firmware_bindings.utils import UInt8Field from opentrons_hardware.firmware_bindings.constants import NodeId, SensorId +from opentrons_shared_data.errors.exceptions import CommandTimedOutError from tests.conftest import CanLoopback @@ -46,7 +48,9 @@ def responder( message_send_loopback.add_responder(responder) res = await get_tip_ejector_state( - mock_messenger, cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node) + mock_messenger, + cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node), + 1, ) # We should have sent a request @@ -61,7 +65,10 @@ async def test_tip_ejector_state_times_out(mock_messenger: AsyncMock) -> None: """Test that a timeout is handled.""" node = NodeId.pipette_left - res = await get_tip_ejector_state( - mock_messenger, cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node) - ) - assert not res + with pytest.raises(CommandTimedOutError): + res = await get_tip_ejector_state( + mock_messenger, + cast(Literal[NodeId.pipette_left, NodeId.pipette_right], node), + 1, + ) + assert not res diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py index ad61db961b9..a2ce23b7962 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py @@ -185,7 +185,7 @@ def move_responder( num_baseline_reads=8, sensor_id=SensorId.S0, ) - assert position[motor_node][0] == 14 + assert position[motor_node].positions_only()[0] == 14 assert mock_sensor_threshold.call_args_list[0][0][0] == SensorThresholdInformation( sensor=sensor_info, data=SensorDataType.build(threshold_pascals * 65536, sensor_info.sensor_type), @@ -270,7 +270,7 @@ def move_responder( current_position_um=UInt32Field(10000), encoder_position_um=Int32Field(10000), position_flags=MotorPositionFlagsField(0), - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), ) ), motor_node, @@ -281,11 +281,11 @@ def move_responder( message_send_loopback.add_responder(move_responder) - position, encoder_position = await capacitive_probe( + status = await capacitive_probe( mock_messenger, target_node, motor_node, distance, speed ) - assert position == 10 # this comes from the current_position_um above - assert encoder_position == 10 + assert status.motor_position == 10 # this comes from the current_position_um above + assert status.encoder_position == 10 # this mock assert is annoying because something's __eq__ doesn't work assert mock_sensor_threshold.call_args_list[0][0][0] == SensorThresholdInformation( sensor=sensor_info, @@ -354,7 +354,7 @@ def move_responder( current_position_um=UInt32Field(10000), encoder_position_um=Int32Field(10000), position_flags=MotorPositionFlagsField(0), - ack_id=UInt8Field(0), + ack_id=UInt8Field(1), ) ), motor_node, diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_update_motor_estimation.py b/hardware/tests/opentrons_hardware/hardware_control/test_update_motor_estimation.py index 036464a9de6..2f1648c0518 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_update_motor_estimation.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_update_motor_estimation.py @@ -21,6 +21,7 @@ ) from opentrons_hardware.hardware_control import motor_position_status +from opentrons_hardware.hardware_control.types import MotorPositionStatus NODE_TO_POS = {NodeId.gantry_x: 1000, NodeId.gantry_y: 2000, NodeId.head: 3000} @@ -93,7 +94,7 @@ async def test_parse_estimation_response( ), 1, ) - assert data == (NODE_TO_POS[node] / 1000, 0.123, True, False) + assert data == MotorPositionStatus(NODE_TO_POS[node] / 1000, 0.123, True, False) else: with pytest.raises(StopAsyncIteration): await asyncio.wait_for( diff --git a/protocol-designer/cypress/integration/batchEdit.spec.js b/protocol-designer/cypress/integration/batchEdit.spec.js index 7343f7f5552..7d96ea01a3e 100644 --- a/protocol-designer/cypress/integration/batchEdit.spec.js +++ b/protocol-designer/cypress/integration/batchEdit.spec.js @@ -98,7 +98,7 @@ function importProtocol() { cy.get('[data-test="ComputingSpinner"]').should('exist') cy.get('div') .contains( - 'We have added new features since the last time this protocol was updated, but have not made any changes to existing protocol behavior' + 'Your protocol will be automatically updated to the latest version.' ) .should('exist') cy.get('button').contains('ok', { matchCase: false }).click() diff --git a/protocol-designer/cypress/integration/migrations.spec.js b/protocol-designer/cypress/integration/migrations.spec.js index 6d20a5af15a..7788a9acf1e 100644 --- a/protocol-designer/cypress/integration/migrations.spec.js +++ b/protocol-designer/cypress/integration/migrations.spec.js @@ -12,61 +12,61 @@ describe('Protocol fixtures migrate and match snapshots', () => { }) const testCases = [ + // TODO(jr, 9/26/23): when 7.1 is more developed, lets go back and fix these + // { + // title: 'example_1_1_0 (schema 1, PD version 1.1.1) -> PD 7.0.x, schema 7', + // importFixture: '../../fixtures/protocol/1/example_1_1_0.json', + // expectedExportFixture: + // '../../fixtures/protocol/7/example_1_1_0MigratedFromV1_0_0.json', + // unusedPipettes: true, + // migrationModal: 'newLabwareDefs', + // }, + // { + // title: 'doItAllV3 (schema 3, PD version 4.0.0) -> PD 7.0.x, schema 7', + // importFixture: '../../fixtures/protocol/4/doItAllV3.json', + // expectedExportFixture: + // '../../fixtures/protocol/7/doItAllV3MigratedToV7.json', + // unusedPipettes: false, + // migrationModal: 'generic', + // }, + // { + // title: 'doItAllV4 (schema 4, PD version 4.0.0) -> PD 7.0.x, schema 7', + // importFixture: '../../fixtures/protocol/4/doItAllV4.json', + // expectedExportFixture: + // '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', + // unusedPipettes: false, + // migrationModal: 'generic', + // }, + // { + // title: 'doItAllV6 (schema 6, PD version 6.1.0) -> PD 7.0.x, schema 7', + // importFixture: '../../fixtures/protocol/6/doItAllV4MigratedToV6.json', + // expectedExportFixture: + // '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', + // unusedPipettes: false, + // migrationModal: 'generic', + // }, + // { + // title: + // 'doItAllV7 (schema 7, PD version 7.0.0) -> import and re-export should preserve data', + // importFixture: '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', + // expectedExportFixture: + // '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', + // unusedPipettes: false, + // migrationModal: null, + // }, + // { + // title: + // 'mix 5.0.x (schema 3, PD version 5.0.0) -> should migrate to 7.0.x, schema 7', + // importFixture: '../../fixtures/protocol/5/mix_5_0_x.json', + // expectedExportFixture: '../../fixtures/protocol/7/mix_7_0_0.json', + // migrationModal: 'generic', + // unusedPipettes: false, + // }, { - title: 'example_1_1_0 (schema 1, PD version 1.1.1) -> PD 7.0.x, schema 7', - importFixture: '../../fixtures/protocol/1/example_1_1_0.json', - expectedExportFixture: - '../../fixtures/protocol/7/example_1_1_0MigratedFromV1_0_0.json', - unusedPipettes: true, - migrationModal: 'newLabwareDefs', - }, - { - title: 'doItAllV3 (schema 3, PD version 4.0.0) -> PD 7.0.x, schema 7', - importFixture: '../../fixtures/protocol/4/doItAllV3.json', - expectedExportFixture: - '../../fixtures/protocol/7/doItAllV3MigratedToV7.json', - unusedPipettes: false, - migrationModal: 'noBehaviorChange', - }, - { - title: 'doItAllV4 (schema 4, PD version 4.0.0) -> PD 7.0.x, schema 7', - importFixture: '../../fixtures/protocol/4/doItAllV4.json', - expectedExportFixture: - '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', - unusedPipettes: false, - migrationModal: 'noBehaviorChange', - }, - { - title: 'doItAllV6 (schema 6, PD version 6.1.0) -> PD 7.0.x, schema 7', - importFixture: '../../fixtures/protocol/6/doItAllV4MigratedToV6.json', - expectedExportFixture: - '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', - unusedPipettes: false, - migrationModal: 'noBehaviorChange', - }, - { - title: - 'doItAllV7 (schema 7, PD version 7.0.0) -> import and re-export should preserve data', - importFixture: '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', - expectedExportFixture: - '../../fixtures/protocol/7/doItAllV4MigratedToV7.json', - unusedPipettes: false, - migrationModal: null, - }, - { - title: - 'mix 5.0.x (schema 3, PD version 5.0.0) -> should migrate to 7.0.x, schema 7', - importFixture: '../../fixtures/protocol/5/mix_5_0_x.json', - expectedExportFixture: '../../fixtures/protocol/7/mix_7_0_0.json', - migrationModal: 'noBehaviorChange', - unusedPipettes: false, - }, - { - title: - 'doItAllV7 Flex robot (schema 7, PD version 7.0.0) -> import and re-export should preserve data', + title: 'doItAllV7 Flex robot (schema 7, PD version 7.1.0)', importFixture: '../../fixtures/protocol/7/doItAllV7.json', expectedExportFixture: '../../fixtures/protocol/7/doItAllV7.json', - migrationModal: false, + migrationModal: null, unusedPipettes: false, }, ] diff --git a/protocol-designer/cypress/integration/mixSettings.spec.js b/protocol-designer/cypress/integration/mixSettings.spec.js index 2b9d5da1a0c..ce64ff8c095 100644 --- a/protocol-designer/cypress/integration/mixSettings.spec.js +++ b/protocol-designer/cypress/integration/mixSettings.spec.js @@ -13,7 +13,7 @@ function importProtocol() { cy.get('[data-test="ComputingSpinner"]').should('exist') cy.get('div') .contains( - 'We have added new features since the last time this protocol was updated, but have not made any changes to existing protocol behavior' + 'Your protocol will be automatically updated to the latest version.' ) .should('exist') cy.get('button').contains('ok', { matchCase: false }).click() diff --git a/protocol-designer/cypress/integration/transferSettings.spec.js b/protocol-designer/cypress/integration/transferSettings.spec.js index 37dcb296282..0d7d39dbc07 100644 --- a/protocol-designer/cypress/integration/transferSettings.spec.js +++ b/protocol-designer/cypress/integration/transferSettings.spec.js @@ -14,7 +14,7 @@ function importProtocol() { cy.get('[data-test="ComputingSpinner"]').should('exist') cy.get('div') .contains( - 'We have added new features since the last time this protocol was updated, but have not made any changes to existing protocol behavior' + 'Your protocol will be automatically updated to the latest version.' ) .should('exist') cy.get('button').contains('ok', { matchCase: false }).click() diff --git a/protocol-designer/fixtures/protocol/7/doItAllV7.json b/protocol-designer/fixtures/protocol/7/doItAllV7.json index a2c759c5437..fd8dd0a78e6 100644 --- a/protocol-designer/fixtures/protocol/7/doItAllV7.json +++ b/protocol-designer/fixtures/protocol/7/doItAllV7.json @@ -4,16 +4,16 @@ "author": "", "description": "", "created": 1689346890165, - "lastModified": 1694608126903, + "lastModified": 1695755031556, "category": null, "subcategory": null, "tags": [] }, "designerApplication": { "name": "opentrons/protocol-designer", - "version": "7.0.0", + "version": "7.1.0", "data": { - "_internalAppBuildDate": "Wed, 13 Sep 2023 12:28:06 GMT", + "_internalAppBuildDate": "Tue, 26 Sep 2023 18:58:15 GMT", "defaultValues": { "aspirate_mmFromBottom": 1, "dispense_mmFromBottom": 0.5, @@ -59,7 +59,7 @@ "savedStepForms": { "__INITIAL_DECK_SETUP_STEP__": { "labwareLocationUpdate": { - "fixedTrash": "12", + "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1": "A3", "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1": "C1", "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType", "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1": "ef44ad7f-0fd9-46d6-8bc0-c70785644cc8:temperatureModuleType", @@ -174,7 +174,7 @@ "disposalVolume_checkbox": true, "disposalVolume_volume": "100", "blowout_checkbox": false, - "blowout_location": "fixedTrash", + "blowout_location": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "preWetTip": false, "aspirate_airGap_checkbox": false, "aspirate_airGap_volume": "0", @@ -198,7 +198,7 @@ "mix_wellOrder_first": "t2b", "mix_wellOrder_second": "l2r", "blowout_checkbox": false, - "blowout_location": "fixedTrash", + "blowout_location": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "mix_mmFromBottom": 0.5, "pipette": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": "10", @@ -3730,14 +3730,10 @@ "cornerOffsetFromSlot": { "x": 0, "y": 0, "z": 0 }, "stackingOffsetWithLabware": { "opentrons_96_flat_bottom_adapter": { "x": 0, "y": 0, "z": 6.7 }, - "opentrons_aluminum_flat_bottom_plate": { - "x": 0, - "y": 0, - "z": 5.55 - } + "opentrons_aluminum_flat_bottom_plate": { "x": 0, "y": 0, "z": 5.55 } } }, - "opentrons/opentrons_1_trash_1100ml_fixed/1": { + "opentrons/opentrons_1_trash_3200ml_fixed/1": { "ordering": [["A1"]], "metadata": { "displayCategory": "trash", @@ -3749,14 +3745,14 @@ "version": 1, "namespace": "opentrons", "dimensions": { - "xDimension": 172.86, - "yDimension": 165.86, - "zDimension": 82 + "xDimension": 246.5, + "yDimension": 91.5, + "zDimension": 40 }, "parameters": { "format": "trash", "isTiprack": false, - "loadName": "opentrons_1_trash_1100ml_fixed", + "loadName": "opentrons_1_trash_3200ml_fixed", "isMagneticModuleCompatible": false, "quirks": [ "fixedTrash", @@ -3767,25 +3763,25 @@ "wells": { "A1": { "shape": "rectangular", - "yDimension": 165.67, - "xDimension": 107.11, - "totalLiquidVolume": 1100000, - "depth": 0, - "x": 82.84, - "y": 80, - "z": 82 + "yDimension": 78, + "xDimension": 225, + "totalLiquidVolume": 3200000, + "depth": 40, + "x": 123.25, + "y": 45.75, + "z": 0 } }, "brand": { "brand": "Opentrons" }, "groups": [{ "wells": ["A1"], "metadata": {} }], - "cornerOffsetFromSlot": { "x": 0, "y": 0, "z": 0 } + "cornerOffsetFromSlot": { "x": -17, "y": -2.75, "z": 0 } } }, "$otSharedSchema": "#/protocol/schemas/7", "schemaVersion": 7, "commands": [ { - "key": "70156bb6-4e75-4087-94f5-367f832394f3", + "key": "de14fbc1-e2e8-4a3b-aa12-89d508c2a80a", "commandType": "loadPipette", "params": { "pipetteName": "p1000_single_flex", @@ -3794,7 +3790,7 @@ } }, { - "key": "c0ebb2c1-211c-49cd-ad9a-f75f2fce17ce", + "key": "2b1ed982-6505-4de0-ba3d-1f3e7e593f94", "commandType": "loadPipette", "params": { "pipetteName": "p50_multi_flex", @@ -3803,7 +3799,7 @@ } }, { - "key": "878ea265-ee22-463b-a413-9219fa68f6b7", + "key": "2078d6a5-48fd-4510-b9d2-63b494397087", "commandType": "loadModule", "params": { "model": "magneticBlockV1", @@ -3812,7 +3808,7 @@ } }, { - "key": "a0059887-8f94-49e1-a944-eec3e0656324", + "key": "d81a5961-f42d-4378-94bb-2ba2c62d40db", "commandType": "loadModule", "params": { "model": "heaterShakerModuleV1", @@ -3821,7 +3817,7 @@ } }, { - "key": "0376b406-ea82-4abc-ac7f-7ffbbb2e92f2", + "key": "cb87fd96-fed6-4661-bf65-bb751ddce333", "commandType": "loadModule", "params": { "model": "temperatureModuleV2", @@ -3830,7 +3826,7 @@ } }, { - "key": "90a8b61d-2b53-4411-8766-dd4fd5b66129", + "key": "99316c37-bd11-48d7-b8d2-2901a6248169", "commandType": "loadModule", "params": { "model": "thermocyclerModuleV2", @@ -3839,7 +3835,7 @@ } }, { - "key": "dd191121-d799-4ea0-bb8a-a1e9a71ca9b0", + "key": "1db38ee9-dd4d-4a4c-9c13-24801e2ddc78", "commandType": "loadLabware", "params": { "displayName": "Opentrons 96 Flat Bottom Adapter", @@ -3853,7 +3849,19 @@ } }, { - "key": "15e24d19-7413-4245-80f1-ff4a2790dd99", + "key": "63f8a3e1-9508-46e9-9f57-01c5c0d86fe6", + "commandType": "loadLabware", + "params": { + "displayName": "Opentrons Fixed Trash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", + "loadName": "opentrons_1_trash_3200ml_fixed", + "namespace": "opentrons", + "version": 1, + "location": { "slotName": "A3" } + } + }, + { + "key": "84129019-df06-4786-8284-6247d102b82f", "commandType": "loadLabware", "params": { "displayName": "Opentrons Flex 96 Filter Tip Rack 50 µL", @@ -3865,7 +3873,7 @@ } }, { - "key": "a126d484-3ca9-46f9-88f2-bc031b572dc5", + "key": "b77ccba9-a6a3-482c-943e-2e39297c01f0", "commandType": "loadLabware", "params": { "displayName": "NEST 96 Well Plate 100 µL PCR Full Skirt", @@ -3879,7 +3887,7 @@ } }, { - "key": "365d94e7-d09a-4f44-a2e3-3ac6962595a5", + "key": "c0303c6c-a462-4659-93e3-ee9401bab21f", "commandType": "loadLabware", "params": { "displayName": "Opentrons 24 Well Aluminum Block with NEST 1.5 mL Snapcap", @@ -3893,7 +3901,7 @@ } }, { - "key": "e140a07b-03eb-45f8-a889-28c6f383456d", + "key": "aece300b-8a5e-4120-8b0d-441a81b8cc3a", "commandType": "loadLabware", "params": { "displayName": "NEST 96 Well Plate 200 µL Flat", @@ -3908,7 +3916,7 @@ }, { "commandType": "loadLiquid", - "key": "7e318557-1dde-4e2a-a09b-5fc0969a422a", + "key": "5e8b91c8-a3e9-4464-9231-44fd122ea1b0", "params": { "liquidId": "1", "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", @@ -3917,7 +3925,7 @@ }, { "commandType": "loadLiquid", - "key": "b9644425-85d3-40ef-b9d7-e954ad0ba0eb", + "key": "ca09c741-542e-4af2-9a9a-02cc893176b2", "params": { "liquidId": "0", "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", @@ -3935,7 +3943,7 @@ }, { "commandType": "temperatureModule/setTargetTemperature", - "key": "b3971959-c9f3-45d4-b6b0-b1810ff6e439", + "key": "ef759a65-e434-43d6-b5bb-8eea4dcc7376", "params": { "moduleId": "ef44ad7f-0fd9-46d6-8bc0-c70785644cc8:temperatureModuleType", "celsius": 4 @@ -3943,7 +3951,7 @@ }, { "commandType": "heaterShaker/waitForTemperature", - "key": "22456876-beab-4d63-b956-093ed1b1d721", + "key": "9c4194d3-3ec3-4e9f-9ddc-7a8a5532785c", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType", "celsius": 4 @@ -3951,14 +3959,14 @@ }, { "commandType": "thermocycler/closeLid", - "key": "415c4477-9920-4bfc-aa17-a18717479bfc", + "key": "72f8e0b9-a0f6-4a0b-892d-c54cfd3f7059", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/setTargetLidTemperature", - "key": "c42ab3d1-5391-4a65-afb1-4af732a30ffe", + "key": "99235250-9c9f-4dfc-b0fc-f3d1f5f45fb5", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType", "celsius": 40 @@ -3966,14 +3974,14 @@ }, { "commandType": "thermocycler/waitForLidTemperature", - "key": "201d0900-ac0d-41c5-b704-8e97833e8ab3", + "key": "6d134c76-b6d6-464e-adc7-45f1e6338384", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/runProfile", - "key": "427f6fd3-1b12-4f4b-b7d7-30128b27be24", + "key": "f7d5f044-f4e7-4666-ac30-344d933b1c1a", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType", "profile": [ @@ -3985,28 +3993,28 @@ }, { "commandType": "thermocycler/deactivateBlock", - "key": "27f65750-ddb8-41a2-bcec-7809b492257e", + "key": "cef9592a-f598-48c6-95b4-3ec0e3180bc6", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/deactivateLid", - "key": "beea24a1-2ae9-4919-91cf-e4bdcc65021e", + "key": "e8aff517-52e1-4389-a7d5-305bc4972901", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "thermocycler/openLid", - "key": "355a9342-7ee4-4093-b052-bc37cd1ff74b", + "key": "0c7ef0c5-e6cb-4198-b6e5-5c88aca952d5", "params": { "moduleId": "627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType" } }, { "commandType": "pickUpTip", - "key": "dfc140ed-e295-4190-9219-6b591fe89c42", + "key": "239a95ce-61f3-4453-b7ff-474b1d74e8c6", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4015,7 +4023,7 @@ }, { "commandType": "aspirate", - "key": "7cffdd19-ba70-451e-af60-c79ca8a0da36", + "key": "759eaeaf-e6b6-4259-a4b9-c0d8cb8f74bd", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4027,7 +4035,7 @@ }, { "commandType": "dispense", - "key": "3bc64382-6327-4e47-b69a-1b96c511bb9a", + "key": "f841c259-3ea3-4680-adb9-00b86a352000", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4039,16 +4047,16 @@ }, { "commandType": "dropTip", - "key": "d7dac683-618f-4607-bd10-76018e59f88f", + "key": "1a6fc406-0502-4580-893b-f81ffd7731d7", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "3353be58-d98e-4d1f-a211-35396e066d60", + "key": "7cde6dde-776f-4e1e-9fca-5675144c6769", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4057,7 +4065,7 @@ }, { "commandType": "aspirate", - "key": "f58188aa-f83c-4f30-bcd7-eb3c5ac41b66", + "key": "23a6653d-f063-4e2c-a4fc-f62fc943fdb4", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4069,7 +4077,7 @@ }, { "commandType": "dispense", - "key": "9eeba918-faac-41ad-b682-6a68df7b4e96", + "key": "f900ce0b-c4d2-4917-814e-508b5b6e1f5c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4081,16 +4089,16 @@ }, { "commandType": "dropTip", - "key": "75e0e278-bb36-41ad-9fd1-a5e3115bd16c", + "key": "44851df5-60c4-413b-86a3-9e318782adae", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "8cf793df-1287-45f1-bf72-d3571d359be8", + "key": "8729d44e-9d84-4a84-9dad-fe0e01bae92e", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4099,7 +4107,7 @@ }, { "commandType": "aspirate", - "key": "37bee79d-086a-414f-8987-9d110d5c1796", + "key": "0e53b1fa-48ee-48f0-b070-0dc7d71eca27", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4111,7 +4119,7 @@ }, { "commandType": "dispense", - "key": "b04afc19-fee4-4a1e-a99f-bd8c0b31ea6e", + "key": "977e26bf-e824-4fe1-8d62-7ec245f0556c", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4123,16 +4131,16 @@ }, { "commandType": "dropTip", - "key": "33b993e6-1bfa-4d31-9269-4efd343e2a74", + "key": "419e1de0-f1f8-4f43-833d-06ce3c32d20d", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "2aa917c4-9ac0-464f-a41d-2974fd477ac0", + "key": "dcf59a8b-257c-4bee-bffe-266507a5aab3", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4141,7 +4149,7 @@ }, { "commandType": "aspirate", - "key": "db3e5bd4-8511-4a0d-aff0-6bf351e1ea8b", + "key": "e969e6d2-0092-42ab-8deb-1e27cfecb557", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4153,7 +4161,7 @@ }, { "commandType": "dispense", - "key": "85ae6787-4148-4c36-863d-75a5b473a0e0", + "key": "287cbe62-02d9-4fdb-93d2-d595aa637526", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4165,16 +4173,16 @@ }, { "commandType": "dropTip", - "key": "01232d14-ee91-41fa-86a8-488ecb5a761f", + "key": "84f31ede-7edd-447c-9449-6f494c91a621", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "b8a7fb8a-585f-496c-acdf-48c1c0d62e0b", + "key": "25678478-e8ed-4187-98fd-d4d1d47a93fa", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4183,7 +4191,7 @@ }, { "commandType": "aspirate", - "key": "1abd13f3-e1b5-4076-8735-566fd1d53d8f", + "key": "76389897-c6e1-43c3-ba45-155ddbfc920a", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4195,7 +4203,7 @@ }, { "commandType": "dispense", - "key": "84024bde-4ba6-4c60-bfa4-a78e11979c11", + "key": "ed1cf7c7-a249-4ccd-ac82-1e870f048362", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4207,16 +4215,16 @@ }, { "commandType": "dropTip", - "key": "5b5e6825-d964-4727-8e09-920bfe1f4c57", + "key": "916c823e-3c94-4cf9-8346-81eed082f8db", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "d482ac5f-dbd3-4d13-af2f-870d0a01524f", + "key": "b5fd3e9e-6827-4513-9f56-439c869a029f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4225,7 +4233,7 @@ }, { "commandType": "aspirate", - "key": "854a437f-57b4-4fca-b13e-8041a2c4faed", + "key": "cbadf8b4-a8ee-4573-ab7d-f2b88ef662f2", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4237,7 +4245,7 @@ }, { "commandType": "dispense", - "key": "b8c9bb1c-2ccb-4e86-a947-ffca14897d6e", + "key": "45811f6a-9ba1-474d-8af3-681297f14d43", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4249,16 +4257,16 @@ }, { "commandType": "dropTip", - "key": "556aa41f-bc79-4ef4-a136-b2c291883b49", + "key": "63a99616-95ea-465b-af11-79b8f35537ac", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "814f18f7-8c0b-4ba0-86fa-10f7b47b7f31", + "key": "8ed81f11-9fe1-4f18-a84c-42b353faaf30", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4267,7 +4275,7 @@ }, { "commandType": "aspirate", - "key": "a1035705-a625-4cf7-ba2c-98910ecd4834", + "key": "8344fb4f-27cb-4611-8a01-849a5757f4c5", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4279,7 +4287,7 @@ }, { "commandType": "dispense", - "key": "6fde8e45-2e4a-4508-923a-1c908fcc2e9b", + "key": "752dd11b-a824-4a96-96d0-ddd28858d47f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4291,16 +4299,16 @@ }, { "commandType": "dropTip", - "key": "7542bebc-c307-4e0e-b681-953b03089250", + "key": "bd16d128-3817-40b1-8c3d-5c9f7f0e560f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "2817eeb0-d727-497f-b7a9-1ab0e2472a6f", + "key": "bf97f499-e025-4dd5-9e9b-09f4bb567a83", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4309,7 +4317,7 @@ }, { "commandType": "aspirate", - "key": "2fee1a7c-754e-453a-a38e-08e2d6770b72", + "key": "9c3f5b5b-bda3-4485-b48e-45bf4938b9ab", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4321,7 +4329,7 @@ }, { "commandType": "dispense", - "key": "589d0c90-0dcb-4d3d-add7-47f27c2a1c8e", + "key": "b5ff2126-fc5a-420c-a232-3ff5f341fc63", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4333,16 +4341,16 @@ }, { "commandType": "dropTip", - "key": "7bf8e6b3-6121-4c79-a40f-4f95e71a4081", + "key": "49324cc8-34c2-4398-b30a-2a052deab15e", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "81a081d2-014d-46a8-84a0-32c5db561c43", + "key": "08212567-5c9f-47dd-ac29-15b5e1449f11", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4351,7 +4359,7 @@ }, { "commandType": "aspirate", - "key": "d760060f-8936-409d-8329-d6986d48c1f8", + "key": "81144f12-50f4-499b-b98f-1161fc4b6913", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4363,7 +4371,7 @@ }, { "commandType": "dispense", - "key": "577080fc-ef68-4415-b06c-a48342d8ec53", + "key": "4a2f97c3-6575-4509-875c-afc87400070f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4375,16 +4383,16 @@ }, { "commandType": "dropTip", - "key": "4fc4d1e2-4979-4706-87dc-020947b85b7e", + "key": "518237bc-d1b8-4fc8-87fe-3ca23e2cc33d", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "916572b4-00f7-4af5-bccd-9a015bf22f6b", + "key": "8d4bb1ea-b967-4486-8dab-817e0111db6f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4393,7 +4401,7 @@ }, { "commandType": "aspirate", - "key": "3f114a9b-98a0-4f8b-b2de-973d0ae9c477", + "key": "19f35977-b861-45be-adc1-7a733424b5d5", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4405,7 +4413,7 @@ }, { "commandType": "dispense", - "key": "2c73cf41-93ee-48cc-b382-17fd28adc799", + "key": "a8fce22a-997e-4c4c-8b77-0c4db0e1d6fe", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4417,16 +4425,16 @@ }, { "commandType": "dropTip", - "key": "48ac1ee7-0ea4-4805-9c0f-30ff75aa5cb2", + "key": "e3339560-6b34-485e-a61b-e80cbcf803be", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "25acd345-f9bd-4b23-9994-24da11a487ac", + "key": "24b8d8e6-8949-4474-8f84-5eef0512f978", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4435,7 +4443,7 @@ }, { "commandType": "aspirate", - "key": "16b20b99-6d3b-43e5-8e8f-4292150e120f", + "key": "510a44ff-fced-4cce-adaf-b38172a99c62", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4447,7 +4455,7 @@ }, { "commandType": "dispense", - "key": "c1b60549-7bfa-449e-8fb0-0dbc53d2a94b", + "key": "85d9ef05-6642-4afa-8d00-7a47349b9612", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4459,16 +4467,16 @@ }, { "commandType": "dropTip", - "key": "8f027197-902b-4e1f-b3f6-1caa57cb1700", + "key": "ad2f40ed-df71-4227-9c3b-0558275f93c0", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "c6854d17-1ed0-4941-acd3-988c6c78e1e0", + "key": "6aaab560-e0a2-4fef-9511-96dcfbcd4184", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4477,7 +4485,7 @@ }, { "commandType": "aspirate", - "key": "2a8e835f-a83b-4f5f-a4f1-c6b1261e06b3", + "key": "451a190d-4e36-4e6a-b868-ddb6a055a8d2", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4489,7 +4497,7 @@ }, { "commandType": "dispense", - "key": "76bb8b02-161c-4850-aa6c-fbacef751ad1", + "key": "6271db77-4cd4-4498-a9ad-50651afc2df9", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4501,16 +4509,16 @@ }, { "commandType": "dropTip", - "key": "2a1ab90d-14f2-4452-be8b-381735223c74", + "key": "a37ba6ab-824b-43c4-97cc-19dc199032b9", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "3df1d6f7-65cf-42f6-8a9f-7af01cb96eec", + "key": "e6f6f706-1b2a-4480-b098-565f120ca2be", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4519,7 +4527,7 @@ }, { "commandType": "aspirate", - "key": "8b5c0df0-0b33-4405-815d-03672b2b7fba", + "key": "f9c8e826-baa9-4129-bda7-ae3e3f73c483", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4531,7 +4539,7 @@ }, { "commandType": "dispense", - "key": "4481a649-9c2f-4f97-8c00-1e57c1de4c61", + "key": "70dcdcd8-4214-49d5-b182-79d04cb58683", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4543,16 +4551,16 @@ }, { "commandType": "dropTip", - "key": "c5766753-b837-4f65-89ba-167cd941378f", + "key": "1a483fdc-0575-45fb-9e92-975222f043b6", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "1606194c-fa97-40c4-98bb-beb05bdbe300", + "key": "bb9c56b4-9c64-4247-9a3e-6864cc85a639", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4561,7 +4569,7 @@ }, { "commandType": "aspirate", - "key": "5b486cbe-4bd5-4b62-82f4-c4e14e7b6df8", + "key": "260578b7-796b-418d-bf12-56484a9e593a", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4573,7 +4581,7 @@ }, { "commandType": "dispense", - "key": "a2c1cafa-3dde-4805-8844-0cf4fe9e7a1f", + "key": "44f27994-6a7b-400a-b3d3-bc80357e8931", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4585,16 +4593,16 @@ }, { "commandType": "dropTip", - "key": "f2f315ec-6b0b-4100-ba3f-c9971858e5f5", + "key": "50eccd27-1248-4d3d-9170-d9ab7c34355b", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "e45c5974-d348-4171-9247-be27a2e7bb53", + "key": "3e5313bc-7d97-45d3-bbaf-adf8e1f332ba", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4603,7 +4611,7 @@ }, { "commandType": "aspirate", - "key": "31402b63-3c1a-4d2f-a8e4-14c2322945cc", + "key": "f7b14872-dfdf-4b8d-8de5-2c16e65506ae", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4615,7 +4623,7 @@ }, { "commandType": "dispense", - "key": "f17998bd-207f-46c9-91e0-79fec2d7ee10", + "key": "3d904fbc-82fe-488c-9ad3-c012c9435ec8", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4627,16 +4635,16 @@ }, { "commandType": "dropTip", - "key": "7cb12333-4765-4eff-992a-c51683073b2c", + "key": "ff218f5b-4af0-4ac5-bdea-a65fbe0c9baf", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "d014eb58-5f6f-4e89-8bb2-e1cbc5be9a2e", + "key": "f3441996-ac73-401f-acff-249c6434c9f4", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4645,7 +4653,7 @@ }, { "commandType": "aspirate", - "key": "19aa7563-cc15-4834-a54c-984493397047", + "key": "970a51c0-1d10-4386-a9eb-8ea60512f081", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4657,7 +4665,7 @@ }, { "commandType": "dispense", - "key": "e5d379f2-a2c2-48ac-b136-a374411d7176", + "key": "72834ef3-db72-48f8-883e-0a24e7f31dd8", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", "volume": 50, @@ -4669,16 +4677,16 @@ }, { "commandType": "dropTip", - "key": "b0029b7b-005b-4243-85cb-4ab4acbd921a", + "key": "7404ee18-8e3b-49f0-a6f8-661c9200722f", "params": { "pipetteId": "2e7c6344-58ab-465c-b542-489883cb63fe", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "pickUpTip", - "key": "d87b020c-20a9-4283-8e91-5e0dc931e642", + "key": "afd3f406-69b1-46d9-8867-4e5a6a30a73e", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "labwareId": "23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1", @@ -4687,7 +4695,7 @@ }, { "commandType": "configureForVolume", - "key": "ea06c743-d0ce-477a-9e35-b65513004622", + "key": "4fc3002d-05ac-41fb-aacc-277c352c075c", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10 @@ -4695,7 +4703,7 @@ }, { "commandType": "aspirate", - "key": "f6b08f38-4a3e-4f36-a6df-63bbdefb8210", + "key": "ba0c95b8-bbef-400f-beb5-6892a5bb4972", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, @@ -4707,7 +4715,7 @@ }, { "commandType": "dispense", - "key": "4538bae6-4299-4eef-bd26-997383eab06e", + "key": "71944e92-038c-49c4-bf60-88285fc70678", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, @@ -4719,7 +4727,7 @@ }, { "commandType": "aspirate", - "key": "b07f9f5c-11cf-4892-a862-fd432b5e0eb6", + "key": "bcc4091c-d33b-44a6-9ef8-47991722cde3", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, @@ -4731,7 +4739,7 @@ }, { "commandType": "dispense", - "key": "22f9e9df-5d7e-4bf0-8a4d-eb1b33a877ff", + "key": "1bf6f1c3-ba91-4c53-8e35-5c3b762f20c7", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", "volume": 10, @@ -4743,16 +4751,16 @@ }, { "commandType": "dropTip", - "key": "d21ae698-a0cc-4bb6-a284-de36b4ad0fbe", + "key": "fa3753fd-aa3f-4eb3-be8c-a01c15e2c81a", "params": { "pipetteId": "6d1e53c3-2db3-451b-ad60-3fe13781a193", - "labwareId": "fixedTrash", + "labwareId": "89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1", "wellName": "A1" } }, { "commandType": "moveLabware", - "key": "d8c662b8-e0c9-40dd-8874-d89e1da1f57b", + "key": "e1ddc500-2dd7-45e0-9c2b-93dde844ff35", "params": { "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "strategy": "usingGripper", @@ -4761,12 +4769,12 @@ }, { "commandType": "waitForDuration", - "key": "db02ab6c-fc45-4840-acda-6f79acaa463b", + "key": "5e9e2998-5f91-463b-841f-d4fae592e209", "params": { "seconds": 60, "message": "" } }, { "commandType": "moveLabware", - "key": "651accac-df1d-4200-9854-7e6373ce4755", + "key": "ea23ae98-614f-4fdb-bebe-a5936b5136c7", "params": { "labwareId": "fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2", "strategy": "usingGripper", @@ -4775,21 +4783,21 @@ }, { "commandType": "heaterShaker/closeLabwareLatch", - "key": "505c8515-c61d-41c9-b318-fe0aae624251", + "key": "b4aaf1b6-8606-4624-9d9d-4bd3594e631e", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/deactivateHeater", - "key": "1bbdb172-f0fe-4b1c-b8dd-d2833efdd5c8", + "key": "a55b8261-4248-4a6e-9e65-5cfafdc55a21", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/setAndWaitForShakeSpeed", - "key": "435d2707-dbb0-4339-af9f-13255464dfca", + "key": "fe2feff0-8212-4e4d-9062-e4625d5cfbd2", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType", "rpm": 500 @@ -4797,28 +4805,28 @@ }, { "commandType": "heaterShaker/deactivateHeater", - "key": "632c1e11-8a4b-418c-9db3-331eb7dfcee8", + "key": "a46cc743-4319-4e36-b676-310af4d04acf", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/deactivateShaker", - "key": "9415ddab-dc51-4c00-ac29-1ed4619f0136", + "key": "04f9174c-67a0-457b-9153-7b00a8bb5fcd", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "heaterShaker/openLabwareLatch", - "key": "f07e3aeb-4af7-4641-8f68-44011dc5f7ea", + "key": "db8af8b2-9746-494a-b3c6-5d0ae2efeb2a", "params": { "moduleId": "c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType" } }, { "commandType": "moveLabware", - "key": "99f93f6f-eadf-4b4d-bb4d-c630af96abc3", + "key": "80002463-dd63-4fac-b2f8-bf46d01794bf", "params": { "labwareId": "a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1", "strategy": "manualMoveWithPause", @@ -4827,14 +4835,14 @@ }, { "commandType": "temperatureModule/deactivate", - "key": "192e4aca-9efd-4b61-af6b-a6517dcce079", + "key": "02fc0295-a55a-432c-b34e-d5131ad93b67", "params": { "moduleId": "ef44ad7f-0fd9-46d6-8bc0-c70785644cc8:temperatureModuleType" } }, { "commandType": "moveLabware", - "key": "ab2b9475-4570-4764-acb4-a21245346b97", + "key": "754107ac-2d9e-46e7-afcf-30964892020b", "params": { "labwareId": "239ceac8-23ec-4900-810a-70aeef880273:opentrons/nest_96_wellplate_200ul_flat/2", "strategy": "manualMoveWithPause", diff --git a/protocol-designer/src/atoms/Slideout.tsx b/protocol-designer/src/atoms/Slideout.tsx new file mode 100644 index 00000000000..1f57a3207ae --- /dev/null +++ b/protocol-designer/src/atoms/Slideout.tsx @@ -0,0 +1,225 @@ +import * as React from 'react' +import { css } from 'styled-components' +import { + Box, + Flex, + DIRECTION_ROW, + DIRECTION_COLUMN, + Btn, + Icon, + SPACING, + JUSTIFY_SPACE_BETWEEN, + ALIGN_CENTER, + COLORS, + Overlay, + POSITION_FIXED, + TYPOGRAPHY, + Text, +} from '@opentrons/components' + +export interface SlideoutProps { + title: string | React.ReactElement + children: React.ReactNode + onCloseClick: () => unknown + // isExpanded is for collapse and expand animation + isExpanded?: boolean + footer?: React.ReactNode +} + +const SHARED_STYLE = css` + z-index: 2; + overflow: hidden; + @keyframes slidein { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } + } + @keyframes slideout { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } + } + @keyframes overlayin { + from { + opacity: 0; + } + to { + opacity: 0.35; + } + } + @keyframes overlayout { + from { + opacity: 0.35; + visibility: visible; + } + to { + opacity: 0; + visibility: hidden; + } + } +` +const EXPANDED_STYLE = css` + ${SHARED_STYLE} + animation: slidein 300ms forwards; +` +const COLLAPSED_STYLE = css` + ${SHARED_STYLE} + animation: slideout 300ms forwards; +` +const INITIALLY_COLLAPSED_STYLE = css` + ${SHARED_STYLE} + animation: slideout 0ms forwards; +` +const OVERLAY_IN_STYLE = css` + ${SHARED_STYLE} + animation: overlayin 300ms forwards; +` +const OVERLAY_OUT_STYLE = css` + ${SHARED_STYLE} + animation: overlayout 300ms forwards; +` +const INITIALLY_OVERLAY_OUT_STYLE = css` + ${SHARED_STYLE} + animation: overlayout 0ms forwards; +` + +const CLOSE_ICON_STYLE = css` + border-radius: 50%; + + &:hover { + background: ${COLORS.lightGreyHover}; + } + &:active { + background: ${COLORS.lightGreyPressed}; + } +` + +export const Slideout = (props: SlideoutProps): JSX.Element => { + const { isExpanded, title, onCloseClick, children, footer } = props + const slideOutRef = React.useRef(null) + const [isReachedBottom, setIsReachedBottom] = React.useState(false) + + const hasBeenExpanded = React.useRef(isExpanded ?? false) + const handleScroll = (): void => { + if (slideOutRef.current == null) return + const { scrollTop, scrollHeight, clientHeight } = slideOutRef.current + if (scrollTop + clientHeight === scrollHeight) { + setIsReachedBottom(true) + } else { + setIsReachedBottom(false) + } + } + + React.useEffect(() => { + handleScroll() + }, [slideOutRef]) + + const handleClose = (): void => { + hasBeenExpanded.current = true + onCloseClick() + } + + const collapsedStyle = hasBeenExpanded.current + ? COLLAPSED_STYLE + : INITIALLY_COLLAPSED_STYLE + const overlayOutStyle = hasBeenExpanded.current + ? OVERLAY_OUT_STYLE + : INITIALLY_OVERLAY_OUT_STYLE + return ( + <> + + + + {typeof title === 'string' ? ( + + + {title} + + + + + + + + ) : ( + <>{title} + )} + + + {children} + + {footer != null ? ( + + {footer} + + ) : null} + + + + ) +} diff --git a/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx b/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx index fd51a69136f..4605263e826 100644 --- a/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx +++ b/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx @@ -79,7 +79,6 @@ export const AdapterControlsComponents = ( (itemType !== DND_TYPES.LABWARE && itemType !== null) ) return null - const draggedDef = draggedItem?.labwareOnDeck?.def const isCustomLabware = draggedItem ? getLabwareIsCustom(customLabwareDefs, draggedItem.labwareOnDeck) @@ -127,7 +126,7 @@ export const AdapterControlsComponents = ( {!isOver && } {i18n.t( - `deck.overlay.slot.${isOver ? 'place_here' : 'add_adapter'}` + `deck.overlay.slot.${isOver ? 'place_here' : 'add_labware'}` )} @@ -146,18 +145,23 @@ const mapStateToProps = (state: BaseState): SP => { } } -const mapDispatchToProps = ( - dispatch: ThunkDispatch, - ownProps: OP -): DP => ({ - addLabware: () => dispatch(openAddLabwareModal({ slot: ownProps.labwareId })), - moveDeckItem: (sourceSlot, destSlot) => - dispatch(moveDeckItem(sourceSlot, destSlot)), - deleteLabware: () => { - window.confirm(i18n.t('deck.warning.cancelForSure')) && - dispatch(deleteContainer({ labwareId: ownProps.labwareId })) - }, -}) +const mapDispatchToProps = (dispatch: ThunkDispatch, ownProps: OP): DP => { + const adapterName = + ownProps.allLabware.find(labware => labware.id === ownProps.labwareId)?.def + .metadata.displayName ?? '' + + return { + addLabware: () => + dispatch(openAddLabwareModal({ slot: ownProps.labwareId })), + moveDeckItem: (sourceSlot, destSlot) => + dispatch(moveDeckItem(sourceSlot, destSlot)), + deleteLabware: () => { + window.confirm( + i18n.t('deck.warning.cancelForSure', { adapterName: adapterName }) + ) && dispatch(deleteContainer({ labwareId: ownProps.labwareId })) + }, + } +} const slotTarget = { drop: (props: SlotControlsProps, monitor: DropTargetMonitor) => { diff --git a/protocol-designer/src/components/DeckSetup/LabwareOverlays/BrowseLabware.tsx b/protocol-designer/src/components/DeckSetup/LabwareOverlays/BrowseLabware.tsx index 907f63a2a85..96dbfa15097 100644 --- a/protocol-designer/src/components/DeckSetup/LabwareOverlays/BrowseLabware.tsx +++ b/protocol-designer/src/components/DeckSetup/LabwareOverlays/BrowseLabware.tsx @@ -3,14 +3,16 @@ import cx from 'classnames' import { connect } from 'react-redux' import { Icon } from '@opentrons/components' import { i18n } from '../../../localization' -import { ThunkDispatch } from '../../../types' -import { LabwareOnDeck } from '../../../step-forms' import { drillDownOnLabware } from '../../../labware-ingred/actions' import { resetScrollElements } from '../../../ui/steps/utils' import styles from './LabwareOverlays.css' +import type { LabwareEntity } from '@opentrons/step-generation' +import type { ThunkDispatch } from '../../../types' +import type { LabwareOnDeck } from '../../../step-forms' + interface OP { - labwareOnDeck: LabwareOnDeck + labwareOnDeck: LabwareOnDeck | LabwareEntity } interface DP { @@ -20,7 +22,11 @@ interface DP { type Props = OP & DP function BrowseLabwareOverlay(props: Props): JSX.Element | null { - if (props.labwareOnDeck.def.parameters.isTiprack) return null + if ( + props.labwareOnDeck.def.parameters.isTiprack || + props.labwareOnDeck.def.allowedRoles?.includes('adapter') + ) + return null return (
    diff --git a/protocol-designer/src/components/DeckSetup/LabwareOverlays/EditLabwareOffDeck.tsx b/protocol-designer/src/components/DeckSetup/LabwareOverlays/EditLabwareOffDeck.tsx new file mode 100644 index 00000000000..3828b542161 --- /dev/null +++ b/protocol-designer/src/components/DeckSetup/LabwareOverlays/EditLabwareOffDeck.tsx @@ -0,0 +1,145 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { css } from 'styled-components' +import { + ALIGN_FLEX_START, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Icon, + JUSTIFY_SPACE_AROUND, + POSITION_ABSOLUTE, + SPACING, +} from '@opentrons/components' +import { getLabwareDisplayName } from '@opentrons/shared-data' +import { i18n } from '../../../localization' +import { + deleteContainer, + duplicateLabware, + openIngredientSelector, +} from '../../../labware-ingred/actions' +import { selectors as labwareIngredSelectors } from '../../../labware-ingred/selectors' +import { NameThisLabware } from './NameThisLabware' +import styles from './LabwareOverlays.css' + +import type { LabwareEntity } from '@opentrons/step-generation' +import type { BaseState, ThunkDispatch } from '../../../types' + +const NAME_LABWARE_OVERLAY_STYLE = css` + z-index: 1; + bottom: 0; + position: ${POSITION_ABSOLUTE}; + width: 127.76px; + height: 85.45px; + opacity: 0; + &:hover { + opacity: 1; + } +` + +const REGULAR_OVERLAY_STYLE = css` + z-index: 1; + padding: ${SPACING.spacing8}; + background-color: ${COLORS.darkBlack90}; + flex-direction: ${DIRECTION_COLUMN}; + color: ${COLORS.white}; + display: flex; + align-items: ${ALIGN_FLEX_START}; + justify-content: ${JUSTIFY_SPACE_AROUND}; + border-radius: ${BORDERS.borderRadiusSize4}; + bottom: 0; + font-size: 0.7rem; + position: ${POSITION_ABSOLUTE}; + width: 127.76px; + height: 85.45px; + opacity: 0; + &:hover { + opacity: 1; + } +` + +interface OP { + labwareEntity: LabwareEntity +} +interface SP { + isYetUnnamed: boolean +} +interface DP { + editLiquids: () => void + duplicateLabware: () => void + deleteLabware: () => void +} + +type Props = OP & SP & DP + +const EditLabwareOffDeckComponent = (props: Props): JSX.Element => { + const { + labwareEntity, + isYetUnnamed, + editLiquids, + deleteLabware, + duplicateLabware, + } = props + + const { isTiprack } = labwareEntity.def.parameters + if (isYetUnnamed && !isTiprack) { + return ( +
    + +
    + ) + } else { + return ( +
    + {!isTiprack ? ( + + + {i18n.t('deck.overlay.edit.name_and_liquids')} + + ) : ( + + ) + } +} + +const mapStateToProps = (state: BaseState, ownProps: OP): SP => { + const { id } = ownProps.labwareEntity + const hasName = labwareIngredSelectors.getSavedLabware(state)[id] + return { + isYetUnnamed: !ownProps.labwareEntity.def.parameters.isTiprack && !hasName, + } +} + +const mapDispatchToProps = ( + dispatch: ThunkDispatch, + ownProps: OP +): DP => ({ + editLiquids: () => + dispatch(openIngredientSelector(ownProps.labwareEntity.id)), + duplicateLabware: () => dispatch(duplicateLabware(ownProps.labwareEntity.id)), + deleteLabware: () => { + window.confirm( + `Are you sure you want to permanently delete this ${getLabwareDisplayName( + ownProps.labwareEntity.def + )}?` + ) && dispatch(deleteContainer({ labwareId: ownProps.labwareEntity.id })) + }, +}) + +export const EditLabwareOffDeck = connect( + mapStateToProps, + mapDispatchToProps +)(EditLabwareOffDeckComponent) diff --git a/protocol-designer/src/components/DeckSetup/LabwareOverlays/NameThisLabware.tsx b/protocol-designer/src/components/DeckSetup/LabwareOverlays/NameThisLabware.tsx index 4a1be9cf21f..ae52627004a 100644 --- a/protocol-designer/src/components/DeckSetup/LabwareOverlays/NameThisLabware.tsx +++ b/protocol-designer/src/components/DeckSetup/LabwareOverlays/NameThisLabware.tsx @@ -3,13 +3,14 @@ import { connect } from 'react-redux' import cx from 'classnames' import { Icon, useOnClickOutside } from '@opentrons/components' import { renameLabware } from '../../../labware-ingred/actions' -import { ThunkDispatch } from '../../../types' import { i18n } from '../../../localization' -import { LabwareOnDeck } from '../../../step-forms' import styles from './LabwareOverlays.css' +import type { LabwareEntity } from '@opentrons/step-generation' +import type { ThunkDispatch } from '../../../types' +import type { LabwareOnDeck } from '../../../step-forms' interface OP { - labwareOnDeck: LabwareOnDeck + labwareOnDeck: LabwareOnDeck | LabwareEntity editLiquids: () => unknown } diff --git a/protocol-designer/src/components/DeckSetup/LabwareOverlays/SlotControls.tsx b/protocol-designer/src/components/DeckSetup/LabwareOverlays/SlotControls.tsx index 078ade4748c..baf29ea5cc0 100644 --- a/protocol-designer/src/components/DeckSetup/LabwareOverlays/SlotControls.tsx +++ b/protocol-designer/src/components/DeckSetup/LabwareOverlays/SlotControls.tsx @@ -91,6 +91,18 @@ export const SlotControlsComponent = ( slotBlocked = 'Labware incompatible with this module' } + const isOnHeaterShaker = moduleType === 'heaterShakerModuleType' + const isNoAdapterOption = + moduleType === 'magneticBlockType' || + moduleType === 'magneticModuleType' || + moduleType === 'thermocyclerModuleType' + let overlayText: string = 'add_adapter_or_labware' + if (isOnHeaterShaker) { + overlayText = 'add_adapter' + } else if (isNoAdapterOption) { + overlayText = 'add_labware' + } + return connectDropTarget( {slotBlocked ? ( @@ -116,9 +128,7 @@ export const SlotControlsComponent = ( > {!isOver && } - {i18n.t( - `deck.overlay.slot.${isOver ? 'place_here' : 'add_labware'}` - )} + {i18n.t(`deck.overlay.slot.${isOver ? 'place_here' : overlayText}`)} )} diff --git a/protocol-designer/src/components/DeckSetup/index.tsx b/protocol-designer/src/components/DeckSetup/index.tsx index 0fedc9daec9..8938e8a1665 100644 --- a/protocol-designer/src/components/DeckSetup/index.tsx +++ b/protocol-designer/src/components/DeckSetup/index.tsx @@ -12,6 +12,7 @@ import { RobotWorkSpaceRenderProps, Module, COLORS, + TrashSlotName, } from '@opentrons/components' import { MODULES_WITH_COLLISION_ISSUES, @@ -34,7 +35,7 @@ import { FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { getDeckDefinitions } from '@opentrons/components/src/hardware-sim/Deck/getDeckDefinitions' -import { PSEUDO_DECK_SLOTS } from '../../constants' +import { OT_2_TRASH_DEF_URI, PSEUDO_DECK_SLOTS } from '../../constants' import { i18n } from '../../localization' import { getLabwareIsCompatible, @@ -89,6 +90,7 @@ type ContentsProps = RobotWorkSpaceRenderProps & { showGen1MultichannelCollisionWarnings: boolean deckDef: DeckDefinition robotType: RobotType + trashSlot: string | null } export const VIEWBOX_MIN_X = -64 @@ -152,8 +154,8 @@ export const DeckSetupContents = (props: ContentsProps): JSX.Element => { showGen1MultichannelCollisionWarnings, deckDef, robotType, + trashSlot, } = props - const trashSlot = robotType === FLEX_ROBOT_TYPE ? FLEX_TRASH_SLOT : '12' // NOTE: handling module<>labware compat when moving labware to empty module // is handled by SlotControls. @@ -526,6 +528,12 @@ export const DeckSetup = (): JSX.Element => { const _disableCollisionWarnings = useSelector( featureFlagSelectors.getDisableModuleRestrictions ) + const trashSlot = + Object.values(activeDeckSetup.labware).find( + lw => + lw.labwareDefURI === OT_2_TRASH_DEF_URI || + lw.labwareDefURI === FLEX_TRASH_SLOT + )?.slot ?? null const robotType = useSelector(getRobotType) const dispatch = useDispatch() @@ -553,12 +561,14 @@ export const DeckSetup = (): JSX.Element => { viewBox={robotType === OT2_ROBOT_TYPE ? OT2_VIEWBOX : FLEX_VIEWBOX} width="100%" height="100%" - trashSlotName={FLEX_TRASH_SLOT} + // change this once we support no trash + trashSlotName={(trashSlot ?? 'A3') as TrashSlotName} trashColor={COLORS.darkGreyEnabled} > {({ deckSlotsById, getRobotCoordsFromDOMCoords }) => ( <> { const batchEditSelectedStepTypes = useSelector(getBatchEditSelectedStepTypes) const hoveredItem = useSelector(getHoveredItem) + const enableOffDeckVisAndMultiTipFF = useSelector( + getEnableOffDeckVisAndMultiTip + ) if (batchEditSelectedStepTypes.length === 0 || hoveredItem !== null) { // not batch edit mode, or batch edit while item is hovered: show the deck - return + return ( + <> + {enableOffDeckVisAndMultiTipFF ? : null} + + + ) } else { return } diff --git a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx index fcc6f9c5ab6..5a8ddaa331f 100644 --- a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx +++ b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx @@ -43,8 +43,9 @@ export interface Props { export interface AdditionalEquipment { [additionalEquipmentId: string]: { - name: 'gripper' + name: 'gripper' | 'wasteChute' id: string + location?: string } } diff --git a/protocol-designer/src/components/Hints/index.tsx b/protocol-designer/src/components/Hints/index.tsx index 1d4d45e79a0..ff93427d71f 100644 --- a/protocol-designer/src/components/Hints/index.tsx +++ b/protocol-designer/src/components/Hints/index.tsx @@ -3,7 +3,9 @@ import { connect } from 'react-redux' import { AlertModal, DeprecatedCheckboxField, + Flex, OutlineButton, + Text, } from '@opentrons/components' import { i18n } from '../../localization' import { actions as stepsActions } from '../../ui/steps' @@ -152,6 +154,12 @@ class HintsComponent extends React.Component { ) + case 'waste_chute_warning': + return ( + + {i18n.t(`alert.hint.${hintKey}.body1`)} + + ) default: return null } diff --git a/protocol-designer/src/components/LabwareSelectionModal/LabwareSelectionModal.tsx b/protocol-designer/src/components/LabwareSelectionModal/LabwareSelectionModal.tsx index e4f3342fcdd..979a463a8c6 100644 --- a/protocol-designer/src/components/LabwareSelectionModal/LabwareSelectionModal.tsx +++ b/protocol-designer/src/components/LabwareSelectionModal/LabwareSelectionModal.tsx @@ -78,6 +78,7 @@ const RECOMMENDED_LABWARE_BY_MODULE: { [K in ModuleType]: string[] } = { 'opentrons_24_aluminumblock_nest_2ml_screwcap', 'opentrons_24_aluminumblock_nest_2ml_snapcap', 'opentrons_24_aluminumblock_nest_0.5ml_screwcap', + 'opentrons_aluminum_flat_bottom_plate', ], [MAGNETIC_MODULE_TYPE]: [ 'nest_96_wellplate_100ul_pcr_full_skirt', @@ -238,10 +239,13 @@ export const LabwareSelectionModal = (props: Props): JSX.Element | null => { defs, (acc, def: typeof defs[keyof typeof defs]) => { const category: string = def.metadata.displayCategory - // filter out non-permitted tipracks + // filter out non-permitted tipracks + + // temporarily filtering out 96-channel adapter until we support + // 96-channel if ( - category === 'tipRack' && - !permittedTipracks.includes(getLabwareDefURI(def)) + (category === 'tipRack' && + !permittedTipracks.includes(getLabwareDefURI(def))) || + def.parameters.loadName === 'opentrons_flex_96_tiprack_adapter' ) { return acc } @@ -348,7 +352,11 @@ export const LabwareSelectionModal = (props: Props): JSX.Element | null => { /> {blockingCustomLabwareHint} -
    +
    {getTitleText()}
    {getFilterCheckbox()}
      diff --git a/protocol-designer/src/components/LiquidPlacementModal.css b/protocol-designer/src/components/LiquidPlacementModal.css index 1eaf4b4303d..c267b35460d 100644 --- a/protocol-designer/src/components/LiquidPlacementModal.css +++ b/protocol-designer/src/components/LiquidPlacementModal.css @@ -9,7 +9,7 @@ @apply (--absolute-fill); background-color: rgba(0, 0, 0, 0.9); - z-index: 1; + z-index: 4; /* make up lost space for overlay */ height: 103%; diff --git a/protocol-designer/src/components/OffDeckLabwareButton.tsx b/protocol-designer/src/components/OffDeckLabwareButton.tsx new file mode 100644 index 00000000000..c711b40713b --- /dev/null +++ b/protocol-designer/src/components/OffDeckLabwareButton.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import { useSelector } from 'react-redux' +import { + DeprecatedPrimaryButton, + Flex, + POSITION_ABSOLUTE, + POSITION_RELATIVE, + SPACING, +} from '@opentrons/components' +import { getSelectedTerminalItemId } from '../ui/steps' +import { i18n } from '../localization' +import { OffDeckLabwareSlideout } from './OffDeckLabwareSlideout' + +export const OffDeckLabwareButton = (): JSX.Element => { + const selectedTerminalItemId = useSelector(getSelectedTerminalItemId) + + const [showSlideout, setShowSlideout] = React.useState(false) + + return ( + + + setShowSlideout(true)}> + {i18n.t('button.edit_off_deck')} + + + {showSlideout ? ( + setShowSlideout(false)} + initialSetupTerminalItemId={ + selectedTerminalItemId === '__initial_setup__' + } + /> + ) : null} + + ) +} diff --git a/protocol-designer/src/components/OffDeckLabwareSlideout.tsx b/protocol-designer/src/components/OffDeckLabwareSlideout.tsx new file mode 100644 index 00000000000..48983d4e417 --- /dev/null +++ b/protocol-designer/src/components/OffDeckLabwareSlideout.tsx @@ -0,0 +1,186 @@ +import * as React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { css } from 'styled-components' +import { + Tooltip, + DeprecatedPrimaryButton, + useHoverTooltip, + Flex, + COLORS, + Icon, + SPACING, + Text, + DIRECTION_COLUMN, + BORDERS, + LabwareRender, + RobotWorkSpace, + LabwareNameOverlay, + RobotCoordsForeignDiv, + ALIGN_CENTER, + JUSTIFY_CENTER, + TYPOGRAPHY, + truncateString, + POSITION_ABSOLUTE, +} from '@opentrons/components' +import { getLabwareDisplayName } from '@opentrons/shared-data' +import { i18n } from '../localization' +import { openAddLabwareModal } from '../labware-ingred/actions' +import { getLabwareEntities } from '../step-forms/selectors' +import { selectors } from '../labware-ingred/selectors' +import { getAllWellContentsForActiveItem } from '../top-selectors/well-contents' +import { getRobotStateAtActiveItem } from '../top-selectors/labware-locations' +import { getLabwareNicknamesById } from '../ui/labware/selectors' +import { EditLabwareOffDeck } from './DeckSetup/LabwareOverlays/EditLabwareOffDeck' +import { BrowseLabware } from './DeckSetup/LabwareOverlays/BrowseLabware' +import { Slideout } from '../atoms/Slideout' +import { wellFillFromWellContents } from './labware' + +interface OffDeckLabwareSlideoutProps { + initialSetupTerminalItemId: boolean + isExpanded: boolean + onCloseClick: () => void +} + +export const OffDeckLabwareSlideout = ( + props: OffDeckLabwareSlideoutProps +): JSX.Element => { + const [targetProps, tooltipProps] = useHoverTooltip() + const dispatch = useDispatch() + const disabled = props.initialSetupTerminalItemId === false + const robotState = useSelector(getRobotStateAtActiveItem) + const labwareEntities = useSelector(getLabwareEntities) + const allWellContentsForActiveItem = useSelector( + getAllWellContentsForActiveItem + ) + const liquidDisplayColors = useSelector(selectors.getLiquidDisplayColors) + const labwareNickNames = useSelector(getLabwareNicknamesById) + + const offDeckEntries = + robotState?.labware != null + ? Object.entries(robotState?.labware).filter( + ([key, value]) => value.slot === 'offDeck' + ) + : null + const offDeck = + offDeckEntries != null && offDeckEntries.length > 0 + ? Object.fromEntries(offDeckEntries) + : null + + return ( + + dispatch(openAddLabwareModal({ slot: 'offDeck' }))} + marginTop={SPACING.spacing16} + marginRight={SPACING.spacing16} + disabled={disabled} + > + {i18n.t('button.add_off_deck')} + + {disabled ? ( + + {i18n.t(`tooltip.disabled_off_deck`)} + + ) : null} +
    + } + > + {offDeck == null ? ( + + + {i18n.t('deck.off_deck.slideout_empty_state')} + + ) : ( + Object.keys(offDeck).map(labwareId => { + const labwareNickName = labwareNickNames[labwareId] + const truncatedNickName = + labwareNickName != null + ? truncateString(labwareNickName, 75, 25) + : null + const wellContents = + allWellContentsForActiveItem != null + ? allWellContentsForActiveItem[labwareId] + : null + const definition = + labwareEntities[labwareId] != null + ? labwareEntities[labwareId].def + : null + return definition != null ? ( + + {() => ( + <> + + + + {disabled ? ( +
    + +
    + ) : ( + + )} +
    + + )} +
    + ) : null + }) + )} + + ) +} diff --git a/protocol-designer/src/components/StepEditForm/StepEditForm.css b/protocol-designer/src/components/StepEditForm/StepEditForm.css index 12222fb110d..f3887070fe6 100644 --- a/protocol-designer/src/components/StepEditForm/StepEditForm.css +++ b/protocol-designer/src/components/StepEditForm/StepEditForm.css @@ -235,7 +235,7 @@ margin-top: 0.5rem; font-size: var(--fs-body-1); overflow: hidden; - white-space: nowrap; + white-space: pre-wrap; text-overflow: ellipsis; } diff --git a/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx b/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx index a37615eee50..48537b029a3 100644 --- a/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/BlowoutLocationField.tsx @@ -36,7 +36,7 @@ export const BlowoutLocationField = ( id={'BlowoutLocationField_dropdown'} onBlur={onFieldBlur} onFocus={onFieldFocus} - value={value ? String(value) : null} + value={value != null ? String(value) : null} onChange={(e: React.ChangeEvent) => { updateValue(e.currentTarget.value) }} diff --git a/protocol-designer/src/components/StepEditForm/fields/LabwareLocationField/index.tsx b/protocol-designer/src/components/StepEditForm/fields/LabwareLocationField/index.tsx index d59abcbe00b..1879c63a317 100644 --- a/protocol-designer/src/components/StepEditForm/fields/LabwareLocationField/index.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/LabwareLocationField/index.tsx @@ -1,7 +1,17 @@ import * as React from 'react' import { useSelector } from 'react-redux' +import { getModuleDisplayName, WASTE_CHUTE_SLOT } from '@opentrons/shared-data' import { i18n } from '../../../../localization' -import { getUnocuppiedLabwareLocationOptions } from '../../../../top-selectors/labware-locations' +import { + getAdditionalEquipmentEntities, + getLabwareEntities, + getModuleEntities, +} from '../../../../step-forms/selectors' +import { + getRobotStateAtActiveItem, + getUnocuppiedLabwareLocationOptions, +} from '../../../../top-selectors/labware-locations' +import { getHasWasteChute } from '../../../labware' import { StepFormDropdown } from '../StepFormDropdownField' export function LabwareLocationField( @@ -9,23 +19,65 @@ export function LabwareLocationField( useGripper: boolean } & { canSave: boolean } & { labware: string } ): JSX.Element { + const { labware, useGripper, value } = props + const labwareEntities = useSelector(getLabwareEntities) + const robotState = useSelector(getRobotStateAtActiveItem) + const moduleEntities = useSelector(getModuleEntities) + const additionalEquipmentEntities = useSelector( + getAdditionalEquipmentEntities + ) + const hasWasteChute = getHasWasteChute(additionalEquipmentEntities) + const isLabwareOffDeck = + labware != null ? robotState?.labware[labware]?.slot === 'offDeck' : false + const displayWasteChuteValue = + useGripper && hasWasteChute && !isLabwareOffDeck + let unoccupiedLabwareLocationsOptions = useSelector(getUnocuppiedLabwareLocationOptions) ?? [] - if (props.useGripper) { + if (isLabwareOffDeck && hasWasteChute) { + unoccupiedLabwareLocationsOptions = unoccupiedLabwareLocationsOptions.filter( + option => option.value !== 'offDeck' && option.value !== WASTE_CHUTE_SLOT + ) + } else if (useGripper || isLabwareOffDeck) { unoccupiedLabwareLocationsOptions = unoccupiedLabwareLocationsOptions.filter( option => option.value !== 'offDeck' ) + } else if (!displayWasteChuteValue) { + unoccupiedLabwareLocationsOptions = unoccupiedLabwareLocationsOptions.filter( + option => option.name !== 'Waste Chute in D3' + ) } - const bothFieldsSelected = props.labware != null && props.value != null + const location: string = value as string + const bothFieldsSelected = labware != null && value != null + const labwareDisplayName = + labware != null ? labwareEntities[labware]?.def.metadata.displayName : '' + let locationString = `slot ${location}` + if (location != null) { + if (robotState?.modules[location] != null) { + const moduleSlot = robotState?.modules[location].slot ?? '' + locationString = `${getModuleDisplayName( + moduleEntities[location].model + )} in slot ${moduleSlot}` + } else if (robotState?.labware[location] != null) { + const adapterSlot = robotState?.labware[location].slot + locationString = + robotState?.modules[adapterSlot] != null + ? `${getModuleDisplayName( + moduleEntities[adapterSlot].model + )} in slot ${robotState?.modules[adapterSlot].slot}` + : `slot ${robotState?.labware[location].slot}` ?? '' + } + } return ( = EditModules const editGetAdditionalEquipment: jest.MockedFunction = getAdditionalEquipment diff --git a/protocol-designer/src/components/__tests__/StepCreationButton.test.tsx b/protocol-designer/src/components/__tests__/StepCreationButton.test.tsx index 4dbad0244e2..2efa2e55973 100644 --- a/protocol-designer/src/components/__tests__/StepCreationButton.test.tsx +++ b/protocol-designer/src/components/__tests__/StepCreationButton.test.tsx @@ -52,6 +52,7 @@ describe('StepCreationButton', () => { labware: {}, pipettes: {}, modules: {}, + additionalEquipmentOnDeck: {}, }) when(getIsMultiSelectModeMock) @@ -133,6 +134,7 @@ describe('StepCreationButton', () => { .mockReturnValue({ labware: {}, pipettes: {}, + additionalEquipmentOnDeck: {}, modules: { abcdef: { id: 'abcdef', diff --git a/protocol-designer/src/components/labware/__tests__/utils.test.ts b/protocol-designer/src/components/labware/__tests__/utils.test.ts new file mode 100644 index 00000000000..bda5fc71096 --- /dev/null +++ b/protocol-designer/src/components/labware/__tests__/utils.test.ts @@ -0,0 +1,34 @@ +import { WASTE_CHUTE_SLOT } from '@opentrons/shared-data' +import { getHasWasteChute } from '..' +import type { AdditionalEquipmentEntities } from '@opentrons/step-generation' + +describe('getHasWasteChute', () => { + it('returns false when there is no waste chute', () => { + const mockId = 'mockId' + const mockAdditionalEquipmentEntities = { + mockId: { + id: mockId, + name: 'gripper', + }, + } as AdditionalEquipmentEntities + const result = getHasWasteChute(mockAdditionalEquipmentEntities) + expect(result).toEqual(false) + }) + it('returns true when there is a waste chute', () => { + const mockId = 'mockId' + const mockId2 = 'mockId2' + const mockAdditionalEquipmentEntities = { + mockId: { + id: mockId, + name: 'gripper', + }, + mockId2: { + id: mockId2, + name: 'wasteChute', + location: WASTE_CHUTE_SLOT, + }, + } as AdditionalEquipmentEntities + const result = getHasWasteChute(mockAdditionalEquipmentEntities) + expect(result).toEqual(true) + }) +}) diff --git a/protocol-designer/src/components/labware/utils.ts b/protocol-designer/src/components/labware/utils.ts index 61532873ef0..f3f27add66d 100644 --- a/protocol-designer/src/components/labware/utils.ts +++ b/protocol-designer/src/components/labware/utils.ts @@ -1,6 +1,7 @@ import reduce from 'lodash/reduce' -import { AIR } from '@opentrons/step-generation' +import { AdditionalEquipmentEntities, AIR } from '@opentrons/step-generation' import { WellFill } from '@opentrons/components' +import { WASTE_CHUTE_SLOT } from '@opentrons/shared-data' import { swatchColors, MIXED_WELL_COLOR } from '../swatchColors' import { ContentsByWell, WellContents } from '../../labware-ingred/types' @@ -33,3 +34,13 @@ export const wellFillFromWellContents = ( }, {} ) + +export const getHasWasteChute = ( + additionalEquipmentEntities: AdditionalEquipmentEntities +): boolean => { + return Object.values(additionalEquipmentEntities).some( + additionalEquipmentEntity => + additionalEquipmentEntity.location === WASTE_CHUTE_SLOT && + additionalEquipmentEntity.name === 'wasteChute' + ) +} diff --git a/protocol-designer/src/components/listButtons.css b/protocol-designer/src/components/listButtons.css index ee55a5cd367..095d3299938 100644 --- a/protocol-designer/src/components/listButtons.css +++ b/protocol-designer/src/components/listButtons.css @@ -1,7 +1,7 @@ @import '@opentrons/components'; .list_item_button { - z-index: 10; + z-index: 1; position: relative; padding: 1rem 2rem 1.25rem 2rem; } diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index 30e0061899f..fa257f7a3f1 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -26,12 +26,13 @@ import { ModuleModel, getModuleDisplayName, getModuleType, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { FormModulesByType, getIsCrashablePipetteSelected, } from '../../../step-forms' -import gripperImage from '../../../images/flex_gripper.svg' +import gripperImage from '../../../images/flex_gripper.png' import { i18n } from '../../../localization' import { selectors as featureFlagSelectors } from '../../../feature-flags' import { @@ -44,7 +45,7 @@ import { GoBack } from './GoBack' import { EquipmentOption } from './EquipmentOption' import { HandleEnter } from './HandleEnter' -import type { WizardTileProps } from './types' +import type { AdditionalEquipment, WizardTileProps } from './types' const getCrashableModuleSelected = ( modules: FormModulesByType, @@ -58,6 +59,7 @@ const getCrashableModuleSelected = ( return crashableModuleOnDeck } + export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { const { handleChange, @@ -113,6 +115,7 @@ export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { /> ) const robotType = values.fields.robotType + return ( @@ -187,33 +190,28 @@ const DEFAULT_SLOT_MAP: { [moduleModel in ModuleModel]?: string } = { } function FlexModuleFields(props: WizardTileProps): JSX.Element { - const { values } = props + const { values, setFieldValue } = props + const enableDeckModification = useSelector( + featureFlagSelectors.getEnableDeckModification + ) + const isFlex = values.fields.robotType === FLEX_ROBOT_TYPE + + const handleSetEquipmentOption = (equipment: AdditionalEquipment): void => { + if (values.additionalEquipment.includes(equipment)) { + setFieldValue( + 'additionalEquipment', + without(values.additionalEquipment, equipment) + ) + } else { + setFieldValue('additionalEquipment', [ + ...values.additionalEquipment, + equipment, + ]) + } + } + return ( - { - if (values.additionalEquipment.includes('gripper')) { - props.setFieldValue( - 'additionalEquipment', - without(values.additionalEquipment, 'gripper') - ) - } else { - props.setFieldValue('additionalEquipment', [ - ...values.additionalEquipment, - 'gripper', - ]) - } - }} - isSelected={values.additionalEquipment.includes('gripper')} - image={ - - } - text="Gripper" - showCheckbox - /> {FLEX_SUPPORTED_MODULE_MODELS.map(moduleModel => { const moduleType = getModuleType(moduleModel) return ( @@ -224,16 +222,13 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { text={getModuleDisplayName(moduleModel)} onClick={() => { if (values.modulesByType[moduleType].onDeck) { - props.setFieldValue(`modulesByType.${moduleType}.onDeck`, false) - props.setFieldValue(`modulesByType.${moduleType}.model`, null) - props.setFieldValue(`modulesByType.${moduleType}.slot`, null) + setFieldValue(`modulesByType.${moduleType}.onDeck`, false) + setFieldValue(`modulesByType.${moduleType}.model`, null) + setFieldValue(`modulesByType.${moduleType}.slot`, null) } else { - props.setFieldValue(`modulesByType.${moduleType}.onDeck`, true) - props.setFieldValue( - `modulesByType.${moduleType}.model`, - moduleModel - ) - props.setFieldValue( + setFieldValue(`modulesByType.${moduleType}.onDeck`, true) + setFieldValue(`modulesByType.${moduleType}.model`, moduleModel) + setFieldValue( `modulesByType.${moduleType}.slot`, DEFAULT_SLOT_MAP[moduleModel] ) @@ -243,6 +238,48 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { /> ) })} + handleSetEquipmentOption('gripper')} + isSelected={values.additionalEquipment.includes('gripper')} + image={ + + } + text="Gripper" + showCheckbox + /> + {enableDeckModification && isFlex ? ( + <> + handleSetEquipmentOption('wasteChute')} + isSelected={values.additionalEquipment.includes('wasteChute')} + // todo(jr, 9/14/23): update the asset + image={ + + } + text="Waste Chute" + showCheckbox + /> + handleSetEquipmentOption('trashBin')} + isSelected={values.additionalEquipment.includes('trashBin')} + // todo(jr, 9/14/23): update the asset with trash bin + image={ + + } + text="Trash Bin" + showCheckbox + /> + + ) : null} ) } diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/CreateFileWizard.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/CreateFileWizard.test.tsx index e2bb1ed0cd0..702d5ddd6e3 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/CreateFileWizard.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/CreateFileWizard.test.tsx @@ -10,11 +10,17 @@ import { } from '../../../../labware-defs/selectors' import { toggleNewProtocolModal } from '../../../../navigation/actions' import { createNewProtocol } from '../../../../load-file/actions' +import { createContainer } from '../../../../labware-ingred/actions' import { createCustomLabwareDefAction } from '../../../../labware-defs/actions' import { createModule, createPipettes } from '../../../../step-forms/actions' -import { changeSavedStepForm } from '../../../../steplist/actions' -import { toggleIsGripperRequired } from '../../../../step-forms/actions/additionalItems' -import { getAllowAllTipracks } from '../../../../feature-flags/selectors' +import { + toggleIsGripperRequired, + createDeckFixture, +} from '../../../../step-forms/actions/additionalItems' +import { + getAllowAllTipracks, + getEnableDeckModification, +} from '../../../../feature-flags/selectors' import { getTiprackOptions } from '../../utils' import { CreateFileWizard } from '..' @@ -28,6 +34,7 @@ jest.mock('../../../../step-forms/actions') jest.mock('../../../../steplist/actions') jest.mock('../../../../step-forms/actions/additionalItems') jest.mock('../../../../feature-flags/selectors') +jest.mock('../../../../labware-ingred/actions') jest.mock('../../utils') const mockGetNewProtocolModal = getNewProtocolModal as jest.MockedFunction< @@ -48,8 +55,8 @@ const mockCreateCustomLabwareDefAction = createCustomLabwareDefAction as jest.Mo const mockCreatePipettes = createPipettes as jest.MockedFunction< typeof createPipettes > -const mockChangeSavedStepForm = changeSavedStepForm as jest.MockedFunction< - typeof changeSavedStepForm +const mockCreateContainer = createContainer as jest.MockedFunction< + typeof createContainer > const mockToggleIsGripperRequired = toggleIsGripperRequired as jest.MockedFunction< typeof toggleIsGripperRequired @@ -66,6 +73,12 @@ const mockGetTiprackOptions = getTiprackOptions as jest.MockedFunction< const mockCreateModule = createModule as jest.MockedFunction< typeof createModule > +const mockCreateDeckFixture = createDeckFixture as jest.MockedFunction< + typeof createDeckFixture +> +const mockGetEnableDeckModification = getEnableDeckModification as jest.MockedFunction< + typeof getEnableDeckModification +> const render = () => { return renderWithProviders()[0] } @@ -78,6 +91,7 @@ const ten = '10uL' describe('CreateFileWizard', () => { beforeEach(() => { + mockGetEnableDeckModification.mockReturnValue(false) mockGetNewProtocolModal.mockReturnValue(true) mockGetAllowAllTipracks.mockReturnValue(false) mockGetLabwareDefsByURI.mockReturnValue({ @@ -128,19 +142,7 @@ describe('CreateFileWizard', () => { expect(mockCreateNewProtocol).toHaveBeenCalled() expect(mockCreatePipettes).toHaveBeenCalled() expect(mockCreateModule).not.toHaveBeenCalled() - expect(mockChangeSavedStepForm).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - stepId: '__INITIAL_DECK_SETUP_STEP__', - update: { - labwareLocationUpdate: { - fixedTrash: { - slotName: '12', - }, - }, - }, - }) - ) + expect(mockCreateContainer).toHaveBeenCalled() }) it('renders the wizard and clicking on the exit button calls correct selector', () => { const { getByText, getByRole } = render() @@ -153,6 +155,7 @@ describe('CreateFileWizard', () => { expect(mockToggleNewProtocolModal).toHaveBeenCalled() }) it('renders the wizard for a Flex with custom tiprack', () => { + mockGetEnableDeckModification.mockReturnValue(true) const Custom = 'custom' mockGetCustomLabwareDefsByURI.mockReturnValue({ [Custom]: fixtureTipRack10ul, @@ -205,25 +208,14 @@ describe('CreateFileWizard', () => { next = getByRole('button', { name: 'Next' }) next.click() getByText('Step 6 / 6') - // select gripper + // select gripper and waste chute getByLabelText('EquipmentOption_flex_Gripper').click() + getByLabelText('EquipmentOption_flex_Waste Chute').click() getByRole('button', { name: 'Review file details' }).click() expect(mockCreateNewProtocol).toHaveBeenCalled() expect(mockCreatePipettes).toHaveBeenCalled() expect(mockCreateCustomLabwareDefAction).toHaveBeenCalled() expect(mockToggleIsGripperRequired).toHaveBeenCalled() - expect(mockChangeSavedStepForm).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - stepId: '__INITIAL_DECK_SETUP_STEP__', - update: { - labwareLocationUpdate: { - fixedTrash: { - slotName: 'A3', - }, - }, - }, - }) - ) + expect(mockCreateDeckFixture).toHaveBeenCalled() }) }) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index e9f66c7e865..a4f6e22b9e1 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -21,11 +21,11 @@ import { MAGNETIC_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, SPAN7_8_10_11_SLOT, - FIXED_TRASH_ID, FLEX_ROBOT_TYPE, MAGNETIC_MODULE_V2, THERMOCYCLER_MODULE_V2, TEMPERATURE_MODULE_V2, + WASTE_CHUTE_SLOT, } from '@opentrons/shared-data' import { actions as stepFormActions, @@ -33,7 +33,10 @@ import { FormPipette, PipetteOnDeck, } from '../../../step-forms' -import { INITIAL_DECK_SETUP_STEP_ID } from '../../../constants' +import { + FLEX_TRASH_DEF_URI, + INITIAL_DECK_SETUP_STEP_ID, +} from '../../../constants' import { uuid } from '../../../utils' import { actions as navigationActions } from '../../../navigation' import { getNewProtocolModal } from '../../../navigation/selectors' @@ -45,7 +48,11 @@ import * as labwareDefSelectors from '../../../labware-defs/selectors' import * as labwareDefActions from '../../../labware-defs/actions' import * as labwareIngredActions from '../../../labware-ingred/actions' import { actions as steplistActions } from '../../../steplist' -import { toggleIsGripperRequired } from '../../../step-forms/actions/additionalItems' +import { getEnableDeckModification } from '../../../feature-flags/selectors' +import { + createDeckFixture, + toggleIsGripperRequired, +} from '../../../step-forms/actions/additionalItems' import { RobotTypeTile } from './RobotTypeTile' import { MetadataTile } from './MetadataTile' import { FirstPipetteTypeTile, SecondPipetteTypeTile } from './PipetteTypeTile' @@ -81,6 +88,7 @@ export function CreateFileWizard(): JSX.Element | null { const customLabware = useSelector( labwareDefSelectors.getCustomLabwareDefsByURI ) + const enableDeckModification = useSelector(getEnableDeckModification) const [currentStepIndex, setCurrentStepIndex] = React.useState(0) @@ -184,20 +192,25 @@ export function CreateFileWizard(): JSX.Element | null { }, }) ) - // default trash labware locations in initial deck setup step - dispatch( - steplistActions.changeSavedStepForm({ - stepId: INITIAL_DECK_SETUP_STEP_ID, - update: { - labwareLocationUpdate: { - [FIXED_TRASH_ID]: { - slotName: - values.fields.robotType === FLEX_ROBOT_TYPE ? 'A3' : '12', - }, - }, - }, - }) - ) + if ( + enableDeckModification && + values.additionalEquipment.includes('trashBin') + ) { + // defaulting trash to appropriate locations + dispatch( + labwareIngredActions.createContainer({ + labwareDefURI: FLEX_TRASH_DEF_URI, + slot: 'A3', + }) + ) + } else { + dispatch( + labwareIngredActions.createContainer({ + labwareDefURI: FLEX_TRASH_DEF_URI, + slot: values.fields.robotType === FLEX_ROBOT_TYPE ? 'A3' : '12', + }) + ) + } // create modules modules.forEach(moduleArgs => dispatch(stepFormActions.createModule(moduleArgs)) @@ -217,6 +230,14 @@ export function CreateFileWizard(): JSX.Element | null { }) ) }) + + // add waste chute + if ( + enableDeckModification && + values.additionalEquipment.includes('wasteChute') + ) { + dispatch(createDeckFixture('wasteChute', WASTE_CHUTE_SLOT)) + } } } const wizardHeader = ( diff --git a/protocol-designer/src/components/modals/CreateFileWizard/types.ts b/protocol-designer/src/components/modals/CreateFileWizard/types.ts index 0273da1126c..dc5cf84d078 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/types.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/types.ts @@ -6,7 +6,7 @@ import type { import type { NewProtocolFields } from '../../../load-file' -export type AdditionalEquipment = 'gripper' +export type AdditionalEquipment = 'gripper' | 'wasteChute' | 'trashBin' export interface FormState { fields: NewProtocolFields diff --git a/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx index 398a0a751eb..056a1c770cd 100644 --- a/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx +++ b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx @@ -162,7 +162,7 @@ describe('Edit Modules Modal', () => { getLabwareIsCompatibleMock.mockReturnValue(false) const wrapper = render(props) expect(wrapper.find(SlotDropdown).childAt(0).prop('error')).toMatch( - 'labware incompatible' + 'Slot 1 is occupied. Clear the slot to continue.' ) }) @@ -171,7 +171,7 @@ describe('Edit Modules Modal', () => { getSlotIdsBlockedBySpanningMock.mockReturnValue(['1']) // 1 is default slot const wrapper = render(props) expect(wrapper.find(SlotDropdown).childAt(0).prop('error')).toMatch( - 'labware incompatible' + 'Slot 1 is occupied. Clear the slot to continue.' ) }) diff --git a/protocol-designer/src/components/modals/EditModulesModal/index.tsx b/protocol-designer/src/components/modals/EditModulesModal/index.tsx index 873373835f4..9939588b5c2 100644 --- a/protocol-designer/src/components/modals/EditModulesModal/index.tsx +++ b/protocol-designer/src/components/modals/EditModulesModal/index.tsx @@ -23,6 +23,7 @@ import { TEMPERATURE_MODULE_V1, RobotType, FLEX_ROBOT_TYPE, + THERMOCYCLER_MODULE_V2, } from '@opentrons/shared-data' import { i18n } from '../../../localization' import { @@ -155,7 +156,13 @@ export const EditModulesModal = (props: EditModulesModalProps): JSX.Element => { { selectedSlot } ) } - } else if (hasSlotIssue(selectedSlot)) { + } else if ( + // TODO(jr, 8/31/23): this is a bit hacky since the TCGEN2 slot is only B1 instead of B1 and A1 + // so we have to manually check if slot A1 has issues as well as looking at selectedSlot + // this probably deserves a more elegant refactor + (selectedModel === THERMOCYCLER_MODULE_V2 && hasSlotIssue('A1')) || + hasSlotIssue(selectedSlot) + ) { errors.selectedSlot = i18n.t( 'alert.module_placement.SLOT_OCCUPIED.body', { selectedSlot } diff --git a/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx b/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx index 013f38d3a17..cf976612094 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx @@ -58,7 +58,6 @@ export interface ModuleFieldsProps { } values: FormModulesByType onFieldChange: (event: React.ChangeEvent) => unknown - onSetFieldValue: (field: string, value: string | null) => void onSetFieldTouched: (field: string, touched: boolean) => void onBlur: (event: React.FocusEvent) => unknown } @@ -66,7 +65,6 @@ export interface ModuleFieldsProps { export function ModuleFields(props: ModuleFieldsProps): JSX.Element { const { onFieldChange, - onSetFieldValue, onSetFieldTouched, onBlur, values, @@ -81,15 +79,7 @@ export function ModuleFields(props: ModuleFieldsProps): JSX.Element { ) const handleOnDeckChange = (type: ModuleType) => (e: React.ChangeEvent) => { const targetToClear = `modulesByType.${type}.model` - onFieldChange(e) - - if ( - targetToClear !== 'modulesByType.thermocyclerModuleType.model' && - targetToClear !== 'modulesByType.heaterShakerModuleType.model' - ) { - onSetFieldValue(targetToClear, null) - } onSetFieldTouched(targetToClear, false) } diff --git a/protocol-designer/src/components/modals/FilePipettesModal/PipetteDiagram.tsx b/protocol-designer/src/components/modals/FilePipettesModal/PipetteDiagram.tsx index ac9dff170bd..c2456e743d0 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/PipetteDiagram.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/PipetteDiagram.tsx @@ -46,8 +46,7 @@ function PipetteGroup(props: Props): JSX.Element { imageStyle={ robotType === FLEX_ROBOT_TYPE ? css` - left: 36rem; - position: fixed; + margin-right: 1rem; ` : undefined } @@ -63,8 +62,8 @@ function PipetteGroup(props: Props): JSX.Element { imageStyle={ robotType === FLEX_ROBOT_TYPE ? css` - right: -2rem; - position: fixed; + right: -1.5rem; + position: absolute; ` : undefined } diff --git a/protocol-designer/src/components/modals/FilePipettesModal/PipetteFields.tsx b/protocol-designer/src/components/modals/FilePipettesModal/PipetteFields.tsx index cebc0d6be3a..f3757a4d80e 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/PipetteFields.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/PipetteFields.tsx @@ -96,7 +96,7 @@ export function PipetteFields(props: Props): JSX.Element { const { tabIndex, mount } = props const pipetteName = values[mount].pipetteName return ( - + -
    -
    +
    +
    -
    +
    { [MAGNETIC_BLOCK_TYPE]: magneticBlockNotOnDeck, }, onFieldChange: jest.fn(), - onSetFieldValue: jest.fn(), onSetFieldTouched: jest.fn(), onBlur: jest.fn(), touched: null, @@ -105,7 +104,6 @@ describe('ModuleFields', () => { temperatureSelectChange(expectedEvent) expect(props.onFieldChange).toHaveBeenCalledWith(expectedEvent) - expect(props.onSetFieldValue).toHaveBeenCalledWith(targetToClear, null) expect(props.onSetFieldTouched).toHaveBeenCalledWith(targetToClear, false) }) diff --git a/protocol-designer/src/components/modals/FilePipettesModal/index.tsx b/protocol-designer/src/components/modals/FilePipettesModal/index.tsx index af068a34c02..d6042cea4cc 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/index.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/index.tsx @@ -434,7 +434,6 @@ export class FilePipettesModal extends React.Component { errors={errors.modulesByType ?? null} values={values.modulesByType} onFieldChange={handleChange} - onSetFieldValue={setFieldValue} onBlur={handleBlur} // @ts-expect-error(sa, 2021-7-2): we need to explicitly check that the module model inside of modulesByType exists, because it could be undefined touched={touched.modulesByType ?? null} diff --git a/protocol-designer/src/components/modals/FileUploadMessageModal/__tests__/modalContents.test.ts b/protocol-designer/src/components/modals/FileUploadMessageModal/__tests__/modalContents.test.ts index d536d971ee1..8aba18e509b 100644 --- a/protocol-designer/src/components/modals/FileUploadMessageModal/__tests__/modalContents.test.ts +++ b/protocol-designer/src/components/modals/FileUploadMessageModal/__tests__/modalContents.test.ts @@ -17,7 +17,7 @@ describe('modalContents', () => { ) }) }) - it('should return the "no behavior change message" when migrating from v5.x to 5.2', () => { + it('should return the "no behavior change message" when migrating from v5.x to 6', () => { const migrationsList = [ ['5.0.0'], ['5.0.0', '5.1.0'], @@ -31,12 +31,13 @@ describe('modalContents', () => { ) }) }) - it('should return the generic migration modal when a v4 migration is required', () => { + it('should return the generic migration modal when a v4 migration or v7 migration is required', () => { const migrationsList = [ ['4.0.0'], ['4.0.0', '5.0.0'], ['4.0.0', '5.0.0', '5.1.0'], ['4.0.0', '5.0.0', '5.1.0, 5.2.0'], + ['6.0.0', '6.1.0', '6.2.0', '6.2.1', '6.2.2'], ] migrationsList.forEach(migrations => { expect(JSON.stringify(getMigrationMessage(migrations))).toEqual( diff --git a/protocol-designer/src/components/modals/FileUploadMessageModal/modalContents.tsx b/protocol-designer/src/components/modals/FileUploadMessageModal/modalContents.tsx index 520cbb498b4..7cf8a0440dd 100644 --- a/protocol-designer/src/components/modals/FileUploadMessageModal/modalContents.tsx +++ b/protocol-designer/src/components/modals/FileUploadMessageModal/modalContents.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import assert from 'assert' -import semver from 'semver' import styles from './modalContents.css' import { ModalContents } from './types' import { FileUploadMessage } from '../../../load-file' @@ -52,8 +51,7 @@ export const genericDidMigrateMessage: ModalContents = {

    Updating the file may make changes to liquid handling actions. Please - review your file in the Protocol Designer as well as with a water run on - the robot. + review your file in the Protocol Designer.

    As always, please contact us with any questions or feedback.

    @@ -126,7 +124,16 @@ export function getMigrationMessage(migrationsRan: string[]): ModalContents { if (migrationsRan.includes('3.0.0')) { return toV3MigrationMessage } - if (migrationsRan.every(migration => semver.gt(migration, '4.0.0'))) { + const noBehaviorMigrations = [ + ['5.0.0'], + ['5.0.0', '5.1.0'], + ['5.0.0', '5.1.0', '5.2.0'], + ] + if ( + noBehaviorMigrations.some(migrationList => + migrationsRan.every(migration => migrationList.includes(migration)) + ) + ) { return noBehaviorChangeMessage } return genericDidMigrateMessage diff --git a/protocol-designer/src/components/modules/AdditionalItemsRow.tsx b/protocol-designer/src/components/modules/AdditionalItemsRow.tsx new file mode 100644 index 00000000000..91f78aef056 --- /dev/null +++ b/protocol-designer/src/components/modules/AdditionalItemsRow.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import styled from 'styled-components' +import { FLEX_ROBOT_TYPE, WASTE_CHUTE_SLOT } from '@opentrons/shared-data' +import { + OutlineButton, + Flex, + JUSTIFY_SPACE_BETWEEN, + ALIGN_CENTER, + DIRECTION_COLUMN, + LabeledValue, + SPACING, + SlotMap, +} from '@opentrons/components' +import { i18n } from '../../localization' +import gripperImage from '../../images/flex_gripper.png' +import styles from './styles.css' + +interface AdditionalItemsRowProps { + handleAttachment: () => void + isEquipmentAdded: boolean + name: 'gripper' | 'wasteChute' +} + +export function AdditionalItemsRow( + props: AdditionalItemsRowProps +): JSX.Element { + const { handleAttachment, isEquipmentAdded, name } = props + + return ( + + +

    + {name === 'gripper' + ? i18n.t('modules.additional_equipment_display_names.gripper') + : i18n.t('modules.additional_equipment_display_names.wasteChute')} +

    + +
    +
    + {isEquipmentAdded && name === 'gripper' ? ( + + ) : null} +
    + + {isEquipmentAdded && name === 'wasteChute' ? ( + <> +
    + +
    +
    + +
    + + ) : null} + +
    + + {isEquipmentAdded ? i18n.t('shared.remove') : i18n.t('shared.add')} + +
    +
    + ) +} + +const AdditionalItemImage = styled.img` + width: 6rem; + max-height: 4rem; + display: block; +` diff --git a/protocol-designer/src/components/modules/EditModulesCard.tsx b/protocol-designer/src/components/modules/EditModulesCard.tsx index d30b66b8624..2041e086f9d 100644 --- a/protocol-designer/src/components/modules/EditModulesCard.tsx +++ b/protocol-designer/src/components/modules/EditModulesCard.tsx @@ -9,6 +9,7 @@ import { PipetteName, getPipetteNameSpecs, FLEX_ROBOT_TYPE, + WASTE_CHUTE_SLOT, } from '@opentrons/shared-data' import { selectors as stepFormSelectors, @@ -17,12 +18,17 @@ import { } from '../../step-forms' import { selectors as featureFlagSelectors } from '../../feature-flags' import { SUPPORTED_MODULE_TYPES } from '../../modules' +import { getEnableDeckModification } from '../../feature-flags/selectors' import { getAdditionalEquipment } from '../../step-forms/selectors' -import { toggleIsGripperRequired } from '../../step-forms/actions/additionalItems' +import { + createDeckFixture, + deleteDeckFixture, + toggleIsGripperRequired, +} from '../../step-forms/actions/additionalItems' import { getRobotType } from '../../file-data/selectors' import { CrashInfoBox } from './CrashInfoBox' import { ModuleRow } from './ModuleRow' -import { GripperRow } from './GripperRow' +import { AdditionalItemsRow } from './AdditionalItemsRow' import { isModuleWithCollisionIssue } from './utils' import styles from './styles.css' @@ -33,6 +39,7 @@ export interface Props { export function EditModulesCard(props: Props): JSX.Element { const { modules, openEditModuleModal } = props + const enableDeckModification = useSelector(getEnableDeckModification) const pipettesByMount = useSelector( stepFormSelectors.getPipettesForEditPipetteForm ) @@ -40,6 +47,9 @@ export function EditModulesCard(props: Props): JSX.Element { const isGripperAttached = Object.values(additionalEquipment).some( equipment => equipment?.name === 'gripper' ) + const wasteChute = Object.values(additionalEquipment).find( + equipment => equipment?.name === 'wasteChute' + ) const dispatch = useDispatch() const robotType = useSelector(getRobotType) @@ -85,9 +95,6 @@ export function EditModulesCard(props: Props): JSX.Element { : moduleType !== 'magneticBlockType' ) - const handleGripperClick = (): void => { - dispatch(toggleIsGripperRequired()) - } return (
    @@ -126,9 +133,23 @@ export function EditModulesCard(props: Props): JSX.Element { } })} {isFlex ? ( - dispatch(toggleIsGripperRequired())} + isEquipmentAdded={isGripperAttached} + name="gripper" + /> + ) : null} + {enableDeckModification && isFlex ? ( + + dispatch( + wasteChute != null + ? deleteDeckFixture(wasteChute.id) + : createDeckFixture('wasteChute', WASTE_CHUTE_SLOT) + ) + } + isEquipmentAdded={wasteChute != null} + name="wasteChute" /> ) : null}
    diff --git a/protocol-designer/src/components/modules/GripperRow.tsx b/protocol-designer/src/components/modules/GripperRow.tsx deleted file mode 100644 index c6ab14b8189..00000000000 --- a/protocol-designer/src/components/modules/GripperRow.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react' -import styled from 'styled-components' -import { useTranslation } from 'react-i18next' -import { - OutlineButton, - Flex, - JUSTIFY_SPACE_BETWEEN, - ALIGN_CENTER, - DIRECTION_COLUMN, - LabeledValue, - SPACING, -} from '@opentrons/components' -import gripperImage from '../../images/flex_gripper.svg' -import styles from './styles.css' - -interface GripperRowProps { - handleGripper: () => void - isGripperAdded: boolean -} - -export function GripperRow(props: GripperRowProps): JSX.Element { - const { handleGripper, isGripperAdded } = props - const { i18n, t } = useTranslation() - - return ( - - -

    Flex Gripper

    - -
    -
    - {isGripperAdded && ( - - )} -
    -
    - - {isGripperAdded - ? i18n.format(t('shared.remove'), 'capitalize') - : i18n.format(t('shared.add'), 'capitalize')} - -
    -
    - ) -} - -const AdditionalItemImage = styled.img` - width: 6rem; - max-height: 4rem; - display: block; -` diff --git a/protocol-designer/src/components/modules/ModuleRow.tsx b/protocol-designer/src/components/modules/ModuleRow.tsx index 1c87d93cfa7..f7d334739dd 100644 --- a/protocol-designer/src/components/modules/ModuleRow.tsx +++ b/protocol-designer/src/components/modules/ModuleRow.tsx @@ -74,10 +74,11 @@ export function ModuleRow(props: Props): JSX.Element { // If this Module is a TC deck slot and spanning // populate all 4 slots individually if (slot === SPAN7_8_10_11_SLOT) { - slotDisplayName = 'Slot 7' + slotDisplayName = 'Slot 7,8,10,11' occupiedSlotsForMap = ['7', '8', '10', '11'] // TC on Flex } else if (isFlex && type === THERMOCYCLER_MODULE_TYPE && slot === 'B1') { + slotDisplayName = 'Slot A1+B1' occupiedSlotsForMap = ['A1', 'B1'] } // If collisionSlots are populated, check which slot is occupied diff --git a/protocol-designer/src/components/modules/__tests__/AdditionalItemsRow.test.tsx b/protocol-designer/src/components/modules/__tests__/AdditionalItemsRow.test.tsx new file mode 100644 index 00000000000..f7f06597dcf --- /dev/null +++ b/protocol-designer/src/components/modules/__tests__/AdditionalItemsRow.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react' +import i18n from 'i18next' +import { renderWithProviders, SlotMap } from '@opentrons/components' +import { WASTE_CHUTE_SLOT } from '@opentrons/shared-data' + +import { AdditionalItemsRow } from '../AdditionalItemsRow' + +jest.mock('@opentrons/components/src/slotmap/SlotMap') + +const mockSlotMap = SlotMap as jest.MockedFunction + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('AdditionalItemsRow', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + handleAttachment: jest.fn(), + isEquipmentAdded: false, + name: 'gripper', + } + mockSlotMap.mockReturnValue(
    mock slot map
    ) + }) + it('renders no gripper', () => { + const { getByRole, getByText } = render(props) + getByText('Flex Gripper') + getByRole('button', { name: 'add' }).click() + expect(props.handleAttachment).toHaveBeenCalled() + }) + it('renders a gripper', () => { + props = { + ...props, + isEquipmentAdded: true, + } + const { getByRole, getByText, getByAltText } = render(props) + getByText('Flex Gripper') + getByAltText('Flex Gripper') + getByText('Model:') + getByText('GEN1') + getByRole('button', { name: 'remove' }).click() + expect(props.handleAttachment).toHaveBeenCalled() + }) + it('renders no waste chute', () => { + props = { + ...props, + name: 'wasteChute', + } + const { getByRole, getByText } = render(props) + getByText('Waste Chute') + getByRole('button', { name: 'add' }).click() + expect(props.handleAttachment).toHaveBeenCalled() + }) + it('renders a waste chute', () => { + props = { + ...props, + name: 'wasteChute', + isEquipmentAdded: true, + } + const { getByRole, getByText, getByAltText } = render(props) + getByText('Waste Chute') + getByAltText('Waste Chute') + getByText('mock slot map') + getByText('Position:') + getByText(`Slot ${WASTE_CHUTE_SLOT}`) + getByRole('button', { name: 'remove' }).click() + expect(props.handleAttachment).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx b/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx index b9876a26273..67170cf1fed 100644 --- a/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx +++ b/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx @@ -17,17 +17,19 @@ import { selectors as stepFormSelectors, } from '../../../step-forms' import { getRobotType } from '../../../file-data/selectors' +import { getEnableDeckModification } from '../../../feature-flags/selectors' import { FormPipette } from '../../../step-forms/types' import { getAdditionalEquipment } from '../../../step-forms/selectors' import { SUPPORTED_MODULE_TYPES } from '../../../modules' import { EditModulesCard } from '../EditModulesCard' import { CrashInfoBox } from '../CrashInfoBox' import { ModuleRow } from '../ModuleRow' -import { GripperRow } from '../GripperRow' +import { AdditionalItemsRow } from '../AdditionalItemsRow' jest.mock('../../../feature-flags') jest.mock('../../../step-forms/selectors') jest.mock('../../../file-data/selectors') +jest.mock('../../../feature-flags/selectors') const getDisableModuleRestrictionsMock = featureFlagSelectors.getDisableModuleRestrictions as jest.MockedFunction< typeof featureFlagSelectors.getDisableModuleRestrictions @@ -41,6 +43,9 @@ const mockGetRobotType = getRobotType as jest.MockedFunction< const mockGetAdditionalEquipment = getAdditionalEquipment as jest.MockedFunction< typeof getAdditionalEquipment > +const mockGetEnableDeckModification = getEnableDeckModification as jest.MockedFunction< + typeof getEnableDeckModification +> describe('EditModulesCard', () => { let store: any let crashableMagneticModule: ModuleOnDeck | undefined @@ -88,6 +93,7 @@ describe('EditModulesCard', () => { tiprackDefURI: null, }, }) + mockGetEnableDeckModification.mockReturnValue(false) props = { modules: {}, @@ -243,8 +249,10 @@ describe('EditModulesCard', () => { it('displays gripper row with no gripper', () => { mockGetRobotType.mockReturnValue(FLEX_ROBOT_TYPE) const wrapper = render(props) - expect(wrapper.find(GripperRow)).toHaveLength(1) - expect(wrapper.find(GripperRow).props().isGripperAdded).toEqual(false) + expect(wrapper.find(AdditionalItemsRow)).toHaveLength(1) + expect(wrapper.find(AdditionalItemsRow).props().isEquipmentAdded).toEqual( + false + ) }) it('displays gripper row with gripper attached', () => { const mockGripperId = 'gripeprId' @@ -253,7 +261,26 @@ describe('EditModulesCard', () => { [mockGripperId]: { name: 'gripper', id: mockGripperId }, }) const wrapper = render(props) - expect(wrapper.find(GripperRow)).toHaveLength(1) - expect(wrapper.find(GripperRow).props().isGripperAdded).toEqual(true) + expect(wrapper.find(AdditionalItemsRow)).toHaveLength(1) + expect(wrapper.find(AdditionalItemsRow).props().isEquipmentAdded).toEqual( + true + ) + }) + it('displays gripper and waste chute row with both attached', () => { + const mockGripperId = 'gripeprId' + const mockWasteChuteId = 'wasteChuteId' + mockGetEnableDeckModification.mockReturnValue(true) + mockGetRobotType.mockReturnValue(FLEX_ROBOT_TYPE) + mockGetAdditionalEquipment.mockReturnValue({ + mockGripperId: { name: 'gripper', id: mockGripperId }, + mockWasteChuteId: { + name: 'wasteChute', + id: mockWasteChuteId, + location: 'D3', + }, + }) + + const wrapper = render(props) + expect(wrapper.find(AdditionalItemsRow)).toHaveLength(2) }) }) diff --git a/protocol-designer/src/components/steplist/ContextMenu.tsx b/protocol-designer/src/components/steplist/ContextMenu.tsx index b4d2f5411cc..d9d5b3be1c2 100644 --- a/protocol-designer/src/components/steplist/ContextMenu.tsx +++ b/protocol-designer/src/components/steplist/ContextMenu.tsx @@ -11,6 +11,7 @@ import { actions as steplistActions } from '../../steplist' import { Portal } from '../portals/TopPortal' import styles from './StepItem.css' import { StepIdType } from '../../form-types' +import { getSavedStepForms } from '../../step-forms/selectors' const MENU_OFFSET_PX = 5 @@ -47,6 +48,9 @@ export const ContextMenu = (props: Props): JSX.Element => { const menuRoot = React.useRef(null) const isMultiSelectMode = useSelector(getIsMultiSelectMode) + const allSavedSteps = useSelector(getSavedStepForms) + const isMoveLabwareStepType = + stepId != null ? allSavedSteps[stepId].stepType === 'moveLabware' : null React.useEffect(() => { global.addEventListener('click', handleClick) @@ -78,7 +82,6 @@ export const ContextMenu = (props: Props): JSX.Element => { setStepId(stepId) setPosition({ left, top }) } - const handleClick = (event: MouseEvent): void => { const wasOutside = !( event.target instanceof Node && menuRoot.current?.contains(event.target) @@ -128,24 +131,24 @@ export const ContextMenu = (props: Props): JSX.Element => { })} {!showDeleteConfirmation && visible && ( - -
    +
    + {isMoveLabwareStepType ? null : (
    {i18n.t('context_menu.step.duplicate')}
    -
    - {i18n.t('context_menu.step.delete')} -
    + )} +
    + {i18n.t('context_menu.step.delete')}
    - +
    )}
    diff --git a/protocol-designer/src/components/steplist/MoveLabwareHeader.tsx b/protocol-designer/src/components/steplist/MoveLabwareHeader.tsx index 596b5b68859..9d641a34362 100644 --- a/protocol-designer/src/components/steplist/MoveLabwareHeader.tsx +++ b/protocol-designer/src/components/steplist/MoveLabwareHeader.tsx @@ -1,15 +1,19 @@ import * as React from 'react' import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' import cx from 'classnames' import { Tooltip, useHoverTooltip, TOOLTIP_FIXED } from '@opentrons/components' import { getLabwareDisplayName, getModuleDisplayName, + WASTE_CHUTE_SLOT, } from '@opentrons/shared-data' import { + getAdditionalEquipmentEntities, getLabwareEntities, getModuleEntities, } from '../../step-forms/selectors' +import { getHasWasteChute } from '../labware' import { PDListItem } from '../lists' import { LabwareTooltipContents } from './LabwareTooltipContents' @@ -21,11 +25,14 @@ interface MoveLabwareHeaderProps { useGripper: boolean } -// TODO(jr, 7/31/23): add text to i18n export function MoveLabwareHeader(props: MoveLabwareHeaderProps): JSX.Element { const { sourceLabwareNickname, destinationSlot, useGripper } = props + const { i18n } = useTranslation() const moduleEntities = useSelector(getModuleEntities) const labwareEntities = useSelector(getLabwareEntities) + const additionalEquipmentEntities = useSelector( + getAdditionalEquipmentEntities + ) const [sourceTargetProps, sourceTooltipProps] = useHoverTooltip({ placement: 'bottom-start', @@ -39,7 +46,7 @@ export function MoveLabwareHeader(props: MoveLabwareHeaderProps): JSX.Element { let destSlot: string | null | undefined = null if (destinationSlot === 'offDeck') { - destSlot = 'off deck' + destSlot = 'off-deck' } else if ( destinationSlot != null && moduleEntities[destinationSlot] != null @@ -50,18 +57,27 @@ export function MoveLabwareHeader(props: MoveLabwareHeaderProps): JSX.Element { labwareEntities[destinationSlot] != null ) { destSlot = getLabwareDisplayName(labwareEntities[destinationSlot].def) + } else if ( + getHasWasteChute(additionalEquipmentEntities) && + destinationSlot === WASTE_CHUTE_SLOT + ) { + destSlot = i18n.t('application.waste_chute_slot') } else { destSlot = destinationSlot } return ( <>
  • - {useGripper ? 'With gripper' : 'Manually'} + + {useGripper + ? i18n.t('application.with_gripper') + : i18n.t('application.manually')} +
  • - LABWARE + {i18n.t('application.labware')} - NEW LOCATION + {i18n.t('application.new_location')}
  • diff --git a/protocol-designer/src/constants.ts b/protocol-designer/src/constants.ts index 734d132948d..92b2ba2d5df 100644 --- a/protocol-designer/src/constants.ts +++ b/protocol-designer/src/constants.ts @@ -156,3 +156,6 @@ export const DND_TYPES = { // Values for TC fields export const THERMOCYCLER_STATE: 'thermocyclerState' = 'thermocyclerState' export const THERMOCYCLER_PROFILE: 'thermocyclerProfile' = 'thermocyclerProfile' + +export const OT_2_TRASH_DEF_URI = 'opentrons/opentrons_1_trash_1100ml_fixed/1' +export const FLEX_TRASH_DEF_URI = 'opentrons/opentrons_1_trash_3200ml_fixed/1' diff --git a/protocol-designer/src/feature-flags/reducers.ts b/protocol-designer/src/feature-flags/reducers.ts index 0fed0222d55..0396dc0a83c 100644 --- a/protocol-designer/src/feature-flags/reducers.ts +++ b/protocol-designer/src/feature-flags/reducers.ts @@ -19,9 +19,13 @@ const initialFlags: Flags = { PRERELEASE_MODE: process.env.OT_PD_PRERELEASE_MODE === '1' || false, OT_PD_DISABLE_MODULE_RESTRICTIONS: process.env.OT_PD_DISABLE_MODULE_RESTRICTIONS === '1' || false, - OT_PD_ENABLE_OT_3: process.env.OT_PD_ENABLE_OT_3 === '1' || false, OT_PD_ALLOW_ALL_TIPRACKS: process.env.OT_PD_ALLOW_ALL_TIPRACKS === '1' || false, + OT_PD_ALLOW_96_CHANNEL: process.env.OT_PD_ALLOW_96_CHANNEL === '1' || false, + OT_PD_ENABLE_FLEX_DECK_MODIFICATION: + process.env.OT_PD_ENABLE_FLEX_DECK_MODIFICATION === '1' || false, + OT_PD_ENABLE_OFF_DECK_VIS_AND_MULTI_TIP: + process.env.OT_PD_ENABLE_OFF_DECK_VIS_AND_MULTI_TIP === '1' || false, } // @ts-expect-error(sa, 2021-6-10): cannot use string literals as action type // TODO IMMEDIATELY: refactor this to the old fashioned way if we cannot have type safety: https://github.com/redux-utilities/redux-actions/issues/282#issuecomment-595163081 diff --git a/protocol-designer/src/feature-flags/selectors.ts b/protocol-designer/src/feature-flags/selectors.ts index 0a8059f5aeb..e5c91c86cdf 100644 --- a/protocol-designer/src/feature-flags/selectors.ts +++ b/protocol-designer/src/feature-flags/selectors.ts @@ -15,11 +15,19 @@ export const getDisableModuleRestrictions: Selector< getFeatureFlagData, flags => flags.OT_PD_DISABLE_MODULE_RESTRICTIONS ) -export const getEnabledOT3: Selector = createSelector( - getFeatureFlagData, - flags => flags.OT_PD_ENABLE_OT_3 ?? false -) export const getAllowAllTipracks: Selector = createSelector( getFeatureFlagData, flags => flags.OT_PD_ALLOW_ALL_TIPRACKS ?? false ) +export const getAllow96Channel: Selector = createSelector( + getFeatureFlagData, + flags => flags.OT_PD_ALLOW_96_CHANNEL ?? false +) +export const getEnableDeckModification: Selector = createSelector( + getFeatureFlagData, + flags => flags.OT_PD_ENABLE_FLEX_DECK_MODIFICATION ?? false +) +export const getEnableOffDeckVisAndMultiTip: Selector = createSelector( + getFeatureFlagData, + flags => flags.OT_PD_ENABLE_OFF_DECK_VIS_AND_MULTI_TIP ?? false +) diff --git a/protocol-designer/src/feature-flags/types.ts b/protocol-designer/src/feature-flags/types.ts index 84170d8dd7a..15e305bab36 100644 --- a/protocol-designer/src/feature-flags/types.ts +++ b/protocol-designer/src/feature-flags/types.ts @@ -19,13 +19,16 @@ export const DEPRECATED_FLAGS = [ 'OT_PD_ENABLE_HEATER_SHAKER', 'OT_PD_ENABLE_THERMOCYCLER_GEN_2', 'OT_PD_ENABLE_LIQUID_COLOR_ENHANCEMENTS', + 'OT_PD_ENABLE_OT_3', ] // union of feature flag string constant IDs export type FlagTypes = | 'PRERELEASE_MODE' | 'OT_PD_DISABLE_MODULE_RESTRICTIONS' - | 'OT_PD_ENABLE_OT_3' | 'OT_PD_ALLOW_ALL_TIPRACKS' + | 'OT_PD_ALLOW_96_CHANNEL' + | 'OT_PD_ENABLE_FLEX_DECK_MODIFICATION' + | 'OT_PD_ENABLE_OFF_DECK_VIS_AND_MULTI_TIP' // flags that are not in this list only show in prerelease mode export const userFacingFlags: FlagTypes[] = [ 'OT_PD_DISABLE_MODULE_RESTRICTIONS', diff --git a/protocol-designer/src/file-data/selectors/fileCreator.ts b/protocol-designer/src/file-data/selectors/fileCreator.ts index 2cbc67e213b..5fdd4561e43 100644 --- a/protocol-designer/src/file-data/selectors/fileCreator.ts +++ b/protocol-designer/src/file-data/selectors/fileCreator.ts @@ -6,7 +6,6 @@ import map from 'lodash/map' import reduce from 'lodash/reduce' import uniq from 'lodash/uniq' import { - FIXED_TRASH_ID, FLEX_ROBOT_TYPE, OT2_STANDARD_DECKID, OT2_STANDARD_MODEL, @@ -253,7 +252,7 @@ export const createFile: Selector = createSelector( ): LoadLabwareCreateCommand[] => { const { def } = labwareEntities[labwareId] const isAdapter = def.allowedRoles?.includes('adapter') - if (labwareId === FIXED_TRASH_ID || isAdapter) return acc + if (isAdapter) return acc const isOnTopOfModule = labware.slot in initialRobotState.modules const isOnAdapter = loadAdapterCommands.find( diff --git a/protocol-designer/src/images/flex_gripper.png b/protocol-designer/src/images/flex_gripper.png new file mode 100644 index 00000000000..eaf67d85f38 Binary files /dev/null and b/protocol-designer/src/images/flex_gripper.png differ diff --git a/protocol-designer/src/images/flex_gripper.svg b/protocol-designer/src/images/flex_gripper.svg deleted file mode 100644 index f4871edaa34..00000000000 --- a/protocol-designer/src/images/flex_gripper.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/protocol-designer/src/labware-ingred/__tests__/actions.test.ts b/protocol-designer/src/labware-ingred/__tests__/actions.test.ts index e1efe518933..6194b40fc12 100644 --- a/protocol-designer/src/labware-ingred/__tests__/actions.test.ts +++ b/protocol-designer/src/labware-ingred/__tests__/actions.test.ts @@ -115,7 +115,12 @@ describe('createContainer', () => { mockGetRobotType.mockReturnValue('OT-2 Standard') mockGetInitialDeckSetup.mockImplementation(state => { expect(state).toBe(store.getState()) - return { labware: {}, pipettes: {}, modules: {} } + return { + labware: {}, + pipettes: {}, + modules: {}, + additionalEquipmentOnDeck: {}, + } }) mockGetLabwareDefsByURI.mockImplementation(state => { @@ -145,7 +150,12 @@ describe('createContainer', () => { it('should dispatch CREATE_CONTAINER with slot from getNextAvailableDeckSlot if no slot is specified', () => { const store: any = mockStore({}) mockGetRobotType.mockReturnValue('OT-2 Standard') - const initialDeckSetup = { labware: {}, pipettes: {}, modules: {} } + const initialDeckSetup = { + labware: {}, + pipettes: {}, + modules: {}, + additionalEquipmentOnDeck: {}, + } mockGetInitialDeckSetup.mockImplementation(state => { expect(state).toBe(store.getState()) return initialDeckSetup @@ -181,7 +191,12 @@ describe('createContainer', () => { it('should do nothing if no slot is specified and getNextAvailableDeckSlot returns falsey', () => { const store: any = mockStore({}) mockGetRobotType.mockReturnValue('OT-3 Standard') - const initialDeckSetup = { labware: {}, pipettes: {}, modules: {} } + const initialDeckSetup = { + labware: {}, + pipettes: {}, + modules: {}, + additionalEquipmentOnDeck: {}, + } mockGetInitialDeckSetup.mockImplementation(state => { expect(state).toBe(store.getState()) return initialDeckSetup @@ -230,7 +245,12 @@ describe('createContainer', () => { mockGetInitialDeckSetup.mockImplementation(state => { expect(state).toBe(store.getState()) - return { labware: {}, pipettes: {}, modules: {} } + return { + labware: {}, + pipettes: {}, + modules: {}, + additionalEquipmentOnDeck: {}, + } }) mockGetLabwareDefsByURI.mockImplementation(state => { diff --git a/protocol-designer/src/labware-ingred/actions/thunks.ts b/protocol-designer/src/labware-ingred/actions/thunks.ts index 3877741df1a..1de7622b53e 100644 --- a/protocol-designer/src/labware-ingred/actions/thunks.ts +++ b/protocol-designer/src/labware-ingred/actions/thunks.ts @@ -96,6 +96,8 @@ export const duplicateLabware: ( `no labwareDefURI for labware ${templateLabwareId}, cannot run duplicateLabware thunk` ) const initialDeckSetup = stepFormSelectors.getInitialDeckSetup(state) + const templateLabwareIdIsOffDeck = + initialDeckSetup.labware[templateLabwareId].slot === 'offDeck' const duplicateSlot = getNextAvailableDeckSlot(initialDeckSetup, robotType) if (!duplicateSlot) console.warn('no slots available, cannot duplicate labware') @@ -113,7 +115,7 @@ export const duplicateLabware: ( duplicateLabwareNickname, templateLabwareId, duplicateLabwareId: uuid() + ':' + templateLabwareDefURI, - slot: duplicateSlot, + slot: templateLabwareIdIsOffDeck ? 'offDeck' : duplicateSlot, }, }) } diff --git a/protocol-designer/src/labware-ingred/reducers/index.ts b/protocol-designer/src/labware-ingred/reducers/index.ts index 98d50a0d4d6..a26e5b1b95f 100644 --- a/protocol-designer/src/labware-ingred/reducers/index.ts +++ b/protocol-designer/src/labware-ingred/reducers/index.ts @@ -3,7 +3,6 @@ import { handleActions } from 'redux-actions' import omit from 'lodash/omit' import mapValues from 'lodash/mapValues' import pickBy from 'lodash/pickBy' -import { FIXED_TRASH_ID } from '../../constants' import { getPDMetadata } from '../../file-types' import { SingleLabwareLiquidState, @@ -108,11 +107,7 @@ const selectedLiquidGroup = handleActions( }, unselectedLiquidGroupState ) -const initialLabwareState: ContainersState = { - [FIXED_TRASH_ID]: { - nickname: 'Trash', - }, -} +const initialLabwareState: ContainersState = {} // @ts-expect-error(sa, 2021-6-20): cannot use string literals as action type // TODO IMMEDIATELY: refactor this to the old fashioned way if we cannot have type safety: https://github.com/redux-utilities/redux-actions/issues/282#issuecomment-595163081 export const containers: Reducer = handleActions( @@ -185,7 +180,6 @@ export const containers: Reducer = handleActions( ) }, }, - initialLabwareState ) type SavedLabwareState = Record diff --git a/protocol-designer/src/load-file/migration/7_0_0.ts b/protocol-designer/src/load-file/migration/7_0_0.ts index 026abbcf5fc..b3d61ef0466 100644 --- a/protocol-designer/src/load-file/migration/7_0_0.ts +++ b/protocol-designer/src/load-file/migration/7_0_0.ts @@ -1,3 +1,4 @@ +import mapValues from 'lodash/mapValues' import { uuid } from '../../utils' import { getOnlyLatestDefs } from '../../labware-defs' import { INITIAL_DECK_SETUP_STEP_ID } from '../../constants' @@ -23,6 +24,7 @@ import type { DesignerApplicationData } from './utils/getLoadLiquidCommands' // NOTE: this migration removes pipettes, labware, and modules as top level keys and adds necessary // params to the load commands. Also, this migrates previous combined // adapter + labware commands to all labware commands and definitions to their commands/definitions split up +// as well as removing touch_tip commands from labware where touch_tip is incompatible const PD_VERSION = '7.0.0' const SCHEMA_VERSION = 7 interface LabwareLocationUpdate { @@ -252,6 +254,63 @@ export const migrateFile = ( } const newLabwareIngreds = getNewLabwareIngreds(ingredLocations) + const migrateSavedStepForms = ( + savedStepForms: Record + ): Record => { + return mapValues(savedStepForms, stepForm => { + if (stepForm.stepType === 'moveLiquid') { + const aspirateLabware = + newLabwareDefinitions[labware[stepForm.aspirate_labware].definitionId] + const aspirateTouchTipIncompatible = aspirateLabware?.parameters.quirks?.includes( + 'touchTipDisabled' + ) + const dispenseLabware = + newLabwareDefinitions[labware[stepForm.dispense_labware].definitionId] + const dispenseTouchTipIncompatible = dispenseLabware?.parameters.quirks?.includes( + 'touchTipDisabled' + ) + return { + ...stepForm, + aspirate_touchTip_checkbox: aspirateTouchTipIncompatible + ? false + : stepForm.aspirate_touchTip_checkbox ?? false, + aspirate_touchTip_mmFromBottom: aspirateTouchTipIncompatible + ? null + : stepForm.aspirate_touchTip_mmFromBottom ?? null, + dispense_touchTip_checkbox: dispenseTouchTipIncompatible + ? false + : stepForm.dispense_touchTip_checkbox ?? false, + dispense_touchTip_mmFromBottom: dispenseTouchTipIncompatible + ? null + : stepForm.dispense_touchTip_mmFromBottom ?? null, + } + } else if (stepForm.stepType === 'mix') { + const mixLabware = + newLabwareDefinitions[labware[stepForm.labware].definitionId] + const mixTouchTipIncompatible = mixLabware?.parameters.quirks?.includes( + 'touchTipDisabled' + ) + return { + ...stepForm, + mix_touchTip_checkbox: mixTouchTipIncompatible + ? false + : stepForm.mix_touchTip_checkbox ?? false, + mix_touchTip_mmFromBottom: mixTouchTipIncompatible + ? null + : stepForm.mix_touchTip_mmFromBottom ?? null, + } + } + + return stepForm + }) + } + const filteredavedStepForms = Object.fromEntries( + Object.entries( + appData.designerApplication?.data?.savedStepForms ?? {} + ).filter(([key, value]) => key !== INITIAL_DECK_SETUP_STEP_ID) + ) + const newFilteredavedStepForms = migrateSavedStepForms(filteredavedStepForms) + return { ...rest, designerApplication: { @@ -263,7 +322,6 @@ export const migrateFile = ( ...newLabwareIngreds, }, savedStepForms: { - ...appData.designerApplication?.data?.savedStepForms, [INITIAL_DECK_SETUP_STEP_ID]: { ...appData.designerApplication?.data?.savedStepForms[ INITIAL_DECK_SETUP_STEP_ID @@ -272,6 +330,7 @@ export const migrateFile = ( ...newLabwareLocationUpdate, }, }, + ...newFilteredavedStepForms, }, }, }, diff --git a/protocol-designer/src/load-file/migration/7_1_0.ts b/protocol-designer/src/load-file/migration/7_1_0.ts new file mode 100644 index 00000000000..1823f265836 --- /dev/null +++ b/protocol-designer/src/load-file/migration/7_1_0.ts @@ -0,0 +1,127 @@ +import mapValues from 'lodash/mapValues' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { getOnlyLatestDefs } from '../../labware-defs' +import { uuid } from '../../utils' +import { + FLEX_TRASH_DEF_URI, + INITIAL_DECK_SETUP_STEP_ID, + OT_2_TRASH_DEF_URI, +} from '../../constants' +import type { + LoadLabwareCreateCommand, + ProtocolFile, +} from '@opentrons/shared-data/protocol/types/schemaV7' +import type { DesignerApplicationData } from './utils/getLoadLiquidCommands' + +// NOTE: this migration updates fixed trash by treating it as an entity +const PD_VERSION = '7.1.0' + +interface LabwareLocationUpdate { + [id: string]: string +} + +export const migrateFile = ( + appData: ProtocolFile +): ProtocolFile => { + const { designerApplication, robot, commands } = appData + const labwareLocationUpdate: LabwareLocationUpdate = + designerApplication?.data?.savedStepForms[INITIAL_DECK_SETUP_STEP_ID] + .labwareLocationUpdate + const allLatestDefs = getOnlyLatestDefs() + + const robotType = robot.model + const trashSlot = robotType === FLEX_ROBOT_TYPE ? 'A3' : '12' + const trashDefUri = + robotType === FLEX_ROBOT_TYPE ? FLEX_TRASH_DEF_URI : OT_2_TRASH_DEF_URI + + const trashDefinition = allLatestDefs[trashDefUri] + const trashId = `${uuid()}:${trashDefUri}` + + const trashLoadCommand = [ + { + key: uuid(), + commandType: 'loadLabware', + params: { + location: { slotName: trashSlot }, + version: 1, + namespace: 'opentrons', + loadName: trashDefinition.parameters.loadName, + displayName: trashDefinition.metadata.displayName, + labwareId: trashId, + }, + }, + ] as LoadLabwareCreateCommand[] + + const newLabwareLocationUpdate: LabwareLocationUpdate = Object.keys( + labwareLocationUpdate + ).reduce((acc: LabwareLocationUpdate, labwareId: string) => { + if (labwareId === 'fixedTrash') { + acc[trashId] = trashSlot + } else { + acc[labwareId] = labwareLocationUpdate[labwareId] + } + return acc + }, {}) + + const migrateSavedStepForms = ( + savedStepForms: Record + ): Record => { + return mapValues(savedStepForms, stepForm => { + if (stepForm.stepType === 'moveLiquid') { + return { + ...stepForm, + blowout_location: + stepForm.blowout_location === 'fixedTrash' + ? trashId + : stepForm.blowout_location, + } + } else if (stepForm.stepType === 'mix') { + return { + ...stepForm, + blowout_location: + stepForm.blowout_location === 'fixedTrash' + ? trashId + : stepForm.blowout_location, + } + } + + return stepForm + }) + } + + const filteredSavedStepForms = Object.fromEntries( + Object.entries( + appData.designerApplication?.data?.savedStepForms ?? {} + ).filter(([key, value]) => key !== INITIAL_DECK_SETUP_STEP_ID) + ) + const newFilteredSavedStepForms = migrateSavedStepForms( + filteredSavedStepForms + ) + + return { + ...appData, + designerApplication: { + ...appData.designerApplication, + version: PD_VERSION, + data: { + ...appData.designerApplication?.data, + savedStepForms: { + [INITIAL_DECK_SETUP_STEP_ID]: { + ...appData.designerApplication?.data?.savedStepForms[ + INITIAL_DECK_SETUP_STEP_ID + ], + labwareLocationUpdate: { + ...newLabwareLocationUpdate, + }, + }, + ...newFilteredSavedStepForms, + }, + }, + }, + labwareDefinitions: { + ...{ [trashId]: trashDefinition }, + ...appData.labwareDefinitions, + }, + commands: [...commands, ...trashLoadCommand], + } +} diff --git a/protocol-designer/src/load-file/migration/__tests__/7_1_0.test.ts b/protocol-designer/src/load-file/migration/__tests__/7_1_0.test.ts new file mode 100644 index 00000000000..225f8fea535 --- /dev/null +++ b/protocol-designer/src/load-file/migration/__tests__/7_1_0.test.ts @@ -0,0 +1,140 @@ +import { migrateFile } from '../7_1_0' +import fixture_trash from '@opentrons/shared-data/labware/fixtures/2/fixture_trash.json' +import _oldDoItAllProtocol from '../../../../fixtures/protocol/7/doItAllV7.json' +import { getOnlyLatestDefs, LabwareDefByDefURI } from '../../../labware-defs' +import type { ProtocolFile } from '@opentrons/shared-data' + +jest.mock('../../../labware-defs') + +const oldDoItAllProtocol = (_oldDoItAllProtocol as unknown) as ProtocolFile + +const mockGetOnlyLatestDefs = getOnlyLatestDefs as jest.MockedFunction< + typeof getOnlyLatestDefs +> +const trashUri = 'opentrons/opentrons_1_trash_3200ml_fixed/1' + +describe('v7.1 migration', () => { + beforeEach(() => { + mockGetOnlyLatestDefs.mockReturnValue({ + [trashUri]: fixture_trash, + } as LabwareDefByDefURI) + }) + it('adds a trash command', () => { + const migratedFile = migrateFile(oldDoItAllProtocol) + const expectedLoadLabwareCommands = [ + { + commandType: 'loadLabware', + key: expect.any(String), + params: { + displayName: 'Opentrons 96 Flat Bottom Adapter', + labwareId: + 'd95bb3be-b453-457c-a947-bd03dc8e56b9:opentrons/opentrons_96_flat_bottom_adapter/1', + loadName: 'opentrons_96_flat_bottom_adapter', + location: { + moduleId: + 'c19dffa3-cb34-4702-bcf6-dcea786257d1:heaterShakerModuleType', + }, + namespace: 'opentrons', + version: 1, + }, + }, + { + commandType: 'loadLabware', + key: expect.any(String), + params: { + displayName: 'Opentrons Fixed Trash', + labwareId: + '89d0e1b6-4d51-447b-b01b-3726a1f54137:opentrons/opentrons_1_trash_3200ml_fixed/1', + loadName: 'opentrons_1_trash_3200ml_fixed', + location: { + slotName: 'A3', + }, + namespace: 'opentrons', + version: 1, + }, + }, + { + commandType: 'loadLabware', + key: expect.any(String), + params: { + displayName: 'Opentrons Flex 96 Filter Tip Rack 50 µL', + labwareId: + '23ed35de-5bfd-4bb0-8f54-da99a2804ed9:opentrons/opentrons_flex_96_filtertiprack_50ul/1', + loadName: 'opentrons_flex_96_filtertiprack_50ul', + location: { + slotName: 'C1', + }, + namespace: 'opentrons', + version: 1, + }, + }, + { + commandType: 'loadLabware', + key: expect.any(String), + params: { + displayName: 'NEST 96 Well Plate 100 µL PCR Full Skirt', + labwareId: + 'fcba73e7-b88e-438e-963e-f8b9a5de0983:opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2', + loadName: 'nest_96_wellplate_100ul_pcr_full_skirt', + location: { + moduleId: + '627b7a27-5bb7-46de-a530-67af45652e3b:thermocyclerModuleType', + }, + namespace: 'opentrons', + version: 2, + }, + }, + { + commandType: 'loadLabware', + key: expect.any(String), + params: { + displayName: + 'Opentrons 24 Well Aluminum Block with NEST 1.5 mL Snapcap', + labwareId: + 'a793a135-06aa-4ed6-a1d3-c176c7810afa:opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1', + loadName: 'opentrons_24_aluminumblock_nest_1.5ml_snapcap', + location: { + moduleId: + 'ef44ad7f-0fd9-46d6-8bc0-c70785644cc8:temperatureModuleType', + }, + namespace: 'opentrons', + version: 1, + }, + }, + { + commandType: 'loadLabware', + key: expect.any(String), + params: { + displayName: 'NEST 96 Well Plate 200 µL Flat', + labwareId: + '239ceac8-23ec-4900-810a-70aeef880273:opentrons/nest_96_wellplate_200ul_flat/2', + loadName: 'nest_96_wellplate_200ul_flat', + location: { + labwareId: + 'd95bb3be-b453-457c-a947-bd03dc8e56b9:opentrons/opentrons_96_flat_bottom_adapter/1', + }, + namespace: 'opentrons', + version: 2, + }, + }, + { + commandType: 'loadLabware', + key: expect.any(String), + params: { + displayName: 'Tall Fixed Trash', + labwareId: expect.any(String), + loadName: 'fixture_trash', + location: { + slotName: 'A3', + }, + namespace: 'opentrons', + version: 1, + }, + }, + ] + const loadLabwareCommands = migratedFile.commands.filter( + command => command.commandType === 'loadLabware' + ) + expect(loadLabwareCommands).toEqual(expectedLoadLabwareCommands) + }) +}) diff --git a/protocol-designer/src/load-file/migration/index.ts b/protocol-designer/src/load-file/migration/index.ts index 7c061b6c281..29490da4d4e 100644 --- a/protocol-designer/src/load-file/migration/index.ts +++ b/protocol-designer/src/load-file/migration/index.ts @@ -10,6 +10,8 @@ import { migrateFile as migrateFileFiveOne } from './5_1_0' import { migrateFile as migrateFileFiveTwo } from './5_2_0' import { migrateFile as migrateFileSix } from './6_0_0' import { migrateFile as migrateFileSeven } from './7_0_0' +import { migrateFile as migrateFileSevenOne } from './7_1_0' + export const OLDEST_MIGRATEABLE_VERSION = '1.0.0' type Version = string type MigrationsByVersion = Record< @@ -40,6 +42,8 @@ const allMigrationsByVersion: MigrationsByVersion = { '6.0.0': migrateFileSix, // @ts-expect-error '7.0.0': migrateFileSeven, + // @ts-expect-error + '7.1.0': migrateFileSevenOne, } export const migration = ( file: any diff --git a/protocol-designer/src/load-file/reducers.ts b/protocol-designer/src/load-file/reducers.ts index 129e40eed99..a47284a04e3 100644 --- a/protocol-designer/src/load-file/reducers.ts +++ b/protocol-designer/src/load-file/reducers.ts @@ -66,7 +66,9 @@ const unsavedChanges = ( case 'CREATE_MODULE': case 'DELETE_MODULE': case 'EDIT_MODULE': - case 'IS_GRIPPER_REQUIRED': + case 'TOGGLE_IS_GRIPPER_REQUIRED': + case 'DELETE_DECK_FIXTURE': + case 'CREATE_DECK_FIXTURE': return true default: diff --git a/protocol-designer/src/localization/en/alert.json b/protocol-designer/src/localization/en/alert.json index d212fd1c819..0bf2a1a1535 100644 --- a/protocol-designer/src/localization/en/alert.json +++ b/protocol-designer/src/localization/en/alert.json @@ -37,7 +37,7 @@ }, "deck_setup_explanation": { "title": "Setting up your protocol", - "body1": "If you look to your left you will see the Protocol Timeline, and that the \"Starting Deck State\" step is blue. The blue means that this is the step you are currently working on.", + "body1": "If you look to your left you will see the Protocol Timeline, and that the \"Starting Deck State\" step is listed first.", "body2": "Before you can build a protocol that tells the robot how to move liquid around, you'll need to tell the Protocol Designer where all of your labware is on the deck, and which liquids start in which wells. As you add steps to your protocol the Protocol Designer will move liquid from the starting positions you defined in the Starting Deck State to new positions.", "body3": "Hover on empty slots in the deck to add labware. Hover on labware to add liquid to the wells." }, @@ -84,6 +84,10 @@ "body4a": "To batch edit Transfer or Mix advanced settings:", "body4b": "Only include Transfer or Mix steps in your selection." + }, + "waste_chute_warning": { + "title": "Disposing labware", + "body1": "Moving labware to the Waste Chute permanently discards it. You can't use this labware in later steps. During a protocol run, the labware will be dropped in the chute and become irretrievable." } }, "timeline": { @@ -141,7 +145,7 @@ "body": "8-Channel pipettes cannot access labware in front of or behind a Heater-Shaker. They can access Opentrons Tip Racks in this slot. Move labware to a different slot." }, "LABWARE_OFF_DECK": { - "title": "Labware is off deck", + "title": "Labware is off-deck", "body": "The robot can only perform steps on labware that is on the deck. Add or change a Move Labware step to put it on the deck before this step." }, "HEATER_SHAKER_LATCH_CLOSED": { @@ -157,6 +161,14 @@ "ASPIRATE_FROM_PRISTINE_WELL": { "title": "Source well is empty", "body": "The well(s) you're trying to aspirate from are empty. To add liquids, hover over labware in " + }, + "LABWARE_IN_WASTE_CHUTE_HAS_LIQUID": { + "title": "Moving labware into waste chute", + "body": "This labware has remaining liquid, be advised that once you dispose of it, there is no way to get it back later in the protocol." + }, + "TIPRACK_IN_WASTE_CHUTE_HAS_TIPS": { + "title": "Moving tiprack into waste chute", + "body": "This tiprack has remaining tips, be advised that once you dispose of it, there is no way to get it back later in the protocol. " } } }, @@ -169,7 +181,7 @@ "module_placement": { "SLOT_OCCUPIED": { "title": "Cannot place module", - "body": "Slot {{selectedSlot}} is occupied by another module or by labware incompatible with this module. Remove module or labware from the slot in order to continue." + "body": "Slot {{selectedSlot}} is occupied. Clear the slot to continue." }, "HEATER_SHAKER_ADJACENT_LABWARE_TOO_TALL": { "title": "Cannot place module", diff --git a/protocol-designer/src/localization/en/application.json b/protocol-designer/src/localization/en/application.json index b364cebb322..e00f7e4bdf6 100644 --- a/protocol-designer/src/localization/en/application.json +++ b/protocol-designer/src/localization/en/application.json @@ -1,4 +1,17 @@ { + "exit_batch_edit": "exit batch edit", + "go_back": "Go back", + "labware": "labware", + "new_location": "new location", + "n_steps_selected": "{{n}} steps selected", + "networking": { + "generic_verification_failure": "Something went wrong with your unique link. Fill out the form below to have a new one sent to your email. Please contact the Opentrons Support team if you require further help.", + "unauthorized_verification_failure": "This unique link has expired and is no longer valid, to have a new link sent to your email, fill out the form below.", + "validation_server_failure": "There was a problem communicating with the Opentrons validation server. Please make sure you are still connected to the internet and try again. If this problem persists, please contact Opentrons support." + }, + "next": "Next", + "no_batch_edit_shared_settings": "Batch editing of settings is only available for Transfer or Mix steps", + "manually": "Manually", "stepType": { "mix": "mix", "moveLabware": "move labware", @@ -24,15 +37,7 @@ "cycles": "cycles", "rpm": "rpm" }, - "version": "Protocol Designer Version", - "networking": { - "generic_verification_failure": "Something went wrong with your unique link. Fill out the form below to have a new one sent to your email. Please contact the Opentrons Support team if you require further help.", - "unauthorized_verification_failure": "This unique link has expired and is no longer valid, to have a new link sent to your email, fill out the form below.", - "validation_server_failure": "There was a problem communicating with the Opentrons validation server. Please make sure you are still connected to the internet and try again. If this problem persists, please contact Opentrons support." - }, - "exit_batch_edit": "exit batch edit", - "n_steps_selected": "{{n}} steps selected", - "no_batch_edit_shared_settings": "Batch editing of settings is only available for Transfer or Mix steps", - "go_back": "Go back", - "next": "Next" + "waste_chute_slot": "Waste chute in slot D3", + "with_gripper": "With gripper", + "version": "Protocol Designer Version" } diff --git a/protocol-designer/src/localization/en/button.json b/protocol-designer/src/localization/en/button.json index 28f4ae407c5..bedb95b28c1 100644 --- a/protocol-designer/src/localization/en/button.json +++ b/protocol-designer/src/localization/en/button.json @@ -1,5 +1,6 @@ { "add_step": "+ Add Step", + "add_off_deck": "+ Off-deck labware", "cancel": "cancel", "close": "close", "continue": "continue", @@ -9,6 +10,7 @@ "discard_changes": "discard changes", "done": "done", "edit": "edit", + "edit_off_deck": "Edit off-deck labware", "new_liquid": "New Liquid", "no": "no", "notes": "notes", diff --git a/protocol-designer/src/localization/en/deck.json b/protocol-designer/src/localization/en/deck.json index 1337abc9118..436c856ead5 100644 --- a/protocol-designer/src/localization/en/deck.json +++ b/protocol-designer/src/localization/en/deck.json @@ -1,7 +1,7 @@ { "warning": { "gen1multichannel": "No GEN1 8-Channel access", - "cancelForSure": "Are you sure you want to permanently delete this adapter?" + "cancelForSure": "Are you sure you want to remove this {{adapterName}}?" }, "blocked_slot": { "MODULE_INCOMPATIBLE_SINGLE_LABWARE": "Labware incompatible with this module", @@ -11,6 +11,10 @@ "header": { "end": "Click on labware to inspect the result of your protocol" }, + "off_deck": { + "slideout_title": "Off-deck labware", + "slideout_empty_state": "There is currently no off-deck labware in this step of the protocol" + }, "overlay": { "name_labware": { "nickname_placeholder": "Add a nickname?", @@ -29,7 +33,8 @@ "add_labware": "Add Labware", "drag_to_new_slot": "Drag To New Slot", "place_here": "Place Here", - "add_adapter": "Add Adapter" + "add_adapter_or_labware": "Add Labware or Adapter", + "add_adapter": "Add adapter" } }, "inactive_deck": "hover on a step to see deck state" diff --git a/protocol-designer/src/localization/en/feature_flags.json b/protocol-designer/src/localization/en/feature_flags.json index 0e1ca67c72f..ff6e8008d64 100644 --- a/protocol-designer/src/localization/en/feature_flags.json +++ b/protocol-designer/src/localization/en/feature_flags.json @@ -8,20 +8,20 @@ "description_1": "Turn off all restrictions on module placement and related pipette crash guidance.", "description_2": "NOT recommended! Switching from default positions may cause crashes and the Protocol Designer cannot yet give guidance on what to expect. Use at your own discretion. " }, - "OT_PD_ENABLE_BATCH_EDIT_MIX": { - "title": "Enable mix batch edit", - "description": "Allow users to batch edit mix forms" - }, - "OT_PD_ENABLE_SCHEMA_V6": { - "title": "Enable schema v6 support", - "description": "Allow users to migrate older protocols to schema v6" - }, - "OT_PD_ENABLE_OT_3": { - "title": "Enable OT-3 support", - "description": "Allow users to enable OT-3 support in protocol designer" - }, "OT_PD_ALLOW_ALL_TIPRACKS": { "title": "Allow all tip rack options", "description": "Enable selection of all tip racks for each pipette." + }, + "OT_PD_ALLOW_96_CHANNEL": { + "title": "Enable 96-channel pipette", + "description": "Allow users to select 96-channel pipette" + }, + "OT_PD_ENABLE_FLEX_DECK_MODIFICATION": { + "title": "Enable Flex deck modification", + "description": "Allow users to select waste chute, Flex staging, and modify trash slot" + }, + "OT_PD_ENABLE_OFF_DECK_VIS_AND_MULTI_TIP": { + "title": "Enable off-deck visuals and multi tiprack support", + "description": "Allow users to see off-deck labware visualizations and multi tiprack support" } } diff --git a/protocol-designer/src/localization/en/form.json b/protocol-designer/src/localization/en/form.json index d7fc7289fa4..b2df947d5d1 100644 --- a/protocol-designer/src/localization/en/form.json +++ b/protocol-designer/src/localization/en/form.json @@ -37,7 +37,7 @@ "mixLabware": "labware", "movedLabware": "labware", "errors": { - "labwareSlotIncompatible": "The selected labware and new location are incompatible" + "labwareSlotIncompatible": "The {{labwareName}} in {{slot}} is incompatible" } }, "mixVolumeLabel": "mix volume", @@ -114,11 +114,11 @@ "touchTip": { "label": "touch tip" }, "moduleActionLabware": { "label": "module" }, "moduleLabwarePrefix": { - "magneticModuleType": "MAG", - "temperatureModuleType": "TEMP", - "thermocyclerModuleType": "THERMO", - "heaterShakerModuleType": "HS", - "magneticBlockType": "MAG" + "magneticModuleType": "Magnetic Module", + "temperatureModuleType": "Temperature Module", + "thermocyclerModuleType": "Thermocycler", + "heaterShakerModuleType": "Heater-Shaker", + "magneticBlockType": "Magnetic Block" }, "magnetAction": { "label": "Magnet action", diff --git a/protocol-designer/src/localization/en/modules.json b/protocol-designer/src/localization/en/modules.json index 92ff03eaf44..0073f356d14 100644 --- a/protocol-designer/src/localization/en/modules.json +++ b/protocol-designer/src/localization/en/modules.json @@ -1,4 +1,8 @@ { + "additional_equipment_display_names": { + "gripper": "Flex Gripper", + "wasteChute": "Waste Chute" + }, "module_display_names": { "temperatureModuleType": "Temperature", "magneticModuleType": "Magnetic", diff --git a/protocol-designer/src/localization/en/tooltip.json b/protocol-designer/src/localization/en/tooltip.json index 0ac80ec523e..36a5dc23826 100644 --- a/protocol-designer/src/localization/en/tooltip.json +++ b/protocol-designer/src/localization/en/tooltip.json @@ -3,6 +3,7 @@ "advanced_settings": "Advanced Settings", "disabled_step_creation": "New steps cannot be added in Batch Edit mode.", + "disabled_off_deck": "Off-deck labware cannot be modified unless on starting deck state.", "step_description": { "mix": "Mix contents of wells/tubes.", diff --git a/protocol-designer/src/modules/moduleData.ts b/protocol-designer/src/modules/moduleData.ts index 98c4f520f00..1167140bd89 100644 --- a/protocol-designer/src/modules/moduleData.ts +++ b/protocol-designer/src/modules/moduleData.ts @@ -140,12 +140,16 @@ const HEATER_SHAKER_SLOTS_OT2: DropdownOption[] = [ ] const HS_AND_TEMP_SLOTS_FLEX: DropdownOption[] = [ { - name: 'Slot D1', - value: 'D1', + name: 'Slot A1', + value: 'A1', }, { - name: 'Slot D3', - value: 'D3', + name: 'Slot B1', + value: 'B1', + }, + { + name: 'Slot B3', + value: 'B3', }, { name: 'Slot C1', @@ -156,31 +160,35 @@ const HS_AND_TEMP_SLOTS_FLEX: DropdownOption[] = [ value: 'C3', }, { - name: 'Slot B1', - value: 'B1', + name: 'Slot D1', + value: 'D1', }, { - name: 'Slot B3', - value: 'B3', + name: 'Slot D3', + value: 'D3', }, +] + +const MAG_BLOCK_SLOTS_FLEX: DropdownOption[] = [ { name: 'Slot A1', value: 'A1', }, -] - -const MAG_BLOCK_SLOTS_FLEX: DropdownOption[] = [ { - name: 'Slot D1', - value: 'D1', + name: 'Slot A2', + value: 'A2', }, { - name: 'Slot D2', - value: 'D2', + name: 'Slot B1', + value: 'B1', }, { - name: 'Slot D3', - value: 'D3', + name: 'Slot B2', + value: 'B2', + }, + { + name: 'Slot B3', + value: 'B3', }, { name: 'Slot C1', @@ -195,24 +203,16 @@ const MAG_BLOCK_SLOTS_FLEX: DropdownOption[] = [ value: 'C3', }, { - name: 'Slot B1', - value: 'B1', - }, - { - name: 'Slot B2', - value: 'B2', - }, - { - name: 'Slot B3', - value: 'B3', + name: 'Slot D1', + value: 'D1', }, { - name: 'Slot A1', - value: 'A1', + name: 'Slot D2', + value: 'D2', }, { - name: 'Slot A2', - value: 'A2', + name: 'Slot D3', + value: 'D3', }, ] export function getAllModuleSlotsByType( @@ -248,12 +248,16 @@ export function getAllModuleSlotsByType( moduleType === HEATERSHAKER_MODULE_TYPE || moduleType === TEMPERATURE_MODULE_TYPE ) { - slot = HS_AND_TEMP_SLOTS_FLEX.filter( - s => s.value !== supportedSlotOption[0].value + slot = supportedSlotOption.concat( + HS_AND_TEMP_SLOTS_FLEX.filter( + s => s.value !== supportedSlotOption[0].value + ) ) } else { - slot = MAG_BLOCK_SLOTS_FLEX.filter( - s => s.value !== supportedSlotOption[0].value + slot = supportedSlotOption.concat( + MAG_BLOCK_SLOTS_FLEX.filter( + s => s.value !== supportedSlotOption[0].value + ) ) } } diff --git a/protocol-designer/src/step-forms/actions/additionalItems.ts b/protocol-designer/src/step-forms/actions/additionalItems.ts index b7e53ceb5ca..dc61e8125b8 100644 --- a/protocol-designer/src/step-forms/actions/additionalItems.ts +++ b/protocol-designer/src/step-forms/actions/additionalItems.ts @@ -1,3 +1,5 @@ +import { uuid } from '../../utils' + export interface ToggleIsGripperRequiredAction { type: 'TOGGLE_IS_GRIPPER_REQUIRED' } @@ -5,3 +7,37 @@ export interface ToggleIsGripperRequiredAction { export const toggleIsGripperRequired = (): ToggleIsGripperRequiredAction => ({ type: 'TOGGLE_IS_GRIPPER_REQUIRED', }) +export interface CreateDeckFixtureAction { + type: 'CREATE_DECK_FIXTURE' + payload: { + name: 'wasteChute' + id: string + location: string + } +} + +export const createDeckFixture = ( + name: 'wasteChute', + location: string +): CreateDeckFixtureAction => ({ + type: 'CREATE_DECK_FIXTURE', + payload: { + name, + id: `${uuid()}:${name}`, + location, + }, +}) + +export interface DeleteDeckFixtureAction { + type: 'DELETE_DECK_FIXTURE' + payload: { + id: string + } +} + +export const deleteDeckFixture = (id: string): DeleteDeckFixtureAction => ({ + type: 'DELETE_DECK_FIXTURE', + payload: { + id, + }, +}) diff --git a/protocol-designer/src/step-forms/reducers/index.ts b/protocol-designer/src/step-forms/reducers/index.ts index e457f805667..bf879c30118 100644 --- a/protocol-designer/src/step-forms/reducers/index.ts +++ b/protocol-designer/src/step-forms/reducers/index.ts @@ -23,11 +23,7 @@ import { import type { RootState as LabwareDefsRootState } from '../../labware-defs' import { rootReducer as labwareDefsRootReducer } from '../../labware-defs' import { uuid } from '../../utils' -import { - INITIAL_DECK_SETUP_STEP_ID, - FIXED_TRASH_ID, - SPAN7_8_10_11_SLOT, -} from '../../constants' +import { INITIAL_DECK_SETUP_STEP_ID, SPAN7_8_10_11_SLOT } from '../../constants' import { getPDMetadata } from '../../file-types' import { getDefaultsForStepType, @@ -115,7 +111,11 @@ import { ResetBatchEditFieldChangesAction, SaveStepFormsMultiAction, } from '../actions' -import { ToggleIsGripperRequiredAction } from '../actions/additionalItems' +import { + CreateDeckFixtureAction, + DeleteDeckFixtureAction, + ToggleIsGripperRequiredAction, +} from '../actions/additionalItems' type FormState = FormData | null const unsavedFormInitialState = null // the `unsavedForm` state holds temporary form info that is saved or thrown away with "cancel". @@ -140,6 +140,8 @@ export type UnsavedFormActions = | EditProfileStepAction | SelectMultipleStepsAction | ToggleIsGripperRequiredAction + | CreateDeckFixtureAction + | DeleteDeckFixtureAction export const unsavedForm = ( rootState: RootState, action: UnsavedFormActions @@ -200,6 +202,8 @@ export const unsavedForm = ( case 'CREATE_MODULE': case 'DELETE_MODULE': case 'TOGGLE_IS_GRIPPER_REQUIRED': + case 'CREATE_DECK_FIXTURE': + case 'DELETE_DECK_FIXTURE': case 'DELETE_STEP': case 'DELETE_MULTIPLE_STEPS': case 'SELECT_MULTIPLE_STEPS': @@ -487,6 +491,8 @@ export type SavedStepFormsActions = | ReplaceCustomLabwareDef | EditModuleAction | ToggleIsGripperRequiredAction + | CreateDeckFixtureAction + | DeleteDeckFixtureAction export const _editModuleFormUpdate = ({ savedForm, moduleId, @@ -590,7 +596,6 @@ export const savedStepForms = ( ...stepForm, })) } - case 'DUPLICATE_LABWARE': case 'CREATE_CONTAINER': { // auto-update initial deck setup state. @@ -1094,11 +1099,7 @@ export const batchEditFormChanges = ( } } } -const initialLabwareState: NormalizedLabwareById = { - [FIXED_TRASH_ID]: { - labwareDefURI: 'opentrons/opentrons_1_trash_1100ml_fixed/1', - }, -} +const initialLabwareState: NormalizedLabwareById = {} // MIGRATION NOTE: copied from `containers` reducer. Slot + UI stuff stripped out. export const labwareInvariantProperties: Reducer< NormalizedLabwareById, @@ -1144,7 +1145,6 @@ export const labwareInvariantProperties: Reducer< (command): command is LoadLabwareCreateCommand => command.commandType === 'loadLabware' ) - const FIXED_TRASH_ID = 'fixedTrash' const labware = { ...loadLabwareCommands.reduce( (acc: NormalizedLabwareById, command: LoadLabwareCreateCommand) => { @@ -1160,9 +1160,6 @@ export const labwareInvariantProperties: Reducer< }, {} ), - [FIXED_TRASH_ID]: { - labwareDefURI: 'opentrons/opentrons_1_trash_1100ml_fixed/1', - }, } return Object.keys(labware).length > 0 ? labware : state @@ -1317,9 +1314,10 @@ export const additionalEquipmentInvariantProperties = handleActions 0 const isOt3 = file.robot.model === FLEX_ROBOT_TYPE - const additionalEquipmentId = uuid() + const additionalEquipmentId = `${uuid()}:gripper` const updatedEquipment = { [additionalEquipmentId]: { name: 'gripper' as const, @@ -1335,25 +1333,45 @@ export const additionalEquipmentInvariantProperties = handleActions { - const additionalEquipmentId = Object.keys(state)[0] - const existingEquipment = state[additionalEquipmentId] - - let updatedEquipment + let updatedEquipment = { ...state } + const gripperId = `${uuid()}:gripper` + const gripperKey = Object.keys(updatedEquipment).find( + key => updatedEquipment[key].name === 'gripper' + ) - if (existingEquipment && existingEquipment.name === 'gripper') { - updatedEquipment = {} + if (gripperKey != null) { + updatedEquipment = omit(updatedEquipment, [gripperKey]) } else { - const newAdditionalEquipmentId = uuid() updatedEquipment = { - [newAdditionalEquipmentId]: { + ...updatedEquipment, + [gripperId]: { name: 'gripper' as const, - id: newAdditionalEquipmentId, + id: gripperId, }, } } - return updatedEquipment }, + // @ts-expect-error + CREATE_DECK_FIXTURE: ( + state: NormalizedAdditionalEquipmentById, + action: CreateDeckFixtureAction + ): NormalizedAdditionalEquipmentById => { + const { location, id, name } = action.payload + return { + ...state, + [id]: { + name, + id, + location, + }, + } + }, + // @ts-expect-error + DELETE_DECK_FIXTURE: ( + state: NormalizedAdditionalEquipmentById, + action: DeleteDeckFixtureAction + ): NormalizedAdditionalEquipmentById => omit(state, action.payload.id), DEFAULT: (): NormalizedAdditionalEquipmentById => ({}), }, initialAdditionalEquipmentState diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index f49ef3d41e5..8e1192971ef 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -16,6 +16,7 @@ import { MAGNETIC_BLOCK_TYPE, } from '@opentrons/shared-data' import { + AdditionalEquipmentEntities, NormalizedAdditionalEquipmentById, TEMPERATURE_DEACTIVATED, } from '@opentrons/step-generation' @@ -151,6 +152,15 @@ export const _getPipetteEntitiesRootState: ( labwareDefSelectors._getLabwareDefsByIdRootState, denormalizePipetteEntities ) +// Special version of `getAdditionalEquipmentEntities` selector for use in step-forms reducers +export const _getAdditionalEquipmentEntitiesRootState: ( + arg: RootState +) => AdditionalEquipmentEntities = rs => + rs.additionalEquipmentInvariantProperties +export const getAdditionalEquipmentEntities: Selector< + BaseState, + AdditionalEquipmentEntities +> = createSelector(rootSelector, _getAdditionalEquipmentEntitiesRootState) export const getPipetteEntities: Selector< BaseState, @@ -204,18 +214,32 @@ const _getInitialDeckSetup = ( initialSetupStep: FormData, labwareEntities: LabwareEntities, pipetteEntities: PipetteEntities, - moduleEntities: ModuleEntities + moduleEntities: ModuleEntities, + additionalEquipmentEntities: AdditionalEquipmentEntities ): InitialDeckSetup => { assert( initialSetupStep && initialSetupStep.stepType === 'manualIntervention', 'expected initial deck setup step to be "manualIntervention" step' ) + const labwareLocations = (initialSetupStep && initialSetupStep.labwareLocationUpdate) || {} const moduleLocations = (initialSetupStep && initialSetupStep.moduleLocationUpdate) || {} const pipetteLocations = (initialSetupStep && initialSetupStep.pipetteLocationUpdate) || {} + + // filtering only the additionalEquipmentEntities that are rendered on the deck + // which for now is only the wasteChute + const additionalEquipmentEntitiesOnDeck = Object.values( + additionalEquipmentEntities + ).reduce((aeEntities: AdditionalEquipmentEntities, ae) => { + if (ae.name === 'wasteChute') { + aeEntities[ae.id] = ae + } + return aeEntities + }, {}) + return { labware: mapValues<{}, LabwareOnDeck>( labwareLocations, @@ -281,6 +305,7 @@ const _getInitialDeckSetup = ( return { mount, ...pipetteEntities[pipetteId] } } ), + additionalEquipmentOnDeck: additionalEquipmentEntitiesOnDeck, } } @@ -292,6 +317,7 @@ export const getInitialDeckSetup: Selector< getLabwareEntities, getPipetteEntities, getModuleEntities, + getAdditionalEquipment, _getInitialDeckSetup ) // Special version of `getLabwareEntities` selector for use in step-forms reducers @@ -302,6 +328,7 @@ export const _getInitialDeckSetupRootState: ( _getLabwareEntitiesRootState, _getPipetteEntitiesRootState, _getModuleEntitiesRootState, + _getAdditionalEquipmentRootState, _getInitialDeckSetup ) export const getPermittedTipracks: Selector< @@ -595,18 +622,21 @@ export const getInvariantContext: Selector< getLabwareEntities, getModuleEntities, getPipetteEntities, + getAdditionalEquipmentEntities, featureFlagSelectors.getDisableModuleRestrictions, featureFlagSelectors.getAllowAllTipracks, ( labwareEntities, moduleEntities, pipetteEntities, + additionalEquipmentEntities, disableModuleRestrictions, allowAllTipracks ) => ({ labwareEntities, moduleEntities, pipetteEntities, + additionalEquipmentEntities, config: { OT_PD_ALLOW_ALL_TIPRACKS: Boolean(allowAllTipracks), OT_PD_DISABLE_MODULE_RESTRICTIONS: Boolean(disableModuleRestrictions), diff --git a/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts b/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts index 9c7b01612d8..6e41f84a7b5 100644 --- a/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts +++ b/protocol-designer/src/step-forms/test/createPresavedStepForm.test.ts @@ -150,7 +150,7 @@ describe('createPresavedStepForm', () => { aspirate_wells: [], aspirate_wells_grouped: false, blowout_checkbox: false, - blowout_location: 'fixedTrash', + blowout_location: null, changeTip: 'always', dispense_airGap_checkbox: false, dispense_airGap_volume: '1', @@ -193,7 +193,7 @@ describe('createPresavedStepForm', () => { mix_wellOrder_first: 't2b', mix_wellOrder_second: 'l2r', blowout_checkbox: false, - blowout_location: 'fixedTrash', + blowout_location: null, changeTip: 'always', stepDetails: '', stepName: 'mix', diff --git a/protocol-designer/src/step-forms/types.ts b/protocol-designer/src/step-forms/types.ts index bcde8c8338b..2a7db89fa94 100644 --- a/protocol-designer/src/step-forms/types.ts +++ b/protocol-designer/src/step-forms/types.ts @@ -15,6 +15,7 @@ import { ModuleEntity, PipetteEntity, LabwareEntity, + AdditionalEquipmentEntity, } from '@opentrons/step-generation' export interface FormPipette { pipetteName: string | null | undefined @@ -93,6 +94,7 @@ export interface PipetteTemporalProperties { // which may change across time (eg moving a labware to another slot) export type LabwareOnDeck = LabwareEntity & LabwareTemporalProperties export type PipetteOnDeck = PipetteEntity & PipetteTemporalProperties +export type AdditionalEquipmentOnDeck = AdditionalEquipmentEntity // TODO: Ian 2019-11-08 make all values Maybe typed export type InitialDeckSetup = AllTemporalPropertiesForTimelineFrame @@ -107,4 +109,7 @@ export interface AllTemporalPropertiesForTimelineFrame { modules: { [moduleId: string]: ModuleOnDeck } + additionalEquipmentOnDeck: { + [additionalEquipmentId: string]: AdditionalEquipmentOnDeck + } } diff --git a/protocol-designer/src/step-forms/utils/index.ts b/protocol-designer/src/step-forms/utils/index.ts index 0a880f7f840..fd343120188 100644 --- a/protocol-designer/src/step-forms/utils/index.ts +++ b/protocol-designer/src/step-forms/utils/index.ts @@ -24,6 +24,7 @@ import { FormPipette, LabwareOnDeck as LabwareOnDeckType, } from '../types' +import { AdditionalEquipmentOnDeck } from '..' export { createPresavedStepForm } from './createPresavedStepForm' export function getIdsInRange( orderedIds: T[], @@ -113,8 +114,11 @@ export const getSlotIsEmpty = ( } else if (getSlotIdsBlockedBySpanning(initialDeckSetup).includes(slot)) { // if a slot is being blocked by a spanning labware/module (eg thermocycler), it's not empty return false + // don't allow duplicating into the trash slot. + // TODO(jr, 8/31/23): delete this when we support moveable trash + } else if (slot === '12' || slot === 'A3') { + return false } - // NOTE: should work for both deck slots and module slots return ( [ @@ -124,6 +128,10 @@ export const getSlotIsEmpty = ( ...values(initialDeckSetup.labware).filter( (labware: LabwareOnDeckType) => labware.slot === slot ), + ...values(initialDeckSetup.additionalEquipmentOnDeck).filter( + (additionalEquipment: AdditionalEquipmentOnDeck) => + additionalEquipment.location === slot + ), ].length === 0 ) } diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index ba75ab88974..8ffd1979b19 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -4,7 +4,6 @@ import { DEFAULT_WELL_ORDER_FIRST_OPTION, DEFAULT_WELL_ORDER_SECOND_OPTION, DEFAULT_DELAY_SECONDS, - FIXED_TRASH_ID, } from '../../constants' import { StepType, StepFieldName } from '../../form-types' export function getDefaultsForStepType( @@ -19,7 +18,7 @@ export function getDefaultsForStepType( mix_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, mix_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, blowout_checkbox: false, - blowout_location: FIXED_TRASH_ID, + blowout_location: null, // NOTE(IL, 2021-03-12): mix uses dispense for both asp + disp, unless its falsey. // For now, unlike the other mmFromBottom fields, it's initializing to a constant instead of // using null to represent default (because null becomes 1mm asp, 0.5mm dispense -- see #7470.) @@ -69,7 +68,7 @@ export function getDefaultsForStepType( disposalVolume_checkbox: false, disposalVolume_volume: null, blowout_checkbox: false, - blowout_location: FIXED_TRASH_ID, + blowout_location: null, preWetTip: false, aspirate_airGap_checkbox: false, aspirate_airGap_volume: null, diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts index 8399a5d4d0c..1223018bb23 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/test/moveLiquid.test.ts @@ -365,7 +365,7 @@ describe('disposal volume should update...', () => { describe('blowout location should reset via updatePatchBlowoutFields...', () => { const resetBlowoutLocation = { - blowout_location: 'fixedTrash', + blowout_location: null, } const testCases = [ diff --git a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts index 734c74ce0e9..6103bc75495 100644 --- a/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts +++ b/protocol-designer/src/steplist/formLevel/test/getDefaultsForStepType.test.ts @@ -4,7 +4,6 @@ import { DEFAULT_MM_FROM_BOTTOM_DISPENSE, DEFAULT_WELL_ORDER_FIRST_OPTION, DEFAULT_WELL_ORDER_SECOND_OPTION, - FIXED_TRASH_ID, } from '../../../constants' import { getDefaultsForStepType } from '..' @@ -49,7 +48,7 @@ describe('getDefaultsForStepType', () => { disposalVolume_volume: null, blowout_checkbox: false, - blowout_location: FIXED_TRASH_ID, + blowout_location: null, preWetTip: false, aspirate_airGap_checkbox: false, @@ -78,7 +77,7 @@ describe('getDefaultsForStepType', () => { mix_wellOrder_first: DEFAULT_WELL_ORDER_FIRST_OPTION, mix_wellOrder_second: DEFAULT_WELL_ORDER_SECOND_OPTION, blowout_checkbox: false, - blowout_location: FIXED_TRASH_ID, + blowout_location: null, mix_mmFromBottom: DEFAULT_MM_FROM_BOTTOM_DISPENSE, mix_touchTip_mmFromBottom: null, mix_touchTip_checkbox: false, diff --git a/protocol-designer/src/top-selectors/labware-locations/index.ts b/protocol-designer/src/top-selectors/labware-locations/index.ts index 723a72c14a4..f9e64b8c9fb 100644 --- a/protocol-designer/src/top-selectors/labware-locations/index.ts +++ b/protocol-designer/src/top-selectors/labware-locations/index.ts @@ -5,12 +5,14 @@ import { getDeckDefFromRobotType, getModuleDisplayName, FLEX_ROBOT_TYPE, + WASTE_CHUTE_SLOT, } from '@opentrons/shared-data' import { START_TERMINAL_ITEM_ID, END_TERMINAL_ITEM_ID, PRESAVED_STEP_ID, } from '../../steplist' +import { getHasWasteChute } from '../../components/labware' import { AllTemporalPropertiesForTimelineFrame, selectors as stepFormSelectors, @@ -23,6 +25,7 @@ import { getLabwareEntities, getModuleEntities, getPipetteEntities, + getAdditionalEquipmentEntities, } from '../../step-forms/selectors' import { getIsAdapter } from '../../utils' import type { RobotState } from '@opentrons/step-generation' @@ -87,6 +90,7 @@ export const getRobotStateAtActiveItem: Selector = createSele } ) +// TODO(jr, 9/20/23): we should test this util since it does a lot. export const getUnocuppiedLabwareLocationOptions: Selector< Option[] | null > = createSelector( @@ -94,10 +98,19 @@ export const getUnocuppiedLabwareLocationOptions: Selector< getModuleEntities, getRobotType, getLabwareEntities, - (robotState, moduleEntities, robotType, labwareEntities) => { + getAdditionalEquipmentEntities, + ( + robotState, + moduleEntities, + robotType, + labwareEntities, + additionalEquipmentEntities + ) => { const deckDef = getDeckDefFromRobotType(robotType) const trashSlot = robotType === FLEX_ROBOT_TYPE ? 'A3' : '12' const allSlotIds = deckDef.locations.orderedSlots.map(slot => slot.id) + const hasWasteChute = getHasWasteChute(additionalEquipmentEntities) + if (robotState == null) return null const { modules, labware } = robotState @@ -122,6 +135,8 @@ export const getUnocuppiedLabwareLocationOptions: Selector< const modIdWithAdapter = Object.keys(modules).find( modId => modId === labwareOnDeck.slot ) + const adapterDisplayName = + labwareEntities[labwareId].def.metadata.displayName const modSlot = modIdWithAdapter != null ? modules[modIdWithAdapter].slot : null const isAdapter = getIsAdapter(labwareId, labwareEntities) @@ -130,7 +145,7 @@ export const getUnocuppiedLabwareLocationOptions: Selector< ? [ ...acc, { - name: `Adapter on top of ${ + name: `${adapterDisplayName} on top of ${ modIdWithAdapter != null ? getModuleDisplayName( moduleEntities[modIdWithAdapter].model @@ -176,30 +191,30 @@ export const getUnocuppiedLabwareLocationOptions: Selector< !Object.values(labware) .map(lw => lw.slot) .includes(slotId) && - slotId !== trashSlot + slotId !== trashSlot && + (hasWasteChute ? slotId !== WASTE_CHUTE_SLOT : true) ) .map(slotId => ({ name: slotId, value: slotId })) - - const offDeckSlot = Object.values(labware) - .map(lw => lw.slot) - .find(slot => slot === 'offDeck') - const offDeck = - offDeckSlot !== 'offDeck' ? { name: 'Off Deck', value: 'offDeck' } : null - - if (offDeck == null) { - return [ - ...unoccupiedAdapterOptions, - ...unoccupiedModuleOptions, - ...unoccupiedSlotOptions, - ] - } else { - return [ - ...unoccupiedAdapterOptions, - ...unoccupiedModuleOptions, - ...unoccupiedSlotOptions, - offDeck, - ] + const offDeck = { name: 'Off-deck', value: 'offDeck' } + const wasteChuteSlot = { + name: 'Waste Chute in D3', + value: WASTE_CHUTE_SLOT, } + + return hasWasteChute + ? [ + wasteChuteSlot, + ...unoccupiedAdapterOptions, + ...unoccupiedModuleOptions, + ...unoccupiedSlotOptions, + offDeck, + ] + : [ + ...unoccupiedAdapterOptions, + ...unoccupiedModuleOptions, + ...unoccupiedSlotOptions, + offDeck, + ] } ) @@ -208,8 +223,29 @@ export const getDeckSetupForActiveItem: Selector { - if (robotState == null) return { pipettes: {}, labware: {}, modules: {} } + getAdditionalEquipmentEntities, + ( + robotState, + pipetteEntities, + moduleEntities, + labwareEntities, + additionalEquipmentEntities + ) => { + if (robotState == null) + return { + pipettes: {}, + labware: {}, + modules: {}, + additionalEquipmentOnDeck: {}, + } + + // only allow wasteChute since its the only additional equipment that is like an entity + // that deck setup needs to be aware of + const filteredAdditionalEquipment = Object.fromEntries( + Object.entries(additionalEquipmentEntities).filter( + ([_, entity]) => entity.name === 'wasteChute' + ) + ) return { pipettes: mapValues(pipetteEntities, (pipEntity, pipId) => ({ ...pipEntity, @@ -223,6 +259,12 @@ export const getDeckSetupForActiveItem: Selector ({ + ...additionalEquipmentEntity, + }) + ), } } ) diff --git a/protocol-designer/src/tutorial/index.ts b/protocol-designer/src/tutorial/index.ts index 03c4410e4b1..e5a1a71d97a 100644 --- a/protocol-designer/src/tutorial/index.ts +++ b/protocol-designer/src/tutorial/index.ts @@ -7,6 +7,7 @@ type HintKey = // normal hints | 'module_without_labware' | 'thermocycler_lid_passive_cooling' | 'protocol_can_enter_batch_edit' + | 'waste_chute_warning' // blocking hints | 'custom_labware_with_modules' | 'export_v7_protocol_7_0' diff --git a/protocol-designer/src/tutorial/selectors.ts b/protocol-designer/src/tutorial/selectors.ts index 299738179f5..ec198989d88 100644 --- a/protocol-designer/src/tutorial/selectors.ts +++ b/protocol-designer/src/tutorial/selectors.ts @@ -1,10 +1,18 @@ import { createSelector } from 'reselect' -import { THERMOCYCLER_MODULE_TYPE } from '@opentrons/shared-data' +import { + THERMOCYCLER_MODULE_TYPE, + WASTE_CHUTE_SLOT, +} from '@opentrons/shared-data' import { timelineFrameBeforeActiveItem } from '../top-selectors/timelineFrames' -import { getUnsavedForm, getOrderedStepIds } from '../step-forms/selectors' +import { + getUnsavedForm, + getOrderedStepIds, + getAdditionalEquipmentEntities, +} from '../step-forms/selectors' import isEmpty from 'lodash/isEmpty' import { BaseState, Selector } from '../types' import { HintKey } from '.' +import { getHasWasteChute } from '../components/labware' const rootSelector = (state: BaseState): BaseState['tutorial'] => state.tutorial @@ -64,3 +72,23 @@ export const shouldShowBatchEditHint: Selector = createSelector( getOrderedStepIds, orderedStepIds => orderedStepIds.length >= 1 ) +export const shouldShowWasteChuteHint: Selector = createSelector( + timelineFrameBeforeActiveItem, + getUnsavedForm, + getAdditionalEquipmentEntities, + (prevTimelineFrame, unsavedForm, additionalEquipmentEntities) => { + const hasWasteChute = getHasWasteChute(additionalEquipmentEntities) + if (unsavedForm?.stepType !== 'moveLabware' || !hasWasteChute) { + return false + } + if (prevTimelineFrame == null) { + return false + } + const { newLocation } = unsavedForm + if (newLocation === WASTE_CHUTE_SLOT) { + return true + } + + return false + } +) diff --git a/protocol-designer/src/ui/labware/__tests__/selectors.test.ts b/protocol-designer/src/ui/labware/__tests__/selectors.test.ts index 25e246c6d12..c3388d91825 100644 --- a/protocol-designer/src/ui/labware/__tests__/selectors.test.ts +++ b/protocol-designer/src/ui/labware/__tests__/selectors.test.ts @@ -1,4 +1,6 @@ import { + HEATERSHAKER_MODULE_TYPE, + HEATERSHAKER_MODULE_V1, MAGNETIC_MODULE_TYPE, MAGNETIC_MODULE_V1, TEMPERATURE_MODULE_TYPE, @@ -6,7 +8,7 @@ import { THERMOCYCLER_MODULE_TYPE, THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' -import { SPAN7_8_10_11_SLOT, FIXED_TRASH_ID } from '../../../constants' +import { SPAN7_8_10_11_SLOT } from '../../../constants' import { getDisposalLabwareOptions, getLabwareOptions, @@ -30,38 +32,36 @@ describe('labware selectors', () => { let trash: LabwareEntities let otherLabware: LabwareEntities + const mockTrash = 'mockTrash' + const mockTrash2 = 'mockTrash2' beforeEach(() => { trash = { - // @ts-expect-error(sa, 2021-6-15): missing id and labwareDefURI - fixedTrash: { + [mockTrash]: { def: { ...fixtureTrash }, - }, + } as any, } tipracks = { - // @ts-expect-error(sa, 2021-6-15): missing labwareDefURI tiprack100Id: { id: 'tiprack100Id', def: { ...fixtureTiprack1000ul }, - }, - // @ts-expect-error(sa, 2021-6-15): missing labwareDefURI + } as any, tiprack10Id: { id: 'tiprack10Id', def: { ...fixtureTiprack10ul }, - }, + } as any, } otherLabware = { - // @ts-expect-error(sa, 2021-6-15): missing labwareDefURI wellPlateId: { id: 'wellPlateId', def: { ...fixture96Plate }, - }, + } as any, } names = { - fixedTrash: 'Trash', - fixedTrash2: 'Trash', + [mockTrash]: 'Trash', + [mockTrash2]: 'Trash', tiprack100Id: 'Opentrons Tip Rack 1000 µL', tiprack10Id: 'Opentrons Tip Rack 10 µL', @@ -95,11 +95,11 @@ describe('labware selectors', () => { expect( // @ts-expect-error(sa, 2021-6-15): resultFunc getDisposalLabwareOptions.resultFunc(labwareEntities, names) - ).toEqual([{ name: 'Trash', value: 'fixedTrash' }]) + ).toEqual([{ name: 'Trash Bin', value: mockTrash }]) }) it('filters out labware that is NOT trash when multiple trash bins present', () => { const trash2 = { - fixedTrash2: { + mockTrash2: { def: { ...fixtureTrash }, }, } @@ -113,8 +113,8 @@ describe('labware selectors', () => { // @ts-expect-error(sa, 2021-6-15): resultFunc getDisposalLabwareOptions.resultFunc(labwareEntities, names) ).toEqual([ - { name: 'Trash', value: 'fixedTrash' }, - { name: 'Trash', value: 'fixedTrash2' }, + { name: 'Trash Bin', value: mockTrash }, + { name: 'Trash Bin', value: mockTrash2 }, ]) }) }) @@ -144,14 +144,20 @@ describe('labware selectors', () => { } expect( // @ts-expect-error(sa, 2021-6-15): resultFunc - getLabwareOptions.resultFunc(labwareEntities, names, initialDeckSetup) + getLabwareOptions.resultFunc( + labwareEntities, + names, + initialDeckSetup, + {}, + {} + ) ).toEqual([ { name: 'Source Plate', value: 'wellPlateId' }, - { name: 'Trash', value: 'fixedTrash' }, + { name: 'Trash', value: mockTrash }, ]) }) - it('should return labware options for move labware with tips and no trash', () => { + it('should return labware options for move labware with tips and trash', () => { const labwareEntities = { ...tipracks, ...trash, @@ -172,12 +178,14 @@ describe('labware selectors', () => { labwareEntities, names, initialDeckSetup, - presavedStepForm + presavedStepForm, + {} ) ).toEqual([ { name: 'Opentrons Tip Rack 10 µL', value: 'tiprack10Id' }, { name: 'Opentrons Tip Rack 1000 µL', value: 'tiprack100Id' }, { name: 'Source Plate', value: 'wellPlateId' }, + { name: 'Trash', value: mockTrash }, ]) }) @@ -197,6 +205,11 @@ describe('labware selectors', () => { id: 'tcPlateId', slot: 'thermocyclerId', // On thermocycler }, + hsPlateId: { + ...otherLabware.wellPlateId, + id: 'hsPlateId', + slot: 'heaterShakerId', // On heater-shaker + }, } const labwareEntities = { ...trash, ...labware } const initialDeckSetup = { @@ -224,6 +237,12 @@ describe('labware selectors', () => { model: THERMOCYCLER_MODULE_V1, slot: SPAN7_8_10_11_SLOT, }, + heaterShakerId: { + id: 'heaterShakerId', + type: HEATERSHAKER_MODULE_TYPE, + model: HEATERSHAKER_MODULE_V1, + slot: '6', + }, }, } @@ -232,6 +251,7 @@ describe('labware selectors', () => { wellPlateId: 'Well Plate', tempPlateId: 'Temp Plate', tcPlateId: 'TC Plate', + hsPlateId: 'HS Plate', } expect( @@ -239,29 +259,85 @@ describe('labware selectors', () => { getLabwareOptions.resultFunc( labwareEntities, nicknames, - initialDeckSetup + initialDeckSetup, + {}, + {} ) ).toEqual([ - { name: 'MAG Well Plate', value: 'wellPlateId' }, - { name: 'TEMP Temp Plate', value: 'tempPlateId' }, - { name: 'THERMO TC Plate', value: 'tcPlateId' }, - { name: 'Trash', value: 'fixedTrash' }, + { name: 'HS Plate in Heater-Shaker', value: 'hsPlateId' }, + { name: 'TC Plate in Thermocycler', value: 'tcPlateId' }, + { name: 'Temp Plate in Temperature Module', value: 'tempPlateId' }, + { name: 'Trash', value: mockTrash }, + { name: 'Well Plate in Magnetic Module', value: 'wellPlateId' }, + ]) + }) + + it('should return labware options with a labware moved off of the initial module slot', () => { + const labware = { + wellPlateId: { + ...otherLabware.wellPlateId, + slot: 'magModuleId', // On magnetic module + }, + } + const labwareEntities = { ...trash, ...labware } + const initialDeckSetup = { + pipettes: {}, + labware: { + ...trash, + ...labware, + }, + modules: { + magModuleId: { + id: 'magModuleId', + type: MAGNETIC_MODULE_TYPE, + model: MAGNETIC_MODULE_V1, + slot: '1', + }, + }, + } + + const nicknames: Record = { + ...names, + wellPlateId: 'Well Plate', + } + const mockId = 'mockId' + + const savedStep = { + [mockId]: { + stepType: 'moveLabware', + id: mockId, + labware: 'wellPlateId', + newLocation: '2', + }, + } + + expect( + // @ts-expect-error(sa, 2021-6-15): resultFunc + getLabwareOptions.resultFunc( + labwareEntities, + nicknames, + initialDeckSetup, + savedStep + ) + ).toEqual([ + { name: 'Trash', value: mockTrash }, + { name: 'Well Plate in Magnetic Module', value: 'wellPlateId' }, ]) }) }) describe('_sortLabwareDropdownOptions', () => { const trashOption = { - name: 'Some kinda fixed trash', - value: FIXED_TRASH_ID, + name: 'Trash Bin', + value: mockTrash, } const zzzPlateOption = { name: 'Zzz Plate', value: 'zzz' } const aaaPlateOption = { name: 'Aaa Plate', value: 'aaa' } it('should sort labware ids in alphabetical order but with fixed trash at the bottom', () => { const result = _sortLabwareDropdownOptions([ - trashOption, aaaPlateOption, zzzPlateOption, + trashOption, ]) expect(result).toEqual([aaaPlateOption, zzzPlateOption, trashOption]) }) diff --git a/protocol-designer/src/ui/labware/selectors.ts b/protocol-designer/src/ui/labware/selectors.ts index f481338f5a6..7ee927c1ff7 100644 --- a/protocol-designer/src/ui/labware/selectors.ts +++ b/protocol-designer/src/ui/labware/selectors.ts @@ -6,14 +6,18 @@ import { getLabwareDisplayName, getLabwareHasQuirk, } from '@opentrons/shared-data' -import { FIXED_TRASH_ID } from '../../constants' import { i18n } from '../../localization' import * as stepFormSelectors from '../../step-forms/selectors' import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' import { getModuleUnderLabware } from '../modules/utils' -import { Options } from '@opentrons/components' -import { LabwareEntity } from '@opentrons/step-generation' -import { Selector } from '../../types' +import { getLabwareOffDeck } from './utils' + +import type { Options } from '@opentrons/components' +import type { LabwareEntity } from '@opentrons/step-generation' +import type { Selector } from '../../types' + +const TRASH = 'Trash Bin' + export const getLabwareNicknamesById: Selector< Record > = createSelector( @@ -28,22 +32,29 @@ export const getLabwareNicknamesById: Selector< ) export const _sortLabwareDropdownOptions = (options: Options): Options => options.sort((a, b) => { - // special case for fixed trash (always at the bottom of the list) - if (a.value === FIXED_TRASH_ID) return 1 - if (b.value === FIXED_TRASH_ID) return -1 + // special case for trash (always at the bottom of the list) + if (a.name === TRASH) return 1 + if (b.name === TRASH) return -1 // sort by name everything else by name return a.name.localeCompare(b.name) }) -/** Returns options for labware dropdowns, excluding tiprack labware. - * Ordered by display name / nickname, but with fixed trash at the bottom. +/** Returns options for labware dropdowns. + * Ordered by display name / nickname, but with trash at the bottom. */ export const getLabwareOptions: Selector = createSelector( stepFormSelectors.getLabwareEntities, getLabwareNicknamesById, stepFormSelectors.getInitialDeckSetup, stepFormSelectors.getPresavedStepForm, - (labwareEntities, nicknamesById, initialDeckSetup, presavedStepForm) => { + stepFormSelectors.getSavedStepForms, + ( + labwareEntities, + nicknamesById, + initialDeckSetup, + presavedStepForm, + savedStepForms + ) => { const moveLabwarePresavedStep = presavedStepForm?.stepType === 'moveLabware' const options = reduce( labwareEntities, @@ -53,18 +64,34 @@ export const getLabwareOptions: Selector = createSelector( labwareId: string ): Options => { const isAdapter = labwareEntity.def.allowedRoles?.includes('adapter') + const isOffDeck = getLabwareOffDeck( + initialDeckSetup, + savedStepForms ?? {}, + labwareId + ) const isAdapterOrAluminumBlock = isAdapter || labwareEntity.def.metadata.displayCategory === 'aluminumBlock' - const moduleOnDeck = getModuleUnderLabware(initialDeckSetup, labwareId) - const prefix = moduleOnDeck - ? i18n.t( - `form.step_edit_form.field.moduleLabwarePrefix.${moduleOnDeck.type}` - ) - : null - const nickName = prefix - ? `${prefix} ${nicknamesById[labwareId]}` - : nicknamesById[labwareId] + const moduleOnDeck = getModuleUnderLabware( + initialDeckSetup, + savedStepForms ?? {}, + labwareId + ) + const module = + moduleOnDeck != null + ? i18n.t( + `form.step_edit_form.field.moduleLabwarePrefix.${moduleOnDeck.type}` + ) + : null + + let nickName = nicknamesById[labwareId] + if (module != null) { + nickName = `${nicknamesById[labwareId]} in ${module}` + } else if (isOffDeck) { + nickName = `Off-deck - ${nicknamesById[labwareId]}` + } else if (nickName === 'Opentrons Fixed Trash') { + nickName = TRASH + } if (!moveLabwarePresavedStep) { return getIsTiprack(labwareEntity.def) || isAdapter @@ -77,9 +104,8 @@ export const getLabwareOptions: Selector = createSelector( }, ] } else { - // TODO(jr, 7/17/23): filter out moving trash for now in MoveLabware step type - // remove this when we support other slots for trash - return nickName === 'Trash' || isAdapterOrAluminumBlock + // filter out moving trash for now in MoveLabware step type + return nickName === TRASH || isAdapterOrAluminumBlock ? acc : [ ...acc, @@ -96,11 +122,10 @@ export const getLabwareOptions: Selector = createSelector( } ) -/** Returns options for disposal (e.g. fixed trash and trash box) */ +/** Returns options for disposal (e.g. trash) */ export const getDisposalLabwareOptions: Selector = createSelector( stepFormSelectors.getLabwareEntities, - getLabwareNicknamesById, - (labwareEntities, names) => + labwareEntities => reduce( labwareEntities, (acc: Options, labware: LabwareEntity, labwareId): Options => @@ -108,7 +133,7 @@ export const getDisposalLabwareOptions: Selector = createSelector( ? [ ...acc, { - name: names[labwareId], + name: TRASH, value: labwareId, }, ] diff --git a/protocol-designer/src/ui/labware/utils.ts b/protocol-designer/src/ui/labware/utils.ts new file mode 100644 index 00000000000..ea2cc98b8b7 --- /dev/null +++ b/protocol-designer/src/ui/labware/utils.ts @@ -0,0 +1,26 @@ +import type { InitialDeckSetup, SavedStepFormState } from '../../step-forms' + +export function getLabwareOffDeck( + initialDeckSetup: InitialDeckSetup, + savedStepFormState: SavedStepFormState, + labwareId: string +): boolean { + // latest moveLabware step related to labwareId + const moveLabwareStep = Object.values(savedStepFormState) + .filter( + state => + state.stepType === 'moveLabware' && + labwareId != null && + state.labware === labwareId + ) + .reverse()[0] + + if (moveLabwareStep?.newLocation === 'offDeck') { + return true + } else if ( + moveLabwareStep == null && + initialDeckSetup.labware[labwareId]?.slot === 'offDeck' + ) { + return true + } else return false +} diff --git a/protocol-designer/src/ui/modules/utils.ts b/protocol-designer/src/ui/modules/utils.ts index 803dd5bf07d..84571c88a42 100644 --- a/protocol-designer/src/ui/modules/utils.ts +++ b/protocol-designer/src/ui/modules/utils.ts @@ -6,11 +6,13 @@ import { ModuleType, } from '@opentrons/shared-data' import { Options } from '@opentrons/components' -import { +import type { ModuleOnDeck, LabwareOnDeck, InitialDeckSetup, } from '../../step-forms/types' +import type { SavedStepFormState } from '../../step-forms' + export function getModuleOnDeckByType( initialDeckSetup: InitialDeckSetup, type: ModuleType @@ -29,11 +31,25 @@ export function getLabwareOnModule( } export function getModuleUnderLabware( initialDeckSetup: InitialDeckSetup, + savedStepFormState: SavedStepFormState, labwareId: string ): ModuleOnDeck | null | undefined { + // latest moveLabware step related to labwareId + const moveLabwareStep = Object.values(savedStepFormState) + .filter( + state => + state.stepType === 'moveLabware' && + labwareId != null && + state.labware === labwareId + ) + .reverse()[0] + const newLocation = moveLabwareStep?.newLocation + return values(initialDeckSetup.modules).find( (moduleOnDeck: ModuleOnDeck) => - initialDeckSetup.labware[labwareId]?.slot === moduleOnDeck.id + (newLocation != null + ? newLocation + : initialDeckSetup.labware[labwareId]?.slot) === moduleOnDeck.id ) } export function getModuleLabwareOptions( @@ -44,21 +60,21 @@ export function getModuleLabwareOptions( const moduleOnDeck = getModuleOnDeckByType(initialDeckSetup, type) const labware = moduleOnDeck && getLabwareOnModule(initialDeckSetup, moduleOnDeck.id) - const prefix = i18n.t(`form.step_edit_form.field.moduleLabwarePrefix.${type}`) + const module = i18n.t(`form.step_edit_form.field.moduleLabwarePrefix.${type}`) let options: Options = [] if (moduleOnDeck) { if (labware) { options = [ { - name: `${prefix} ${nicknamesById[labware.id]}`, + name: `${nicknamesById[labware.id]} in ${module}`, value: moduleOnDeck.id, }, ] } else { options = [ { - name: `${prefix} No labware on module`, + name: `${module} No labware on module`, value: moduleOnDeck.id, }, ] diff --git a/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts b/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts index 878bfe40855..a6f4f14244d 100644 --- a/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts +++ b/protocol-designer/src/ui/steps/actions/__tests__/actions.test.ts @@ -474,6 +474,7 @@ describe('steps actions', () => { stepType: 'temperature', setTemperature: 'true', targetTemperature: 10, + moduleId: 'mockTemp', } as any) mockGetUnsavedFormIsPristineSetTempForm.mockReturnValue(true) mockGetRobotStateTimeline.mockReturnValue(mockRobotStateTimeline) @@ -490,6 +491,7 @@ describe('steps actions', () => { setTemperature: 'true', stepType: 'temperature', targetTemperature: 10, + moduleId: 'mockTemp', }, type: 'SAVE_STEP_FORM', }, @@ -555,6 +557,14 @@ describe('steps actions', () => { }, type: 'CHANGE_FORM_INPUT', }, + { + payload: { + update: { + moduleId: 'mockTemp', + }, + }, + type: 'CHANGE_FORM_INPUT', + }, { payload: { update: { @@ -568,6 +578,7 @@ describe('steps actions', () => { setTemperature: 'true', stepType: 'temperature', targetTemperature: 10, + moduleId: 'mockTemp', }, type: 'SAVE_STEP_FORM', }, diff --git a/protocol-designer/src/ui/steps/actions/thunks/index.ts b/protocol-designer/src/ui/steps/actions/thunks/index.ts index 6147668eeeb..a41fb5faa82 100644 --- a/protocol-designer/src/ui/steps/actions/thunks/index.ts +++ b/protocol-designer/src/ui/steps/actions/thunks/index.ts @@ -182,6 +182,10 @@ export const saveStepForm: () => ThunkAction = () => ( dispatch(tutorialActions.addHint('protocol_can_enter_batch_edit')) } + if (tutorialSelectors.shouldShowWasteChuteHint(initialState)) { + dispatch(tutorialActions.addHint('waste_chute_warning')) + } + // save the form dispatch(_saveStepForm(unsavedForm)) } @@ -242,6 +246,14 @@ export const saveSetTempFormWithAddedPauseUntilTemp: () => ThunkAction = () }, }) ) + const tempertureModuleId = unsavedSetTemperatureForm?.moduleId + dispatch( + changeFormInput({ + update: { + moduleId: tempertureModuleId, + }, + }) + ) dispatch( changeFormInput({ update: { diff --git a/protocol-designer/src/ui/steps/selectors.ts b/protocol-designer/src/ui/steps/selectors.ts index 8c4935eecad..dd520cf58d3 100644 --- a/protocol-designer/src/ui/steps/selectors.ts +++ b/protocol-designer/src/ui/steps/selectors.ts @@ -143,6 +143,11 @@ export const getHoveredStepLabware: Selector = createSelector( return labware ? [labware.id] : [] } + if (stepArgs.commandCreatorFnName === 'moveLabware') { + const src = stepArgs.labware + return [src] + } + // step types that have no labware that gets highlighted if (!(stepArgs.commandCreatorFnName === 'delay')) { // TODO Ian 2018-05-08 use assert here diff --git a/protocol-designer/src/ui/steps/test/selectors.test.ts b/protocol-designer/src/ui/steps/test/selectors.test.ts index f3292dcf15a..adb51fa89fc 100644 --- a/protocol-designer/src/ui/steps/test/selectors.test.ts +++ b/protocol-designer/src/ui/steps/test/selectors.test.ts @@ -40,6 +40,7 @@ function createArgsForStepId( const hoveredStepId = 'hoveredStepId' const labware = 'well plate' const mixCommand = 'mix' +const moveLabwareCommand = 'moveLabware' describe('getHoveredStepLabware', () => { let initialDeckState: any beforeEach(() => { @@ -132,6 +133,22 @@ describe('getHoveredStepLabware', () => { expect(result).toEqual([labware]) }) + it('correct labware is returned when command is moveLabware', () => { + const stepArgs = { + commandCreatorFnName: moveLabwareCommand, + labware, + } + const argsByStepId = createArgsForStepId(hoveredStepId, stepArgs) + // @ts-expect-error(sa, 2021-6-15): resultFunc not part of Selector type + const result = getHoveredStepLabware.resultFunc( + argsByStepId, + hoveredStepId, + initialDeckState + ) + + expect(result).toEqual([labware]) + }) + describe('modules', () => { const type = TEMPERATURE_MODULE_TYPE const setTempCommand = 'setTemperature' diff --git a/protocol-designer/src/utils/index.ts b/protocol-designer/src/utils/index.ts index ac7ebc6a788..5598d7478f4 100644 --- a/protocol-designer/src/utils/index.ts +++ b/protocol-designer/src/utils/index.ts @@ -111,8 +111,7 @@ export const getIsAdapter = ( labwareId: string, labwareEntities: LabwareEntities ): boolean => { - if (labwareEntities[labwareId]) return false - + if (labwareEntities[labwareId] == null) return false return ( labwareEntities[labwareId].def.allowedRoles?.includes('adapter') ?? false ) diff --git a/protocol-designer/src/utils/labwareModuleCompatibility.ts b/protocol-designer/src/utils/labwareModuleCompatibility.ts index e2702072b4b..eb4dd843ff8 100644 --- a/protocol-designer/src/utils/labwareModuleCompatibility.ts +++ b/protocol-designer/src/utils/labwareModuleCompatibility.ts @@ -40,6 +40,7 @@ export const COMPATIBLE_LABWARE_ALLOWLIST_BY_MODULE_TYPE: Record< 'opentrons_24_aluminumblock_nest_2ml_snapcap', 'opentrons_24_aluminumblock_nest_0.5ml_screwcap', 'opentrons_96_well_aluminum_block', + 'opentrons_aluminum_flat_bottom_plate', ], [MAGNETIC_MODULE_TYPE]: [ 'biorad_96_wellplate_200ul_pcr', @@ -82,25 +83,41 @@ const FLAT_BOTTOM_ADAPTER_LOADNAME = 'opentrons_96_flat_bottom_adapter' const PCR_ADAPTER_LOADNAME = 'opentrons_96_pcr_adapter' const UNIVERSAL_FLAT_ADAPTER_LOADNAME = 'opentrons_universal_flat_adapter' const ALUMINUM_BLOCK_96_LOADNAME = 'opentrons_96_well_aluminum_block' +const ALUMINUM_FLAT_BOTTOM_PLATE = 'opentrons_aluminum_flat_bottom_plate' export const COMPATIBLE_LABWARE_ALLOWLIST_FOR_ADAPTER: Record< string, string[] > = { - [DEEP_WELL_ADAPTER_LOADNAME]: [ - 'opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2', - ], + [DEEP_WELL_ADAPTER_LOADNAME]: ['opentrons/nest_96_wellplate_2ml_deep/2'], [FLAT_BOTTOM_ADAPTER_LOADNAME]: ['opentrons/nest_96_wellplate_200ul_flat/2'], [PCR_ADAPTER_LOADNAME]: [ 'opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2', ], [UNIVERSAL_FLAT_ADAPTER_LOADNAME]: [ 'opentrons/corning_384_wellplate_112ul_flat/2', + 'opentrons/corning_96_wellplate_360ul_flat/2', + // TODO(jr, 9/18/23): comment this out for now until these labwares are compatible + // with this adapter from the API side + // 'opentrons/corning_48_wellplate_1.6ml_flat/2', + // 'opentrons/corning_24_wellplate_3.4ml_flat/2', + // 'opentrons/corning_12_wellplate_6.9ml_flat/2', + // 'opentrons/corning_6_wellplate_16.8ml_flat/2', + // 'opentrons/nest_96_wellplate_200ul_flat/2', ], [ALUMINUM_BLOCK_96_LOADNAME]: [ 'opentrons/biorad_96_wellplate_200ul_pcr/2', 'opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2', ], + [ALUMINUM_FLAT_BOTTOM_PLATE]: [ + 'opentrons/corning_384_wellplate_112ul_flat/2', + 'opentrons/corning_96_wellplate_360ul_flat/2', + 'opentrons/corning_48_wellplate_1.6ml_flat/2', + 'opentrons/corning_24_wellplate_3.4ml_flat/2', + 'opentrons/corning_12_wellplate_6.9ml_flat/2', + 'opentrons/corning_6_wellplate_16.8ml_flat/2', + 'opentrons/nest_96_wellplate_200ul_flat/2', + ], } export const getLabwareCompatibleWithAdapter = ( @@ -125,6 +142,16 @@ export const getAdapterLabwareIsAMatch = ( const loadName = Object.values(allLabware).find(lab => lab.id === labwareId) ?.def.parameters.loadName + const flatBottomLabwares = [ + 'corning_384_wellplate_112ul_flat', + 'corning_96_wellplate_360ul_flat', + 'corning_6_wellplate_16.8ml_flat', + 'corning_384_wellplate_112ul_flat', + 'corning_96_wellplate_360ul_flat', + 'corning_6_wellplate_16.8ml_flat', + 'nest_96_wellplate_200ul_flat', + ] + const deepWellPair = loadName === DEEP_WELL_ADAPTER_LOADNAME && draggedLabwareLoadname === 'nest_96_wellplate_2ml_deep' @@ -136,18 +163,23 @@ export const getAdapterLabwareIsAMatch = ( draggedLabwareLoadname === 'nest_96_wellplate_100ul_pcr_full_skirt' const universalPair = loadName === UNIVERSAL_FLAT_ADAPTER_LOADNAME && - draggedLabwareLoadname === 'corning_384_wellplate_112ul_flat' + (draggedLabwareLoadname === 'corning_384_wellplate_112ul_flat' || + draggedLabwareLoadname === 'corning_96_wellplate_360ul_flat') const aluminumBlock96Pairs = loadName === ALUMINUM_BLOCK_96_LOADNAME && (draggedLabwareLoadname === 'biorad_96_wellplate_200ul_pcr' || draggedLabwareLoadname === 'nest_96_wellplate_100ul_pcr_full_skirt') + const aluminumFlatBottomPlatePairs = + loadName === ALUMINUM_FLAT_BOTTOM_PLATE && + flatBottomLabwares.includes(draggedLabwareLoadname) if ( deepWellPair || flatBottomPair || pcrPair || universalPair || - aluminumBlock96Pairs + aluminumBlock96Pairs || + aluminumFlatBottomPlatePairs ) { return true } else { diff --git a/react-api-client/src/deck_configuration/index.ts b/react-api-client/src/deck_configuration/index.ts new file mode 100644 index 00000000000..063a5b0fe82 --- /dev/null +++ b/react-api-client/src/deck_configuration/index.ts @@ -0,0 +1,2 @@ +export { useDeckConfigurationQuery } from './useDeckConfigurationQuery' +export { useUpdateDeckConfigurationMutation } from './useUpdateDeckConfigurationMutation' diff --git a/react-api-client/src/deck_configuration/useDeckConfigurationQuery.ts b/react-api-client/src/deck_configuration/useDeckConfigurationQuery.ts new file mode 100644 index 00000000000..16a572a9667 --- /dev/null +++ b/react-api-client/src/deck_configuration/useDeckConfigurationQuery.ts @@ -0,0 +1,19 @@ +import { useQuery } from 'react-query' +import { getDeckConfiguration } from '@opentrons/api-client' +import { useHost } from '../api' +import type { UseQueryResult, UseQueryOptions } from 'react-query' +import type { DeckConfiguration, HostConfig } from '@opentrons/api-client' + +export function useDeckConfigurationQuery( + options: UseQueryOptions = {} +): UseQueryResult { + const host = useHost() + const query = useQuery( + [host, 'deck_configuration'], + () => + getDeckConfiguration(host as HostConfig).then(response => response.data), + { enabled: host !== null, ...options } + ) + + return query +} diff --git a/react-api-client/src/deck_configuration/useUpdateDeckConfigurationMutation.ts b/react-api-client/src/deck_configuration/useUpdateDeckConfigurationMutation.ts new file mode 100644 index 00000000000..b4d74ebc188 --- /dev/null +++ b/react-api-client/src/deck_configuration/useUpdateDeckConfigurationMutation.ts @@ -0,0 +1,61 @@ +import { + UseMutationResult, + UseMutationOptions, + useMutation, + UseMutateFunction, + useQueryClient, +} from 'react-query' + +import { updateDeckConfiguration } from '@opentrons/api-client' + +import { useHost } from '../api' + +import type { AxiosError } from 'axios' +import type { ErrorResponse, Fixture, HostConfig } from '@opentrons/api-client' + +export type UseUpdateDeckConfigurationMutationResult = UseMutationResult< + Omit, + AxiosError, + Omit +> & { + updateDeckConfiguration: UseMutateFunction< + Omit, + AxiosError, + Omit + > +} + +export type UseUpdateDeckConfigurationMutationOptions = UseMutationOptions< + Omit, + AxiosError, + Omit +> + +export function useUpdateDeckConfigurationMutation( + options: UseUpdateDeckConfigurationMutationOptions = {} +): UseUpdateDeckConfigurationMutationResult { + const host = useHost() + const queryClient = useQueryClient() + + const mutation = useMutation< + Omit, + AxiosError, + Omit + >( + [host, 'deck_configuration'], + (fixture: Omit) => + updateDeckConfiguration(host as HostConfig, fixture).then(response => { + queryClient + .invalidateQueries([host, 'deck_configuration']) + .catch((e: Error) => { + throw e + }) + return response.data + }), + options + ) + return { + ...mutation, + updateDeckConfiguration: mutation.mutate, + } +} diff --git a/react-api-client/src/index.ts b/react-api-client/src/index.ts index 0dfa409fc07..4fd96a22be0 100644 --- a/react-api-client/src/index.ts +++ b/react-api-client/src/index.ts @@ -1,6 +1,7 @@ // react api client entry point export * from './api' export * from './calibration' +export * from './deck_configuration' export * from './health' export * from './instruments' export * from './maintenance_runs' diff --git a/robot-server/robot_server/errors/exception_handlers.py b/robot-server/robot_server/errors/exception_handlers.py index 2e21f1cb8d6..821c9514c23 100644 --- a/robot-server/robot_server/errors/exception_handlers.py +++ b/robot-server/robot_server/errors/exception_handlers.py @@ -8,6 +8,9 @@ from typing import Any, Callable, Coroutine, Dict, Optional, Sequence, Type, Union from opentrons_shared_data.errors import ErrorCodes, EnumeratedError, PythonException +from opentrons_shared_data.errors.exceptions import ( + FirmwareUpdateRequiredError as HWFirmwareUpdateRequired, +) from robot_server.versioning import ( API_VERSION, @@ -23,10 +26,6 @@ FirmwareUpdateRequired, ) -from opentrons.hardware_control.errors import ( - FirmwareUpdateRequired as HWFirmwareUpdateRequired, -) - from .error_responses import ( ApiError, ErrorSource, diff --git a/robot-server/robot_server/service/legacy/models/logs.py b/robot-server/robot_server/service/legacy/models/logs.py index cda5584abd1..b4dd56edbe2 100644 --- a/robot-server/robot_server/service/legacy/models/logs.py +++ b/robot-server/robot_server/service/legacy/models/logs.py @@ -8,7 +8,7 @@ class LogIdentifier(str, Enum): serial = "serial.log" server = "server.log" api_server = "combined_api_server.log" - odd = "touchscreen.log" + touchscreen = "touchscreen.log" class LogFormat(str, Enum): diff --git a/robot-server/robot_server/service/legacy/routers/logs.py b/robot-server/robot_server/service/legacy/routers/logs.py index e76775e7c6d..aca477adadb 100644 --- a/robot-server/robot_server/service/legacy/routers/logs.py +++ b/robot-server/robot_server/service/legacy/routers/logs.py @@ -11,7 +11,7 @@ LogIdentifier.serial: "opentrons-api-serial", LogIdentifier.server: "uvicorn", LogIdentifier.api_server: "opentrons-robot-server", - LogIdentifier.odd: "opentrons-robot-app", + LogIdentifier.touchscreen: "opentrons-robot-app", } diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml index dd73ae706bf..29377c8b9fe 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml @@ -56,9 +56,14 @@ stages: status: failed errors: - id: !anystr - errorType: PythonException createdAt: !anystr - detail: 'opentrons.hardware_control.errors.NoTipAttachedError: Cannot perform DROPTIP without a tip attached' + errorType: 'UnexpectedTipRemovalError' + detail: 'Cannot perform DROPTIP without a tip attached.' + errorInfo: + mount: 'LEFT' + pipette_name: 'p10_single' + errorCode: '3005' + wrappedErrors: [] - name: Verify commands contain the expected results request: url: '{ot2_server_base_url}/runs/{run_id}/commands' @@ -116,12 +121,14 @@ stages: status: failed error: id: !anystr - errorType: PythonException createdAt: !anystr - detail: 'opentrons.hardware_control.errors.NoTipAttachedError: Cannot perform DROPTIP without a tip attached' - errorCode: '4000' - errorInfo: !anydict - wrappedErrors: !anylist + errorType: 'UnexpectedTipRemovalError' + detail: 'Cannot perform DROPTIP without a tip attached.' + errorInfo: + mount: 'LEFT' + pipette_name: 'p10_single' + errorCode: '3005' + wrappedErrors: [] params: pipetteId: pipetteId labwareId: tipRackId diff --git a/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml index 10d71ae5121..273274b1a54 100644 --- a/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml @@ -58,8 +58,9 @@ stages: - id: !anystr errorType: ExceptionInProtocolError createdAt: !anystr - detail: 'NoTipAttachedError [line 9]: Cannot perform DROPTIP without a tip attached' + detail: 'UnexpectedTipRemovalError [line 9]: Error 3005 UNEXPECTED_TIP_REMOVAL (UnexpectedTipRemovalError): Cannot perform DROPTIP without a tip attached.' errorCode: '4000' + wrappedErrors: !anylist - name: Verify commands contain the expected results request: @@ -116,8 +117,8 @@ stages: id: !anystr errorType: LegacyContextCommandError createdAt: !anystr - detail: 'Cannot perform DROPTIP without a tip attached' - errorCode: '4000' + detail: 'Cannot perform DROPTIP without a tip attached.' + errorCode: '3005' errorInfo: !anydict wrappedErrors: !anylist params: diff --git a/scripts/push.mk b/scripts/push.mk index d1fcf08cf83..4f114daa64e 100644 --- a/scripts/push.mk +++ b/scripts/push.mk @@ -1,7 +1,7 @@ # utilities for pushing things to robots in a reusable fashion find_robot=$(shell yarn run -s discovery find -i 169.254) -default_ssh_key := +default_ssh_key := ~/.ssh/robot_key default_ssh_opts := -o stricthostkeychecking=no -o userknownhostsfile=/dev/null version_dict=$(shell ssh $(call id-file-arg,$(2)) $(3) root@$(1) cat /etc/VERSION.json) is-ot3=$(findstring OT-3, $(version_dict)) diff --git a/shared-data/command/schemas/7.json b/shared-data/command/schemas/7.json index ea500fc0dc4..30fe52f96bd 100644 --- a/shared-data/command/schemas/7.json +++ b/shared-data/command/schemas/7.json @@ -1307,15 +1307,10 @@ "type": "object", "properties": { "pipetteName": { - "title": "Pipettename", "description": "The load name of the pipette to be required.", - "anyOf": [ + "allOf": [ { "$ref": "#/definitions/PipetteNameType" - }, - { - "enum": ["p1000_96"], - "type": "string" } ] }, diff --git a/shared-data/errors/definitions/1/errors.json b/shared-data/errors/definitions/1/errors.json index 9f158777697..8c92d9a0486 100644 --- a/shared-data/errors/definitions/1/errors.json +++ b/shared-data/errors/definitions/1/errors.json @@ -102,6 +102,18 @@ "detail": "Misaligned Gantry", "category": "roboticsControlError" }, + "2012": { + "detail": "Unmatched tip presence states", + "category": "roboticsControlError" + }, + "2013": { + "detail": "Position Unknown", + "category": "roboticsControlError" + }, + "2014": { + "detail": "Execution Cancelled", + "category": "roboticsControlError" + }, "3000": { "detail": "A robotics interaction error occurred.", "category": "roboticsInteractionError" diff --git a/shared-data/js/constants.ts b/shared-data/js/constants.ts index b4e556d6c8f..3ccdc752b38 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -182,3 +182,9 @@ export const TC_MODULE_LOCATION_OT2: '7,8,10,11' = '7,8,10,11' export const TC_MODULE_LOCATION_OT3: 'A1+B1' = 'A1+B1' export const WEIGHT_OF_96_CHANNEL: '~10kg' = '~10kg' + +export const WASTE_CHUTE_SLOT: 'D3' = 'D3' + +export const STAGING_AREA_LOAD_NAME = 'stagingArea' +export const STANDARD_SLOT_LOAD_NAME = 'standardSlot' +export const WASTE_CHUTE_LOAD_NAME = 'wasteChute' diff --git a/shared-data/js/fixtures.ts b/shared-data/js/fixtures.ts new file mode 100644 index 00000000000..560a6141df0 --- /dev/null +++ b/shared-data/js/fixtures.ts @@ -0,0 +1,12 @@ +import { STAGING_AREA_LOAD_NAME, WASTE_CHUTE_LOAD_NAME } from './constants' +import type { FixtureLoadName } from './types' + +export function getFixtureDisplayName(loadName: FixtureLoadName): string { + if (loadName === STAGING_AREA_LOAD_NAME) { + return 'Staging Area Slot' + } else if (loadName === WASTE_CHUTE_LOAD_NAME) { + return 'Waste Chute' + } else { + return 'Slot' + } +} diff --git a/shared-data/js/index.ts b/shared-data/js/index.ts index 13943131aba..37a32efaa2f 100644 --- a/shared-data/js/index.ts +++ b/shared-data/js/index.ts @@ -5,6 +5,7 @@ export * from './pipettes' export * from './types' export * from './labwareTools' export * from './modules' +export * from './fixtures' export * from './gripper' export * from '../protocol' export * from './titleCase' diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 75b5f5f2956..e7631a1ae06 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -24,6 +24,9 @@ import { GRIPPER_V1_2, EXTENSION, MAGNETIC_BLOCK_V1, + STAGING_AREA_LOAD_NAME, + STANDARD_SLOT_LOAD_NAME, + WASTE_CHUTE_LOAD_NAME, } from './constants' import type { INode } from 'svgson' import type { RunTimeCommand } from '../protocol' @@ -228,6 +231,11 @@ export type ModuleModelWithLegacy = | typeof MAGDECK | typeof TEMPDECK +export type FixtureLoadName = + | typeof STAGING_AREA_LOAD_NAME + | typeof STANDARD_SLOT_LOAD_NAME + | typeof WASTE_CHUTE_LOAD_NAME + export interface DeckOffset { x: number y: number diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_0.json index 50eac490bca..44e1bf84760 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_3.json b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_3.json index a1ddd6a757e..b71b9f112e2 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_3.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_3.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -5.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_4.json b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_4.json index 5fa6c25482e..7884060abb7 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_4.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_4.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_5.json b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_5.json index dafca060358..e8447efdce0 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_5.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_5.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake", "doubleDropTip"] + "quirks": ["dropTipShake", "doubleDropTip"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_6.json b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_6.json index dafca060358..e8447efdce0 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p10/1_6.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p10/1_6.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake", "doubleDropTip"] + "quirks": ["dropTipShake", "doubleDropTip"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json index 2390c7e671d..2da0be49f08 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p1000/1_0.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -22,9 +28,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +47,9 @@ "shaftDiameter": 9.0, "shaftULperMM": 63.617, "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_0.json index 86edf57dc9a..502ada5dd9b 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_0.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -22,9 +28,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +47,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_3.json index 86edf57dc9a..502ada5dd9b 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_3.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -22,9 +28,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +47,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_4.json index 4b4fd1a4f3c..f8e508bdfd7 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_4.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -22,9 +28,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +47,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_5.json index 4b4fd1a4f3c..f8e508bdfd7 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p1000/3_5.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -22,9 +28,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +47,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p20/2_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p20/2_0.json index 055f3abd75a..c02138a297e 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p20/2_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p20/2_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -31.6 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": ["p10_multi"], "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p20/2_1.json b/shared-data/pipette/definitions/2/general/eight_channel/p20/2_1.json index 055f3abd75a..c02138a297e 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p20/2_1.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p20/2_1.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -31.6 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": ["p10_multi"], "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_0.json index 4409b81628b..23354433c5f 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -2.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_3.json b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_3.json index 12e1ee863f6..66ba1354970 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_3.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_3.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -3.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_4.json b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_4.json index 12e1ee863f6..66ba1354970 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_4.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_4.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -3.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_5.json b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_5.json index 39a54170a05..f2813129e2e 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p300/1_5.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p300/1_5.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -3.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake", "doubleDropTip"] + "quirks": ["dropTipShake", "doubleDropTip"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p300/2_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p300/2_0.json index 477d9ff34ce..c8b47ddd04c 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p300/2_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p300/2_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -33.4 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 9.621, "backCompatNames": ["p300_multi"], "backlashDistance": 0.0, - "quirks": ["needsUnstick"] + "quirks": ["needsUnstick"], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p300/2_1.json b/shared-data/pipette/definitions/2/general/eight_channel/p300/2_1.json index 477d9ff34ce..c8b47ddd04c 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p300/2_1.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p300/2_1.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -33.4 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 9.621, "backCompatNames": ["p300_multi"], "backlashDistance": 0.0, - "quirks": ["needsUnstick"] + "quirks": ["needsUnstick"], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json index 8e8701dd159..04979895755 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -3.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_3.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_3.json index 3943373ea8c..9b4d07690a7 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_3.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_3.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -5.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_4.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_4.json index 5d81ca30893..4ec2c916d3e 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_4.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_4.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_5.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_5.json index 574900f0f95..744cd90444e 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/1_5.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/1_5.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": true, "availableConfigurations": [1, 2, 3, 4, 5, 6, 7, 8] @@ -36,5 +41,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["doubleDropTip", "dropTipShake"] + "quirks": ["doubleDropTip", "dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_0.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_0.json index 206b34839ab..4f4ace328ab 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_0.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_0.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -28,9 +34,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -41,5 +53,9 @@ "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_3.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_3.json index 206b34839ab..4f4ace328ab 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_3.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_3.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -28,9 +34,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -41,5 +53,9 @@ "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_4.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_4.json index 8ffe4845edb..8ebb55ff42f 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_4.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_4.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -28,9 +34,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -41,5 +53,9 @@ "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_5.json b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_5.json index 8ffe4845edb..8ebb55ff42f 100644 --- a/shared-data/pipette/definitions/2/general/eight_channel/p50/3_5.json +++ b/shared-data/pipette/definitions/2/general/eight_channel/p50/3_5.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -28,9 +34,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -41,5 +53,9 @@ "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json index b222820de27..7d972aa3b17 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/1_0.json @@ -10,8 +10,15 @@ "increment": 0.0, "distance": 19.0 }, - "dropTipConfigurations": { "current": 1.5, "speed": 5.5, "distance": 26.5 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 2.0 }, + "dropTipConfigurations": { + "current": 1.5, + "speed": 5.5, + "distance": 26.5 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 2.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0, @@ -22,9 +29,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +48,9 @@ "shaftDiameter": 9.0, "shaftULperMM": 63.617, "backlashDistance": 0.3, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 2.0, + "speed": 5 + } } diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_0.json index 3edaca0c481..c18e9fded71 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_0.json @@ -10,8 +10,15 @@ "increment": 0.0, "distance": 19.0 }, - "dropTipConfigurations": { "current": 1.5, "speed": 5.5, "distance": 26.5 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 2.0 }, + "dropTipConfigurations": { + "current": 1.5, + "speed": 5.5, + "distance": 26.5 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 2.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0, @@ -22,9 +29,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +48,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.3, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 2.0, + "speed": 5 + } } diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_3.json index 3edaca0c481..c18e9fded71 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_3.json @@ -10,8 +10,15 @@ "increment": 0.0, "distance": 19.0 }, - "dropTipConfigurations": { "current": 1.5, "speed": 5.5, "distance": 26.5 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 2.0 }, + "dropTipConfigurations": { + "current": 1.5, + "speed": 5.5, + "distance": 26.5 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 2.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0, @@ -22,9 +29,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -35,5 +48,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.3, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 2.0, + "speed": 5 + } } diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json index 7a8a792b0e4..bac55b3996f 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_4.json @@ -29,9 +29,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -42,5 +48,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.3, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 0.8, + "speed": 5 + } } diff --git a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json index 7a8a792b0e4..bac55b3996f 100644 --- a/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/general/ninety_six_channel/p1000/3_5.json @@ -29,9 +29,15 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 2 }, - "capacitive": { "count": 2 }, - "environment": { "count": 1 } + "pressure": { + "count": 2 + }, + "capacitive": { + "count": 2 + }, + "environment": { + "count": 1 + } }, "partialTipConfigurations": { "partialTipSupported": true, @@ -42,5 +48,9 @@ "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.3, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 0.8, + "speed": 5 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p10/1_0.json b/shared-data/pipette/definitions/2/general/single_channel/p10/1_0.json index a8114eeb450..8cc699533ec 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p10/1_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p10/1_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p10/1_3.json b/shared-data/pipette/definitions/2/general/single_channel/p10/1_3.json index d8b0f94f4ac..1e6765e00b4 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p10/1_3.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p10/1_3.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -6.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p10/1_4.json b/shared-data/pipette/definitions/2/general/single_channel/p10/1_4.json index 47a5c8d1dc6..a044395382c 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p10/1_4.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p10/1_4.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -5.2 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p10/1_5.json b/shared-data/pipette/definitions/2/general/single_channel/p10/1_5.json index 47a5c8d1dc6..a044395382c 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p10/1_5.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p10/1_5.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -5.2 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json index 20ec052e1d1..c5e73bc3e7e 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -2.2 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 63.617, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["pickupTipShake", "dropTipShake"] + "quirks": ["pickupTipShake", "dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_3.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_3.json index 54abbeaf3b2..38a62cda856 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_3.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_3.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 63.617, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["pickupTipShake", "dropTipShake"] + "quirks": ["pickupTipShake", "dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_4.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_4.json index 54abbeaf3b2..38a62cda856 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_4.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_4.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 63.617, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["pickupTipShake", "dropTipShake"] + "quirks": ["pickupTipShake", "dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_5.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_5.json index d7529694240..a2fc3c6edc2 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/1_5.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/1_5.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.5 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.5 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 63.617, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["pickupTipShake", "dropTipShake"] + "quirks": ["pickupTipShake", "dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.5, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/2_0.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/2_0.json index 41cf25d9563..3c1777feffe 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/2_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/2_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -37.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 28.274, "backCompatNames": ["p1000_single"], "backlashDistance": 0.0, - "quirks": ["pickupTipShake"] + "quirks": ["pickupTipShake"], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/2_1.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/2_1.json index 3a8a1ef869f..7609ef93972 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/2_1.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/2_1.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -37.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 28.274, "backCompatNames": ["p1000_single"], "backlashDistance": 0.0, - "quirks": ["pickupTipShake"] + "quirks": ["pickupTipShake"], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/2_2.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/2_2.json index 3a8a1ef869f..7609ef93972 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/2_2.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/2_2.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -37.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 28.274, "backCompatNames": ["p1000_single"], "backlashDistance": 0.0, - "quirks": ["pickupTipShake"] + "quirks": ["pickupTipShake"], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_0.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_0.json index 34e99e9c99e..972a8516216 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_0.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -22,15 +28,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_3.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_3.json index 34e99e9c99e..972a8516216 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_3.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_3.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -22,15 +28,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_4.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_4.json index caf6f3ecf6d..98c7d9707e0 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_4.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_4.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 15 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 15 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -22,15 +28,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_5.json b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_5.json index caf6f3ecf6d..98c7d9707e0 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p1000/3_5.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p1000/3_5.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 15 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 15 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -22,15 +28,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 4.5, "shaftULperMM": 15.904, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p20/2_0.json b/shared-data/pipette/definitions/2/general/single_channel/p20/2_0.json index 3cd75fdc721..7d8dc7fa606 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p20/2_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p20/2_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -27.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": ["p10_single"], "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p20/2_1.json b/shared-data/pipette/definitions/2/general/single_channel/p20/2_1.json index 71f8283699d..a7a3649cdea 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p20/2_1.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p20/2_1.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -27.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": ["p10_single"], "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p20/2_2.json b/shared-data/pipette/definitions/2/general/single_channel/p20/2_2.json index 71f8283699d..a7a3649cdea 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p20/2_2.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p20/2_2.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -27.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 0.785, "backCompatNames": ["p10_single"], "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p300/1_0.json b/shared-data/pipette/definitions/2/general/single_channel/p300/1_0.json index c9bc17bdcb5..4f174a5eb8c 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p300/1_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p300/1_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p300/1_3.json b/shared-data/pipette/definitions/2/general/single_channel/p300/1_3.json index a24f4a0f797..8e7663ad458 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p300/1_3.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p300/1_3.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -5.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p300/1_4.json b/shared-data/pipette/definitions/2/general/single_channel/p300/1_4.json index 518cffb9fd8..5bc84742f62 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p300/1_4.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p300/1_4.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p300/1_5.json b/shared-data/pipette/definitions/2/general/single_channel/p300/1_5.json index 518cffb9fd8..5bc84742f62 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p300/1_5.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p300/1_5.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 19.635, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p300/2_0.json b/shared-data/pipette/definitions/2/general/single_channel/p300/2_0.json index a7c1dbf74ae..5a9dae759b4 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p300/2_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p300/2_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -37.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 9.621, "backCompatNames": ["p300_single"], "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p300/2_1.json b/shared-data/pipette/definitions/2/general/single_channel/p300/2_1.json index d91cddd2253..3686e581240 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p300/2_1.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p300/2_1.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -37.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 9.621, "backCompatNames": ["p300_single"], "backlashDistance": 0.0, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json b/shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json index 39de93ca0a3..a23409dfdf9 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/1_0.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -4.5 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/1_3.json b/shared-data/pipette/definitions/2/general/single_channel/p50/1_3.json index fc769caaf3c..34329f19cbd 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/1_3.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/1_3.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -6.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/1_4.json b/shared-data/pipette/definitions/2/general/single_channel/p50/1_4.json index ef4c5b9945c..a6e1872d352 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/1_4.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/1_4.json @@ -17,7 +17,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -26,7 +29,9 @@ "drop": -5.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -36,5 +41,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/1_5.json b/shared-data/pipette/definitions/2/general/single_channel/p50/1_5.json index b4b31dec91d..7a4f2053d1d 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/1_5.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/1_5.json @@ -19,7 +19,10 @@ "increment": 0.0, "distance": 0.0 }, - "plungerMotorConfigurations": { "idle": 0.05, "run": 0.3 }, + "plungerMotorConfigurations": { + "idle": 0.05, + "run": 0.3 + }, "plungerPositionsConfigurations": { "default": { "top": 19.5, @@ -28,7 +31,9 @@ "drop": -5.0 } }, - "availableSensors": { "sensors": [] }, + "availableSensors": { + "sensors": [] + }, "partialTipConfigurations": { "partialTipSupported": false, "availableConfigurations": null @@ -38,5 +43,9 @@ "shaftULperMM": 3.142, "backCompatNames": [], "backlashDistance": 0.0, - "quirks": ["dropTipShake"] + "quirks": ["dropTipShake"], + "plungerHomingConfigurations": { + "current": 0.3, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/3_0.json b/shared-data/pipette/definitions/2/general/single_channel/p50/3_0.json index 623e0f7b6ad..32b0e88ab38 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/3_0.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/3_0.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -28,15 +34,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/3_3.json b/shared-data/pipette/definitions/2/general/single_channel/p50/3_3.json index 623e0f7b6ad..32b0e88ab38 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/3_3.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/3_3.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 10 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 10 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.5, @@ -28,15 +34,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/3_4.json b/shared-data/pipette/definitions/2/general/single_channel/p50/3_4.json index e5304e77ac4..470c9a9985c 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/3_4.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/3_4.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 15 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 15 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -28,15 +34,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/pipette/definitions/2/general/single_channel/p50/3_5.json b/shared-data/pipette/definitions/2/general/single_channel/p50/3_5.json index e5304e77ac4..470c9a9985c 100644 --- a/shared-data/pipette/definitions/2/general/single_channel/p50/3_5.json +++ b/shared-data/pipette/definitions/2/general/single_channel/p50/3_5.json @@ -10,8 +10,14 @@ "increment": 0.0, "distance": 13.0 }, - "dropTipConfigurations": { "current": 1.0, "speed": 15 }, - "plungerMotorConfigurations": { "idle": 0.3, "run": 1.0 }, + "dropTipConfigurations": { + "current": 1.0, + "speed": 15 + }, + "plungerMotorConfigurations": { + "idle": 0.3, + "run": 1.0 + }, "plungerPositionsConfigurations": { "default": { "top": 0.0, @@ -28,15 +34,27 @@ }, "availableSensors": { "sensors": ["pressure", "capacitive", "environment"], - "pressure": { "count": 1 }, - "capacitive": { "count": 1 }, - "environment": { "count": 1 } + "pressure": { + "count": 1 + }, + "capacitive": { + "count": 1 + }, + "environment": { + "count": 1 + } + }, + "partialTipConfigurations": { + "partialTipSupported": false }, - "partialTipConfigurations": { "partialTipSupported": false }, "backCompatNames": [], "channels": 1, "shaftDiameter": 1.0, "shaftULperMM": 0.785, "backlashDistance": 0.1, - "quirks": [] + "quirks": [], + "plungerHomingConfigurations": { + "current": 1.0, + "speed": 30 + } } diff --git a/shared-data/protocol/types/schemaV7/command/setup.ts b/shared-data/protocol/types/schemaV7/command/setup.ts index 84f17313f25..920a8d3c1be 100644 --- a/shared-data/protocol/types/schemaV7/command/setup.ts +++ b/shared-data/protocol/types/schemaV7/command/setup.ts @@ -5,6 +5,7 @@ import type { LabwareOffset, PipetteName, ModuleModel, + FixtureLoadName, } from '../../../../js' export interface LoadPipetteCreateCommand extends CommonCommandCreateInfo { @@ -58,10 +59,20 @@ export interface LoadLiquidRunTimeCommand LoadLiquidCreateCommand { result?: LoadLiquidResult } +export interface LoadFixtureCreateCommand extends CommonCommandCreateInfo { + commandType: 'loadFixture' + params: LoadFixtureParams +} +export interface LoadFixtureRunTimeCommand + extends CommonCommandRunTimeInfo, + LoadFixtureCreateCommand { + result?: LoadLabwareResult +} export type SetupRunTimeCommand = | LoadPipetteRunTimeCommand | LoadLabwareRunTimeCommand + | LoadFixtureRunTimeCommand | LoadModuleRunTimeCommand | LoadLiquidRunTimeCommand | MoveLabwareRunTimeCommand @@ -69,6 +80,7 @@ export type SetupRunTimeCommand = export type SetupCreateCommand = | LoadPipetteCreateCommand | LoadLabwareCreateCommand + | LoadFixtureCreateCommand | LoadModuleCreateCommand | LoadLiquidCreateCommand | MoveLabwareCreateCommand @@ -138,3 +150,9 @@ interface LoadLiquidParams { interface LoadLiquidResult { liquidId: string } +export type Cutout = 'B3' | 'C3' | 'D3' +interface LoadFixtureParams { + location: { cutout: Cutout } + loadName: FixtureLoadName + fixtureId?: string +} diff --git a/shared-data/python/opentrons_shared_data/errors/codes.py b/shared-data/python/opentrons_shared_data/errors/codes.py index d96f5d74bb0..0b9473f8f18 100644 --- a/shared-data/python/opentrons_shared_data/errors/codes.py +++ b/shared-data/python/opentrons_shared_data/errors/codes.py @@ -55,6 +55,9 @@ class ErrorCodes(Enum): EARLY_CAPACITIVE_SENSE_TRIGGER = _code_from_dict_entry("2009") INACCURATE_NON_CONTACT_SWEEP = _code_from_dict_entry("2010") MISALIGNED_GANTRY = _code_from_dict_entry("2011") + UNMATCHED_TIP_PRESENCE_STATES = _code_from_dict_entry("2012") + POSITION_UNKNOWN = _code_from_dict_entry("2013") + EXECUTION_CANCELLED = _code_from_dict_entry("2014") ROBOTICS_INTERACTION_ERROR = _code_from_dict_entry("3000") LABWARE_DROPPED = _code_from_dict_entry("3001") LABWARE_NOT_PICKED_UP = _code_from_dict_entry("3002") diff --git a/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index 092669e1139..f43ba260d3f 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -350,7 +350,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build a FirmwareUpdateFailedError.""" + """Build a MotionFailedError.""" super().__init__(ErrorCodes.MOTION_FAILED, message, detail, wrapping) @@ -363,7 +363,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build a FirmwareUpdateFailedError.""" + """Build a HomingFailedError.""" super().__init__(ErrorCodes.HOMING_FAILED, message, detail, wrapping) @@ -528,6 +528,60 @@ def __init__( ) +class UnmatchedTipPresenceStates(RoboticsControlError): + """An error indicating that a tip presence check resulted in two differing responses.""" + + def __init__( + self, + states: Dict[int, int], + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an UnmatchedTipPresenceStatesError.""" + format_tip_state = {0: "not detected", 1: "detected"} + msg = ( + "Received two differing tip presence statuses:" + "\nRear Sensor tips" + + format_tip_state[states[0]] + + "\nFront Sensor tips" + + format_tip_state[states[1]] + ) + if detail: + msg += str(detail) + super().__init__( + ErrorCodes.UNMATCHED_TIP_PRESENCE_STATES, + msg, + detail, + wrapping, + ) + + +class PositionUnknownError(RoboticsControlError): + """An error indicating that the robot's position is unknown.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PositionUnknownError.""" + super().__init__(ErrorCodes.POSITION_UNKNOWN, message, detail, wrapping) + + +class ExecutionCancelledError(RoboticsControlError): + """An error indicating that the robot's execution manager has been cancelled.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ExecutionCancelledError.""" + super().__init__(ErrorCodes.EXECUTION_CANCELLED, message, detail, wrapping) + + class LabwareDroppedError(RoboticsInteractionError): """An error indicating that the gripper dropped labware it was holding.""" @@ -572,12 +626,20 @@ class UnexpectedTipRemovalError(RoboticsInteractionError): def __init__( self, - message: Optional[str] = None, + action: str, + pipette_name: str, + mount: str, detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an UnexpectedTipRemovalError.""" - super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, detail, wrapping) + checked_detail: Dict[str, Any] = detail or {} + checked_detail["pipette_name"] = pipette_name + checked_detail["mount"] = mount + message = f"Cannot perform {action} without a tip attached." + super().__init__( + ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, checked_detail, wrapping + ) class UnexpectedTipAttachError(RoboticsInteractionError): @@ -585,11 +647,18 @@ class UnexpectedTipAttachError(RoboticsInteractionError): def __init__( self, + action: str, + pipette_name: str, + mount: str, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an UnexpectedTipAttachError.""" + checked_detail: Dict[str, Any] = detail or {} + checked_detail["pipette_name"] = pipette_name + checked_detail["mount"] = mount + message = f"Cannot perform {action} with a tip already attached." super().__init__(ErrorCodes.UNEXPECTED_TIP_ATTACH, message, detail, wrapping) @@ -598,11 +667,17 @@ class FirmwareUpdateRequiredError(RoboticsInteractionError): def __init__( self, + action: str, + subsystems_to_update: List[Any], message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a FirmwareUpdateRequiredError.""" + checked_detail: Dict[str, Any] = detail or {} + checked_detail["identifier"] = action + checked_detail["subsystems_to_update"] = subsystems_to_update + message = f"Cannot perform {action} until {subsystems_to_update} are updated." super().__init__(ErrorCodes.FIRMWARE_UPDATE_REQUIRED, message, detail, wrapping) @@ -680,7 +755,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build an GripperNotPresentError.""" + """Build an InvalidActuator.""" super().__init__(ErrorCodes.INVALID_ACTUATOR, message, detail, wrapping) @@ -712,7 +787,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build an GripperNotPresentError.""" + """Build an InvalidInstrumentData.""" super().__init__(ErrorCodes.INVALID_INSTRUMENT_DATA, message, detail, wrapping) @@ -797,3 +872,18 @@ def __init__( for e in wrapping_checked ], ) + + +class UnsupportedHardwareCommand(GeneralError): + """An error indicating that a command being executed is not supported by the hardware.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an UnsupportedHardwareCommand.""" + super().__init__( + ErrorCodes.NOT_SUPPORTED_ON_ROBOT_TYPE, message, detail, wrapping + ) diff --git a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py index d42a580d828..4214f376396 100644 --- a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py +++ b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py @@ -139,7 +139,7 @@ class PlungerPositions(BaseModel): ) -class TipHandlingConfigurations(BaseModel): +class PlungerHomingConfigurations(BaseModel): current: float = Field( ..., description="Either the z motor current needed for picking up tip or the plunger motor current for dropping tip off the nozzle.", @@ -148,6 +148,9 @@ class TipHandlingConfigurations(BaseModel): ..., description="The speed to move the z or plunger axis for tip pickup or drop off.", ) + + +class TipHandlingConfigurations(PlungerHomingConfigurations): presses: int = Field( default=0.0, description="The number of tries required to force pick up a tip." ) @@ -211,6 +214,9 @@ class PipettePhysicalPropertiesDefinition(BaseModel): drop_tip_configurations: TipHandlingConfigurations = Field( ..., alias="dropTipConfigurations" ) + plunger_homing_configurations: PlungerHomingConfigurations = Field( + ..., alias="plungerHomingConfigurations" + ) plunger_motor_configurations: MotorConfigurations = Field( ..., alias="plungerMotorConfigurations" ) diff --git a/step-generation/src/__tests__/__snapshots__/fixtureGeneration.test.ts.snap b/step-generation/src/__tests__/__snapshots__/fixtureGeneration.test.ts.snap index 9fde90f30c6..02524626001 100644 --- a/step-generation/src/__tests__/__snapshots__/fixtureGeneration.test.ts.snap +++ b/step-generation/src/__tests__/__snapshots__/fixtureGeneration.test.ts.snap @@ -544,6 +544,7 @@ Object { exports[`snapshot tests makeContext 1`] = ` Object { + "additionalEquipmentEntities": Object {}, "config": Object { "OT_PD_DISABLE_MODULE_RESTRICTIONS": false, }, diff --git a/step-generation/src/__tests__/glue.test.ts b/step-generation/src/__tests__/glue.test.ts index 28d21001b6b..9c65d85d282 100644 --- a/step-generation/src/__tests__/glue.test.ts +++ b/step-generation/src/__tests__/glue.test.ts @@ -153,6 +153,7 @@ beforeEach(() => { labwareEntities: {}, moduleEntities: {}, pipetteEntities: {}, + additionalEquipmentEntities: {}, config: DEFAULT_CONFIG, } }) diff --git a/step-generation/src/__tests__/moveLabware.test.ts b/step-generation/src/__tests__/moveLabware.test.ts index 8519aed104b..b57f3d7d200 100644 --- a/step-generation/src/__tests__/moveLabware.test.ts +++ b/step-generation/src/__tests__/moveLabware.test.ts @@ -1,4 +1,7 @@ -import { HEATERSHAKER_MODULE_TYPE } from '@opentrons/shared-data' +import { + HEATERSHAKER_MODULE_TYPE, + WASTE_CHUTE_SLOT, +} from '@opentrons/shared-data' import { getInitialRobotStateStandard, getInitialRobotStateWithOffDeckLabwareStandard, @@ -7,11 +10,14 @@ import { getErrorResult, getStateAndContextTempTCModules, SOURCE_LABWARE, + TIPRACK_1, } from '../fixtures' import { moveLabware, MoveLabwareArgs } from '..' import type { InvariantContext, RobotState } from '../types' +const mockWasteChuteId = 'mockWasteChuteId' + describe('moveLabware', () => { let robotState: RobotState let invariantContext: InvariantContext @@ -165,6 +171,45 @@ describe('moveLabware', () => { type: 'HEATER_SHAKER_LATCH_CLOSED', }) }) + it('should return an error when trying to move labware to an adapter on the heater-shaker when its latch is closed', () => { + const state = getInitialRobotStateStandard(invariantContext) + const HEATER_SHAKER_ID = 'heaterShakerId' + const HEATER_SHAKER_SLOT = 'A1' + const ADAPTER_ID = 'adapterId' + + robotState = { + ...state, + labware: { + ...state.labware, + [ADAPTER_ID]: { + slot: HEATER_SHAKER_ID, + }, + }, + modules: { + ...state.modules, + [HEATER_SHAKER_ID]: { + slot: HEATER_SHAKER_SLOT, + moduleState: { + type: HEATERSHAKER_MODULE_TYPE, + latchOpen: false, + targetSpeed: null, + }, + } as any, + }, + } + const params = { + commandCreatorFnName: 'moveLabware', + labware: SOURCE_LABWARE, + useGripper: true, + newLocation: { labwareId: ADAPTER_ID }, + } as MoveLabwareArgs + + const result = moveLabware(params, invariantContext, robotState) + expect(getErrorResult(result).errors).toHaveLength(1) + expect(getErrorResult(result).errors[0]).toMatchObject({ + type: 'HEATER_SHAKER_LATCH_CLOSED', + }) + }) it('should return an error when trying to move labware to the heater-shaker when its shaking', () => { const state = getInitialRobotStateStandard(invariantContext) const HEATER_SHAKER_ID = 'heaterShakerId' @@ -197,4 +242,81 @@ describe('moveLabware', () => { type: 'HEATER_SHAKER_IS_SHAKING', }) }) + it('should return a warning for if you try to move a tiprack with tips into the waste chute', () => { + const wasteChuteInvariantContext = { + ...invariantContext, + additionalEquipmentEntities: { + mockWasteChuteId: { + name: 'wasteChute', + id: mockWasteChuteId, + location: WASTE_CHUTE_SLOT, + }, + }, + } as InvariantContext + + const robotStateWithTip = ({ + ...robotState, + tipState: { + tipracks: { + tiprack1Id: { A1: true }, + }, + }, + } as any) as RobotState + const params = { + commandCreatorFnName: 'moveLabware', + labware: TIPRACK_1, + useGripper: true, + newLocation: { slotName: WASTE_CHUTE_SLOT }, + } as MoveLabwareArgs + + const result = moveLabware( + params, + wasteChuteInvariantContext, + robotStateWithTip + ) + expect(result.warnings).toEqual([ + { + message: 'Disposing of a tiprack with tips', + type: 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS', + }, + ]) + }) + it('should return a warning for if you try to move a labware with liquids into the waste chute', () => { + const wasteChuteInvariantContext = { + ...invariantContext, + additionalEquipmentEntities: { + mockWasteChuteId: { + name: 'wasteChute', + id: mockWasteChuteId, + location: WASTE_CHUTE_SLOT, + }, + }, + } as InvariantContext + const robotStateWithLiquid = ({ + ...robotState, + liquidState: { + labware: { + sourcePlateId: { A1: { ingredGroup: { volume: 10 } } }, + }, + }, + } as any) as RobotState + const params = { + commandCreatorFnName: 'moveLabware', + labware: SOURCE_LABWARE, + useGripper: true, + newLocation: { slotName: WASTE_CHUTE_SLOT }, + } as MoveLabwareArgs + + const result = moveLabware( + params, + wasteChuteInvariantContext, + robotStateWithLiquid + ) + expect(result.warnings).toEqual([ + { + message: 'Disposing of a labware with liquid', + type: 'LABWARE_IN_WASTE_CHUTE_HAS_LIQUID', + }, + ]) + }) }) diff --git a/step-generation/src/__tests__/utils.test.ts b/step-generation/src/__tests__/utils.test.ts index 3cda22fe896..75fbd22fa78 100644 --- a/step-generation/src/__tests__/utils.test.ts +++ b/step-generation/src/__tests__/utils.test.ts @@ -321,6 +321,7 @@ describe('makeInitialRobotState', () => { def: fixtureTrash, }, }, + additionalEquipmentEntities: {}, }, labwareLocations: { somePlateId: { slot: '1' }, diff --git a/step-generation/src/commandCreators/atomic/dropTip.ts b/step-generation/src/commandCreators/atomic/dropTip.ts index b7d41f476d3..ece8fe9b75e 100644 --- a/step-generation/src/commandCreators/atomic/dropTip.ts +++ b/step-generation/src/commandCreators/atomic/dropTip.ts @@ -1,5 +1,4 @@ import { uuid } from '../../utils' -import { FIXED_TRASH_ID } from '../../constants' import type { CommandCreator } from '../../types' interface DropTipArgs { pipette: string @@ -12,7 +11,10 @@ export const dropTip: CommandCreator = ( prevRobotState ) => { const { pipette } = args - + const { labwareEntities } = invariantContext + const trashId = Object.values(labwareEntities).find(lw => + lw.def.parameters.quirks?.includes('fixedTrash') + )?.id // No-op if there is no tip if (!prevRobotState.tipState.pipettes[pipette]) { return { @@ -26,7 +28,8 @@ export const dropTip: CommandCreator = ( key: uuid(), params: { pipetteId: pipette, - labwareId: FIXED_TRASH_ID, + // TODO(jr, 9/26/23): support no trash, return tip and waste chute + labwareId: trashId != null ? trashId : '', wellName: 'A1', }, // TODO(jr, 7/17/23): add WellLocation params! diff --git a/step-generation/src/commandCreators/atomic/moveLabware.ts b/step-generation/src/commandCreators/atomic/moveLabware.ts index 541e54dbf7c..44b0b5be684 100644 --- a/step-generation/src/commandCreators/atomic/moveLabware.ts +++ b/step-generation/src/commandCreators/atomic/moveLabware.ts @@ -3,14 +3,23 @@ import { HEATERSHAKER_MODULE_TYPE, LabwareMovementStrategy, THERMOCYCLER_MODULE_TYPE, + WASTE_CHUTE_SLOT, } from '@opentrons/shared-data' import * as errorCreators from '../../errorCreators' +import * as warningCreators from '../../warningCreators' import { uuid } from '../../utils' +import { + getHasWasteChute, + getTiprackHasTips, + getLabwareHasLiquid, + CommandCreatorWarning, +} from '../..' import type { CommandCreator, CommandCreatorError, MoveLabwareArgs, } from '../../types' + /** Move labware from one location to another, manually or via a gripper. */ export const moveLabware: CommandCreator = ( args, @@ -18,8 +27,19 @@ export const moveLabware: CommandCreator = ( prevRobotState ) => { const { labware, useGripper, newLocation } = args + const { additionalEquipmentEntities } = invariantContext + const { tipState, liquidState } = prevRobotState + const tiprackHasTip = getTiprackHasTips(tipState, labware) + const labwareHasLiquid = getLabwareHasLiquid(liquidState, labware) + const actionName = 'moveToLabware' const errors: CommandCreatorError[] = [] + const warnings: CommandCreatorWarning[] = [] + + const newLocationInWasteChute = + newLocation !== 'offDeck' && + 'slotName' in newLocation && + newLocation.slotName === WASTE_CHUTE_SLOT if (!labware || !prevRobotState.labware[labware]) { errors.push( @@ -33,9 +53,12 @@ export const moveLabware: CommandCreator = ( } const initialLabwareSlot = prevRobotState.labware[labware]?.slot - const initialModuleState = - prevRobotState.modules[initialLabwareSlot]?.moduleState ?? null + const initialAdapterSlot = prevRobotState.labware[initialLabwareSlot]?.slot + const initialSlot = + initialAdapterSlot != null ? initialAdapterSlot : initialLabwareSlot + const initialModuleState = + prevRobotState.modules[initialSlot]?.moduleState ?? null if (initialModuleState != null) { if ( initialModuleState.type === THERMOCYCLER_MODULE_TYPE && @@ -43,7 +66,7 @@ export const moveLabware: CommandCreator = ( ) { errors.push(errorCreators.thermocyclerLidClosed()) } else if (initialModuleState.type === HEATERSHAKER_MODULE_TYPE) { - if (initialModuleState.latchOpen === false) { + if (initialModuleState.latchOpen !== true) { errors.push(errorCreators.heaterShakerLatchClosed()) } else if (initialModuleState.targetSpeed !== null) { errors.push(errorCreators.heaterShakerIsShaking()) @@ -55,18 +78,43 @@ export const moveLabware: CommandCreator = ( ? newLocation.moduleId : null + const destAdapterId = + newLocation !== 'offDeck' && 'labwareId' in newLocation + ? newLocation.labwareId + : null + const destModuleIdUnderAdapter = + destAdapterId != null ? prevRobotState.labware[destAdapterId].slot : null + const destinationModuleId = + destModuleIdUnderAdapter != null ? destModuleIdUnderAdapter : destModuleId + if (newLocation === 'offDeck' && useGripper) { errors.push(errorCreators.labwareOffDeck()) } - if (destModuleId != null) { - const destModuleState = prevRobotState.modules[destModuleId].moduleState + + if ( + tiprackHasTip && + newLocationInWasteChute && + getHasWasteChute(additionalEquipmentEntities) + ) { + warnings.push(warningCreators.tiprackInWasteChuteHasTips()) + } else if ( + labwareHasLiquid && + newLocationInWasteChute && + getHasWasteChute(additionalEquipmentEntities) + ) { + warnings.push(warningCreators.labwareInWasteChuteHasLiquid()) + } + + if (destinationModuleId != null) { + const destModuleState = + prevRobotState.modules[destinationModuleId].moduleState if ( destModuleState.type === THERMOCYCLER_MODULE_TYPE && destModuleState.lidOpen !== true ) { errors.push(errorCreators.thermocyclerLidClosed()) } else if (destModuleState.type === HEATERSHAKER_MODULE_TYPE) { - if (destModuleState.latchOpen === false) { + if (destModuleState.latchOpen !== true) { errors.push(errorCreators.heaterShakerLatchClosed()) } if (destModuleState.targetSpeed !== null) { @@ -96,5 +144,6 @@ export const moveLabware: CommandCreator = ( ] return { commands, + warnings: warnings.length > 0 ? warnings : undefined, } } diff --git a/step-generation/src/fixtures/commandFixtures.ts b/step-generation/src/fixtures/commandFixtures.ts index 10fbf897c12..cafa7942ff6 100644 --- a/step-generation/src/fixtures/commandFixtures.ts +++ b/step-generation/src/fixtures/commandFixtures.ts @@ -94,6 +94,7 @@ export const SOURCE_LABWARE = 'sourcePlateId' export const DEST_LABWARE = 'destPlateId' export const TROUGH_LABWARE = 'troughId' export const DEFAULT_BLOWOUT_WELL = 'A1' +export const TIPRACK_1 = 'tiprack1Id' export const AIR_GAP_META = { isAirGap: true } // to differentiate if the aspirate or dispense command is an air gap or not // ================= type MakeAspDispHelper

    = ( diff --git a/step-generation/src/fixtures/robotStateFixtures.ts b/step-generation/src/fixtures/robotStateFixtures.ts index 5439d951122..5d7a44565fa 100644 --- a/step-generation/src/fixtures/robotStateFixtures.ts +++ b/step-generation/src/fixtures/robotStateFixtures.ts @@ -32,6 +32,7 @@ import { import { makeInitialRobotState } from '../utils' import { tiprackWellNamesFlat } from './data' import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { AdditionalEquipmentEntities } from '../types' import type { Config, InvariantContext, @@ -120,6 +121,7 @@ export function makeContext(): InvariantContext { }, } const moduleEntities: ModuleEntities = {} + const additionalEquipmentEntities: AdditionalEquipmentEntities = {} const pipetteEntities: PipetteEntities = { p10SingleId: { name: 'p10_single', @@ -158,6 +160,7 @@ export function makeContext(): InvariantContext { labwareEntities, moduleEntities, pipetteEntities, + additionalEquipmentEntities, config: DEFAULT_CONFIG, } } diff --git a/step-generation/src/getNextRobotStateAndWarnings/forMoveLabware.ts b/step-generation/src/getNextRobotStateAndWarnings/forMoveLabware.ts index d9a940f2e65..56ddf85115e 100644 --- a/step-generation/src/getNextRobotStateAndWarnings/forMoveLabware.ts +++ b/step-generation/src/getNextRobotStateAndWarnings/forMoveLabware.ts @@ -16,6 +16,8 @@ export function forMoveLabware( newLocationString = newLocation.moduleId } else if ('slotName' in newLocation) { newLocationString = newLocation.slotName + } else if ('labwareId' in newLocation) { + newLocationString = newLocation.labwareId } robotState.labware[labwareId].slot = newLocationString diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index 8962c9121aa..c2a6ed9f654 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -112,11 +112,17 @@ export interface NormalizedPipetteById { export interface NormalizedAdditionalEquipmentById { [additionalEquipmentId: string]: { - name: 'gripper' + name: 'gripper' | 'wasteChute' id: string + location?: string } } +export type AdditionalEquipmentEntity = NormalizedAdditionalEquipmentById[keyof NormalizedAdditionalEquipmentById] +export interface AdditionalEquipmentEntities { + [additionalEquipmentId: string]: AdditionalEquipmentEntity +} + export type NormalizedPipette = NormalizedPipetteById[keyof NormalizedPipetteById] // "entities" have only properties that are time-invariant @@ -439,6 +445,7 @@ export interface InvariantContext { labwareEntities: LabwareEntities moduleEntities: ModuleEntities pipetteEntities: PipetteEntities + additionalEquipmentEntities: AdditionalEquipmentEntities config: Config } @@ -511,6 +518,8 @@ export interface CommandCreatorError { export type WarningType = | 'ASPIRATE_MORE_THAN_WELL_CONTENTS' | 'ASPIRATE_FROM_PRISTINE_WELL' + | 'LABWARE_IN_WASTE_CHUTE_HAS_LIQUID' + | 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS' export interface CommandCreatorWarning { message: string diff --git a/step-generation/src/utils/heaterShakerCollision.ts b/step-generation/src/utils/heaterShakerCollision.ts index 3ad086d2086..b64d12c8c50 100644 --- a/step-generation/src/utils/heaterShakerCollision.ts +++ b/step-generation/src/utils/heaterShakerCollision.ts @@ -81,10 +81,11 @@ export const pipetteIntoHeaterShakerLatchOpen = ( labwareId: string ): boolean => { const labwareSlot: string = labware[labwareId]?.slot + const adapterSlot: string = labware[labwareSlot]?.slot const moduleUnderLabware: string | null | undefined = modules && - labwareSlot && - Object.keys(modules).find((moduleId: string) => moduleId === labwareSlot) + adapterSlot && + Object.keys(modules).find((moduleId: string) => moduleId === adapterSlot) const moduleState = moduleUnderLabware && modules[moduleUnderLabware].moduleState const isHSLatchOpen: boolean = Boolean( @@ -114,10 +115,11 @@ export const pipetteIntoHeaterShakerWhileShaking = ( labwareId: string ): boolean => { const labwareSlot: string = labware[labwareId]?.slot + const adapterSlot: string = labware[labwareSlot]?.slot const moduleUnderLabware: string | null | undefined = modules && - labwareSlot && - Object.keys(modules).find((moduleId: string) => moduleId === labwareSlot) + adapterSlot && + Object.keys(modules).find((moduleId: string) => moduleId === adapterSlot) const moduleState = moduleUnderLabware && modules[moduleUnderLabware].moduleState const isShaking: boolean = Boolean( diff --git a/step-generation/src/utils/misc.ts b/step-generation/src/utils/misc.ts index 730dd072f72..ff6c7b1d08c 100644 --- a/step-generation/src/utils/misc.ts +++ b/step-generation/src/utils/misc.ts @@ -8,6 +8,7 @@ import { getLabwareDefURI, getWellsDepth, getWellNamePerMultiTip, + WASTE_CHUTE_SLOT, } from '@opentrons/shared-data' import { blowout } from '../commandCreators/atomic/blowout' import { curryCommandCreator } from './curryCommandCreator' @@ -22,6 +23,7 @@ import type { RobotState, SourceAndDest, } from '../types' +import { AdditionalEquipmentEntities } from '..' export const AIR: '__air__' = '__air__' export const SOURCE_WELL_BLOWOUT_DESTINATION: 'source_well' = 'source_well' export const DEST_WELL_BLOWOUT_DESTINATION: 'dest_well' = 'dest_well' @@ -335,3 +337,35 @@ export function makeInitialRobotState(args: { }, } } + +export const getHasWasteChute = ( + additionalEquipmentEntities: AdditionalEquipmentEntities +): boolean => { + return Object.values(additionalEquipmentEntities).some( + additionalEquipmentEntity => + additionalEquipmentEntity.location === WASTE_CHUTE_SLOT && + additionalEquipmentEntity.name === 'wasteChute' + ) +} + +export const getTiprackHasTips = ( + tipState: RobotState['tipState'], + labwareId: string +): boolean => { + return tipState.tipracks[labwareId] != null + ? Object.values(tipState.tipracks[labwareId]).some( + tipState => tipState === true + ) + : false +} + +export const getLabwareHasLiquid = ( + liquidState: RobotState['liquidState'], + labwareId: string +): boolean => { + return liquidState.labware[labwareId] != null + ? Object.values(liquidState.labware[labwareId]).some(liquidState => + Object.values(liquidState).some(volume => volume.volume > 0) + ) + : false +} diff --git a/step-generation/src/warningCreators.ts b/step-generation/src/warningCreators.ts index 90bba39713e..69959fcbe15 100644 --- a/step-generation/src/warningCreators.ts +++ b/step-generation/src/warningCreators.ts @@ -12,3 +12,15 @@ export function aspirateFromPristineWell(): CommandCreatorWarning { 'Aspirating from a pristine well. No liquids were ever added to this well', } } +export function labwareInWasteChuteHasLiquid(): CommandCreatorWarning { + return { + type: 'LABWARE_IN_WASTE_CHUTE_HAS_LIQUID', + message: 'Disposing of a labware with liquid', + } +} +export function tiprackInWasteChuteHasTips(): CommandCreatorWarning { + return { + type: 'TIPRACK_IN_WASTE_CHUTE_HAS_TIPS', + message: 'Disposing of a tiprack with tips', + } +}