From 5cde3b8083b342a18f909bbe4569d3233f20c8eb Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Mon, 30 Sep 2024 13:24:51 -0500 Subject: [PATCH 001/101] chore(release-notes): ot3@2.1.0-alpha.0 release notes (#16382) # Replacing #16381 --- api/release-notes-internal.md | 6 ++++++ app-shell/build/release-notes-internal.md | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/api/release-notes-internal.md b/api/release-notes-internal.md index 6ddd85c885b..f70cbdc10ca 100644 --- a/api/release-notes-internal.md +++ b/api/release-notes-internal.md @@ -2,6 +2,12 @@ For more details about this release, please see the full [technical change log][ [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.1.0-alpha.0 + +This internal release contains features being developed for 8.1.0. It's for internal testing only. + +- Added support for Verdin IMX8MM Rev E and above which changes the CAN base clock from 20Mhz to 40Mhz. + ## Internal Release 2.0.0-alpha.4 This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. There are no changes to `buildroot`, `ot3-firmware`, or `oe-core` since the last internal release. diff --git a/app-shell/build/release-notes-internal.md b/app-shell/build/release-notes-internal.md index ce6e62da888..6e8f192a01a 100644 --- a/app-shell/build/release-notes-internal.md +++ b/app-shell/build/release-notes-internal.md @@ -1,6 +1,10 @@ For more details about this release, please see the full [technical changelog][]. [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.1.0-alpha.0 + +This internal release contains features being developed for 8.1.0. It's for internal testing only. + ## Internal Release 2.0.0-alpha.4 This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. There are no changes to `buildroot`, `ot3-firmware`, or `oe-core` since the last internal release. From 4bca02a9476a8121544dc48081595f13091d2718 Mon Sep 17 00:00:00 2001 From: koji Date: Thu, 3 Oct 2024 15:17:57 -0400 Subject: [PATCH 002/101] fix(app): get back unboxingFlow path update function (#16411) * fix(app): get back unboxingFlow path update function --- app/src/pages/RobotDashboard/WelcomeModal.tsx | 10 ++++++++++ .../RobotDashboard/__tests__/WelcomeModal.test.tsx | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/pages/RobotDashboard/WelcomeModal.tsx b/app/src/pages/RobotDashboard/WelcomeModal.tsx index d8caef25f9a..67f5b3fdd09 100644 --- a/app/src/pages/RobotDashboard/WelcomeModal.tsx +++ b/app/src/pages/RobotDashboard/WelcomeModal.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' import { COLORS, @@ -14,10 +15,12 @@ import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { SmallButton } from '../../atoms/buttons' import { Modal } from '../../molecules/Modal' +import { updateConfigValue } from '../../redux/config' import welcomeModalImage from '../../assets/images/on-device-display/welcome_dashboard_modal.png' import type { SetStatusBarCreateCommand } from '@opentrons/shared-data' +import type { Dispatch } from '../../redux/types' interface WelcomeModalProps { setShowWelcomeModal: (showWelcomeModal: boolean) => void @@ -27,6 +30,7 @@ export function WelcomeModal({ setShowWelcomeModal, }: WelcomeModalProps): JSX.Element { const { t } = useTranslation(['device_details', 'shared']) + const dispatch = useDispatch() const { createLiveCommand } = useCreateLiveCommandMutation() const animationCommand: SetStatusBarCreateCommand = { @@ -44,6 +48,12 @@ export function WelcomeModal({ } const handleCloseModal = (): void => { + dispatch( + updateConfigValue( + 'onDeviceDisplaySettings.unfinishedUnboxingFlowRoute', + null + ) + ) setShowWelcomeModal(false) } diff --git a/app/src/pages/RobotDashboard/__tests__/WelcomeModal.test.tsx b/app/src/pages/RobotDashboard/__tests__/WelcomeModal.test.tsx index ec3fc2fc5ff..d01fed2b265 100644 --- a/app/src/pages/RobotDashboard/__tests__/WelcomeModal.test.tsx +++ b/app/src/pages/RobotDashboard/__tests__/WelcomeModal.test.tsx @@ -6,12 +6,13 @@ import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' +import { updateConfigValue } from '../../../redux/config' import { WelcomeModal } from '../WelcomeModal' import type { SetStatusBarCreateCommand } from '@opentrons/shared-data' -vi.mock('../../../redux/config') vi.mock('@opentrons/react-api-client') +vi.mock('../../../redux/config') const mockFunc = vi.fn() const WELCOME_MODAL_IMAGE_NAME = 'welcome_dashboard_modal.png' @@ -61,6 +62,10 @@ describe('WelcomeModal', () => { it('should call a mock function when tapping next button', () => { render(props) fireEvent.click(screen.getByText('Next')) + expect(vi.mocked(updateConfigValue)).toHaveBeenCalledWith( + 'onDeviceDisplaySettings.unfinishedUnboxingFlowRoute', + null + ) expect(props.setShowWelcomeModal).toHaveBeenCalled() }) }) From 00ba2e6cf895755bd31fd73e0fc9c457bf25a497 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Thu, 3 Oct 2024 17:23:06 -0400 Subject: [PATCH 003/101] chore: 8.1.0 alpha release notes (#16379) --- api/release-notes.md | 10 ++++++++++ app-shell/build/release-notes.md | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/api/release-notes.md b/api/release-notes.md index aad0cadea7a..2a528502bef 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -8,6 +8,16 @@ By installing and using Opentrons software, you agree to the Opentrons End-User --- +## Opentrons Robot Software Changes in 8.1.0 + +Welcome to the v8.1.0 release of the Opentrons robot software! + +### Hardware Support + +- Latest production version of Flex robots + +--- + ## Opentrons Robot Software Changes in 8.0.0 Welcome to the v8.0.0 release of the Opentrons robot software! diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 80b36253b85..5a425ce0cc6 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -8,6 +8,14 @@ By installing and using Opentrons software, you agree to the Opentrons End-User --- +## Opentrons App Changes in 8.1.0 + +Welcome to the v8.1.0 release of the Opentrons App! + +There are no changes to the Opentrons App in v8.1.0, but it is required for updating the robot software to support the latest production version of Flex robots. + +--- + ## Opentrons App Changes in 8.0.0 Welcome to the v8.0.0 release of the Opentrons App! From 69049a5bde51d43a1ffa453ae995674c2bb6d33b Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 7 Oct 2024 09:26:28 -0400 Subject: [PATCH 004/101] feat(protocol-designer): use i18n for getBlowoutLocationOptionsForForm (#16422) * feat(protocol-designer): use i18n for getBlowoutLocationOptionsForForm --- .../src/assets/localization/en/shared.json | 2 + .../StepForm/__tests__/utils.test.ts | 59 +++++++++++++++++++ .../Designer/ProtocolSteps/StepForm/utils.ts | 5 +- .../__tests__/ProtocolSteps.test.tsx | 7 +++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 92872373624..4da0ce66196 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -10,6 +10,7 @@ "confirm": "Confirm", "create_a_protocol": "Create a protocol", "create_new": "Create new", + "destination_well": "Destination Well", "developer_ff": "Developer feature flags", "done": "Done", "edit_existing": "Edit existing protocol", @@ -106,6 +107,7 @@ "shared_sessions": "Share sessions with Opentrons", "shares_name": "This labware has the same load name or display name as {{customOrStandard}}, which is already in this protocol.", "slot_detail": "Slot Detail", + "source_well": "Source Well", "stagingArea": "Staging area", "step_count": "Step {{current}}", "step": "Step {{current}} / {{max}}", diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts new file mode 100644 index 00000000000..e95aad5d427 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/__tests__/utils.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { + SOURCE_WELL_BLOWOUT_DESTINATION, + DEST_WELL_BLOWOUT_DESTINATION, +} from '@opentrons/step-generation' +import { getBlowoutLocationOptionsForForm } from '../utils' + +describe('getBlowoutLocationOptionsForForm', () => { + const destOption = { + name: 'Destination Well', + value: DEST_WELL_BLOWOUT_DESTINATION, + } + const sourceOption = { + name: 'Source Well', + value: SOURCE_WELL_BLOWOUT_DESTINATION, + } + + it('should return destination option for mix stepType', () => { + const result = getBlowoutLocationOptionsForForm({ stepType: 'mix' }) + expect(result).toEqual([destOption]) + }) + + it('should return source and destination options for moveLiquid stepType with single path', () => { + const result = getBlowoutLocationOptionsForForm({ + stepType: 'moveLiquid', + path: 'single', + }) + expect(result).toEqual([sourceOption, destOption]) + }) + + it('should return source option and disabled destination option for moveLiquid stepType with multiDispense path', () => { + const result = getBlowoutLocationOptionsForForm({ + stepType: 'moveLiquid', + path: 'multiDispense', + }) + expect(result).toEqual([sourceOption, { ...destOption, disabled: true }]) + }) + + it('should return disabled source option and destination option for moveLiquid stepType with multiAspirate path', () => { + const result = getBlowoutLocationOptionsForForm({ + stepType: 'moveLiquid', + path: 'multiAspirate', + }) + expect(result).toEqual([{ ...sourceOption, disabled: true }, destOption]) + }) + + it('should return disabled source and destination options for moveLiquid stepType with undefined path', () => { + const result = getBlowoutLocationOptionsForForm({ stepType: 'moveLiquid' }) + expect(result).toEqual([ + { ...sourceOption, disabled: true }, + { ...destOption, disabled: true }, + ]) + }) + + it('should return an empty array for unknown stepType', () => { + const result = getBlowoutLocationOptionsForForm({ stepType: 'comment' }) + expect(result).toEqual([]) + }) +}) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts index 15a6c6b7ce7..9293b6b6c64 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts @@ -11,6 +11,7 @@ import { getDisabledFields, getDefaultsForStepType, } from '../../../../steplist/formLevel' +import { i18n } from '../../../../assets/localization' import { PROFILE_CYCLE } from '../../../../form-types' import type { PipetteEntity } from '@opentrons/step-generation' import type { Options } from '@opentrons/components' @@ -34,11 +35,11 @@ export function getBlowoutLocationOptionsForForm(args: { const { stepType, path } = args // TODO: Ian 2019-02-21 use i18n for names const destOption = { - name: 'Destination Well', + name: i18n.t('shared:destination_well'), value: DEST_WELL_BLOWOUT_DESTINATION, } const sourceOption = { - name: 'Source Well', + name: i18n.t('shared:source_well'), value: SOURCE_WELL_BLOWOUT_DESTINATION, } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx index 41f6f014227..db393cc693e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx @@ -15,6 +15,13 @@ vi.mock('../../../../ui/steps/selectors') vi.mock('../StepForm') vi.mock('../../DeckSetup') vi.mock('../Timeline') + +vi.mock('../../../../assets/localization', () => ({ + t: vi.fn().mockReturnValue({ + t: (key: string) => key, + }), +})) + const render = () => { return renderWithProviders()[0] } From 9ac510595e85cd126082d0b387f8870d35475720 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Mon, 7 Oct 2024 09:42:09 -0500 Subject: [PATCH 005/101] chore(release): notes for Internal Release 2.2.0-alpha.0 (#16425) # Overview Internal release notes for Internal Release 2.2.0-alpha.0. Tagging edge in the monorepo and HEADs on "main" branches of dependency repositories if necessary. --- api/release-notes-internal.md | 4 ++++ app-shell/build/release-notes-internal.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/api/release-notes-internal.md b/api/release-notes-internal.md index 6ddd85c885b..2d99270edc2 100644 --- a/api/release-notes-internal.md +++ b/api/release-notes-internal.md @@ -2,6 +2,10 @@ For more details about this release, please see the full [technical change log][ [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.2.0-alpha.0 + +This internal release, pulled from the `edge` branch, contains features being developed for 8.2.0. It's for internal testing only. + ## Internal Release 2.0.0-alpha.4 This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. There are no changes to `buildroot`, `ot3-firmware`, or `oe-core` since the last internal release. diff --git a/app-shell/build/release-notes-internal.md b/app-shell/build/release-notes-internal.md index ce6e62da888..5316ca3ad71 100644 --- a/app-shell/build/release-notes-internal.md +++ b/app-shell/build/release-notes-internal.md @@ -1,6 +1,10 @@ For more details about this release, please see the full [technical changelog][]. [technical change log]: https://github.com/Opentrons/opentrons/releases +## Internal Release 2.2.0-alpha.0 + +This internal release, pulled from the `edge` branch, contains features being developed for 8.2.0. It's for internal testing only. + ## Internal Release 2.0.0-alpha.4 This internal release, pulled from the `edge` branch, contains features being developed for 8.0.0. It's for internal testing only. There are no changes to `buildroot`, `ot3-firmware`, or `oe-core` since the last internal release. From 56cd3614a572cc463281c096e4710f35c4fb145e Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Mon, 7 Oct 2024 11:11:46 -0400 Subject: [PATCH 006/101] feat(robot-server): Wire up global error recovery setting (#16416) ## Overview Closes EXEC-719. ## Changelog * Connect the new `PATCH /errorRecovery/settings` HTTP API so it actually affects run behavior. This closes EXEC-719. * One quirk to my implementation: This will not affect preexisting runs, *unless* you *also* do `PUT /runs/{id}/errorRecoveryPolicy`. In effect, `PUT /runs/{id}/errorRecoveryPolicy` refreshes the run's snapshot of `/errorRecovery/settings`. This is probably confusing; it would be better if `PATCH /errorRecovery/settings` immediately affected the preexisting run, or if `PUT /runs/{id}/errorRecoveryPolicy` did not have that refresh effect. I don't think this matters in practice because this button will be disabled or inaccessible in our UIs while there's an active run. Also fix a couple of nearby, unticketed bugs: * If a client gave us an empty error recovery policy, we were no-opping. Instead, we want to overwrite the current policy with the new empty one. * The logic in `error_recovery_mapping.py` was unintentionally enabling error recovery on OT-2 robots if the policy was specific enough. We want it to always be disabled on OT-2s. --- .../protocol_engine/error_recovery_policy.py | 1 + .../error_recovery/settings/router.py | 4 - .../error_recovery/settings/store.py | 13 +- .../robot_server/runs/dependencies.py | 10 +- .../runs/error_recovery_mapping.py | 121 +++++++---- .../runs/error_recovery_models.py | 49 +++-- .../robot_server/runs/router/base_router.py | 17 +- .../robot_server/runs/run_data_manager.py | 34 ++- .../runs/run_orchestrator_store.py | 8 +- .../error_recovery/settings/test_store.py | 4 +- .../tests/runs/router/test_base_router.py | 8 +- .../tests/runs/test_error_recovery_mapping.py | 6 +- .../tests/runs/test_run_data_manager.py | 199 +++++++++--------- ...tore.py => test_run_orchestrator_store.py} | 12 ++ 14 files changed, 283 insertions(+), 203 deletions(-) rename robot-server/tests/runs/{test_engine_store.py => test_run_orchestrator_store.py} (94%) diff --git a/api/src/opentrons/protocol_engine/error_recovery_policy.py b/api/src/opentrons/protocol_engine/error_recovery_policy.py index f9f39d99f4d..d959651393e 100644 --- a/api/src/opentrons/protocol_engine/error_recovery_policy.py +++ b/api/src/opentrons/protocol_engine/error_recovery_policy.py @@ -40,6 +40,7 @@ class ErrorRecoveryPolicy(Protocol): and return an appropriate `ErrorRecoveryType`. Args: + config: The config of the calling `ProtocolEngine`. failed_command: The command that failed, in its final `status=="failed"` state. defined_error_data: If the command failed with a defined error, details about that error. If the command failed with an undefined error, `None`. diff --git a/robot-server/robot_server/error_recovery/settings/router.py b/robot-server/robot_server/error_recovery/settings/router.py index 1a302b05582..4fdfeee5498 100644 --- a/robot-server/robot_server/error_recovery/settings/router.py +++ b/robot-server/robot_server/error_recovery/settings/router.py @@ -61,10 +61,6 @@ async def _get_current_response( store: ErrorRecoverySettingStore, ) -> PydanticResponse[SimpleBody[ResponseData]]: is_enabled = store.get_is_enabled() - if is_enabled is None: - # todo(mm, 2024-09-30): This defaulting will probably need to move down a layer - # when we connect this setting to `POST /runs`. - is_enabled = True return await PydanticResponse.create( SimpleBody.construct(data=ResponseData.construct(enabled=is_enabled)) ) diff --git a/robot-server/robot_server/error_recovery/settings/store.py b/robot-server/robot_server/error_recovery/settings/store.py index 6cef66aae2e..7bad6b5c77c 100644 --- a/robot-server/robot_server/error_recovery/settings/store.py +++ b/robot-server/robot_server/error_recovery/settings/store.py @@ -10,24 +10,25 @@ from robot_server.persistence.tables import boolean_setting_table, BooleanSettingKey +_ERROR_RECOVERY_ENABLED_DEFAULT = True + + class ErrorRecoverySettingStore: """Persistently stores settings related to error recovery.""" def __init__(self, sql_engine: sqlalchemy.engine.Engine) -> None: self._sql_engine = sql_engine - def get_is_enabled(self) -> bool | None: - """Get the value of the "error recovery enabled" setting. - - `None` is the default, i.e. it's never been explicitly set one way or the other. - """ + def get_is_enabled(self) -> bool: + """Get the value of the "error recovery enabled" setting.""" with self._sql_engine.begin() as transaction: - return transaction.execute( + result: bool | None = transaction.execute( sqlalchemy.select(boolean_setting_table.c.value).where( boolean_setting_table.c.key == BooleanSettingKey.ENABLE_ERROR_RECOVERY ) ).scalar_one_or_none() + return result if result is not None else _ERROR_RECOVERY_ENABLED_DEFAULT def set_is_enabled(self, is_enabled: bool | None) -> None: """Set the value of the "error recovery enabled" setting. diff --git a/robot-server/robot_server/runs/dependencies.py b/robot-server/robot_server/runs/dependencies.py index 146297715a9..60c96d1e5e6 100644 --- a/robot-server/robot_server/runs/dependencies.py +++ b/robot-server/robot_server/runs/dependencies.py @@ -2,6 +2,10 @@ from typing import Annotated from fastapi import Depends, status +from robot_server.error_recovery.settings.store import ( + ErrorRecoverySettingStore, + get_error_recovery_setting_store, +) from robot_server.protocols.dependencies import get_protocol_store from robot_server.protocols.protocol_models import ProtocolKind from robot_server.protocols.protocol_store import ProtocolStore @@ -156,12 +160,16 @@ async def get_run_data_manager( ], run_store: Annotated[RunStore, Depends(get_run_store)], runs_publisher: Annotated[RunsPublisher, Depends(get_runs_publisher)], + error_recovery_setting_store: Annotated[ + ErrorRecoverySettingStore, Depends(get_error_recovery_setting_store) + ], ) -> RunDataManager: """Get a run data manager to keep track of current/historical run data.""" return RunDataManager( - task_runner=task_runner, run_orchestrator_store=run_orchestrator_store, run_store=run_store, + error_recovery_setting_store=error_recovery_setting_store, + task_runner=task_runner, runs_publisher=runs_publisher, ) diff --git a/robot-server/robot_server/runs/error_recovery_mapping.py b/robot-server/robot_server/runs/error_recovery_mapping.py index fb936eddadd..d29ebf4b054 100644 --- a/robot-server/robot_server/runs/error_recovery_mapping.py +++ b/robot-server/robot_server/runs/error_recovery_mapping.py @@ -1,5 +1,4 @@ """Functions used for managing error recovery policy.""" -from typing import Optional from opentrons.protocol_engine.state.config import Config from robot_server.runs.error_recovery_models import ErrorRecoveryRule, ReactionIfMatch from opentrons.protocol_engine.commands.command_unions import ( @@ -14,52 +13,96 @@ def create_error_recovery_policy_from_rules( rules: list[ErrorRecoveryRule], + enabled: bool, ) -> ErrorRecoveryPolicy: - """Given a list of error recovery rules return an error recovery policy.""" + """Map a robot-server error recovery policy to an opentrons.protocol_engine one. - def _policy( + In its HTTP API, robot-server expresses error recovery policies as Pydantic models. + But opentrons.protocol_engine is more general, expressing them as Python callables. + + Args: + rules: The rules in the robot-server error recovery policy. + enabled: Whether error recovery should be enabled at all. + If `False`, `rules` is ignored. + + Returns: + An error recovery policy in `opentrons.protocol_engine` terms. + """ + + def mapped_policy( config: Config, failed_command: Command, - defined_error_data: Optional[CommandDefinedErrorData], + defined_error_data: CommandDefinedErrorData | None, ) -> ErrorRecoveryType: - for rule in rules: - command_type_matches = ( - failed_command.commandType == rule.matchCriteria.command.commandType - ) - error_type_matches = ( - defined_error_data is not None - and defined_error_data.public.errorType - == rule.matchCriteria.command.error.errorType + first_matching_rule = next( + ( + rule + for rule in rules + if _rule_matches_error(rule, failed_command, defined_error_data) + ), + None, + ) + robot_is_flex = config.robot_type == "OT-3 Standard" + error_is_defined = defined_error_data is not None + + if not enabled: + return ErrorRecoveryType.FAIL_RUN + elif not robot_is_flex: + # Although error recovery can theoretically work on OT-2s, we haven't tested + # it, and it's generally scarier because the OT-2 has much less hardware + # feedback. + return ErrorRecoveryType.FAIL_RUN + elif first_matching_rule is not None: + # The input policy explicitly deals this error, so do what it says. + return _map_error_recovery_type(first_matching_rule.ifMatch) + else: + # The input policy doesn't explicitly deal with this error, so the decision + # is our own. + # + # We try to WAIT_FOR_RECOVERY whenever we can, for two reasons: + # + # 1. It matches the frontend's current expectations. + # For example, the frontend expects us to WAIT_FOR_RECOVERY on + # overpressure errors, but it does not send us an error recovery policy + # that explicitly says that; it relies on this default. + # 2. Philosophically, we always want to give the operator a shot at + # recovery, even if we don't know the details of the problem and can't + # guarantee good robot behavior if they keep using it. + # + # We currently FAIL_RUN for undefined errors, with the thinking that they + # are especially likely to have messed something up in Protocol Engine's + # internal state, and that they are especially likely to cause confusing + # behavior. But we might want to change that--see point (2) above. + return ( + ErrorRecoveryType.WAIT_FOR_RECOVERY + if error_is_defined + else ErrorRecoveryType.FAIL_RUN ) - if command_type_matches and error_type_matches: - if rule.ifMatch == ReactionIfMatch.IGNORE_AND_CONTINUE: - return ErrorRecoveryType.IGNORE_AND_CONTINUE - elif rule.ifMatch == ReactionIfMatch.FAIL_RUN: - return ErrorRecoveryType.FAIL_RUN - elif rule.ifMatch == ReactionIfMatch.WAIT_FOR_RECOVERY: - return ErrorRecoveryType.WAIT_FOR_RECOVERY + return mapped_policy - return default_error_recovery_policy(config, failed_command, defined_error_data) - return _policy +def _rule_matches_error( + rule: ErrorRecoveryRule, + failed_command: Command, + defined_error_data: CommandDefinedErrorData | None, +) -> bool: + command_type_matches = ( + failed_command.commandType == rule.matchCriteria.command.commandType + ) + error_type_matches = ( + defined_error_data is not None + and defined_error_data.public.errorType + == rule.matchCriteria.command.error.errorType + ) + return command_type_matches and error_type_matches -def default_error_recovery_policy( - config: Config, - failed_command: Command, - defined_error_data: Optional[CommandDefinedErrorData], -) -> ErrorRecoveryType: - """The `ErrorRecoveryPolicy` to use when none has been set on a run.""" - # Although error recovery can theoretically work on OT-2s, we haven't tested it, - # and it's generally scarier because the OT-2 has much less hardware feedback. - robot_is_flex = config.robot_type == "OT-3 Standard" - # If the error is defined, we're taking that to mean that we should - # WAIT_FOR_RECOVERY. This is not necessarily the right long-term logic--we might - # want to FAIL_RUN on certain defined errors and WAIT_FOR_RECOVERY on certain - # undefined errors--but this is convenient for now. - error_is_defined = defined_error_data is not None - if robot_is_flex and error_is_defined: - return ErrorRecoveryType.WAIT_FOR_RECOVERY - else: - return ErrorRecoveryType.FAIL_RUN +def _map_error_recovery_type(reaction_if_match: ReactionIfMatch) -> ErrorRecoveryType: + match reaction_if_match: + case ReactionIfMatch.IGNORE_AND_CONTINUE: + return ErrorRecoveryType.IGNORE_AND_CONTINUE + case ReactionIfMatch.FAIL_RUN: + return ErrorRecoveryType.FAIL_RUN + case ReactionIfMatch.WAIT_FOR_RECOVERY: + return ErrorRecoveryType.WAIT_FOR_RECOVERY diff --git a/robot-server/robot_server/runs/error_recovery_models.py b/robot-server/robot_server/runs/error_recovery_models.py index 5558c65a8ac..a2990a007cb 100644 --- a/robot-server/robot_server/runs/error_recovery_models.py +++ b/robot-server/robot_server/runs/error_recovery_models.py @@ -4,13 +4,31 @@ from pydantic import BaseModel, Field +# There's a lot of nested classes here. +# Here's an example of a JSON document that this code models: +# { +# "policyRules": [ +# { +# "matchCriteria": { +# "command": { +# "commandType": "foo", +# "error": { +# "errorType": "bar" +# } +# } +# }, +# "ifMatch": "ignoreAndContinue" +# } +# ] +# } + class ReactionIfMatch(Enum): - """The type of the error recovery setting. + """How to handle a given error. - * `"ignoreAndContinue"`: Ignore this error and future errors of the same type. - * `"failRun"`: Errors of this type should fail the run. - * `"waitForRecovery"`: Instances of this error should initiate a recover operation. + * `"ignoreAndContinue"`: Ignore this error and continue with the next command. + * `"failRun"`: Fail the run. + * `"waitForRecovery"`: Enter interactive error recovery mode. """ @@ -19,20 +37,6 @@ class ReactionIfMatch(Enum): WAIT_FOR_RECOVERY = "waitForRecovery" -# There's a lot of nested classes here. This is the JSON schema this code models. -# "ErrorRecoveryRule": { -# "matchCriteria": { -# "command": { -# "commandType": "foo", -# "error": { -# "errorType": "bar" -# } -# } -# }, -# "ifMatch": "baz" -# } - - class ErrorMatcher(BaseModel): """The error type that this rule applies to.""" @@ -67,7 +71,7 @@ class ErrorRecoveryRule(BaseModel): ) ifMatch: ReactionIfMatch = Field( ..., - description="The specific recovery setting that will be in use if the type parameters match.", + description="How to handle errors matched by this rule.", ) @@ -76,6 +80,9 @@ class ErrorRecoveryPolicy(BaseModel): policyRules: List[ErrorRecoveryRule] = Field( ..., - description="A list or error recovery rules to apply for a run's recovery management." - "The rules are evaluated first-to-last. The first exact match will dectate recovery management.", + description=( + "A list of error recovery rules to apply for a run's recovery management." + " The rules are evaluated first-to-last." + " The first exact match will dictate recovery management." + ), ) diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 3b0e7040e02..b9bd8cd24b2 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -426,11 +426,13 @@ async def update_run( @PydanticResponse.wrap_route( base_router.put, path="/runs/{runId}/errorRecoveryPolicy", - summary="Set run policies", + summary="Set a run's error recovery policy", description=dedent( """ Update how to handle different kinds of command failures. - The following rules will persist during the run. + + For this to have any effect, error recovery must also be enabled globally. + See `PATCH /errorRecovery/settings`. """ ), status_code=status.HTTP_201_CREATED, @@ -451,12 +453,11 @@ async def put_error_recovery_policy( request_body: Request body with run policies data. run_data_manager: Current and historical run data management. """ - policies = request_body.data.policyRules - if policies: - try: - run_data_manager.set_policies(run_id=runId, policies=policies) - except RunNotCurrentError as e: - raise RunStopped(detail=str(e)).as_error(status.HTTP_409_CONFLICT) from e + rules = request_body.data.policyRules + try: + run_data_manager.set_error_recovery_rules(run_id=runId, rules=rules) + except RunNotCurrentError as e: + raise RunStopped(detail=str(e)).as_error(status.HTTP_409_CONFLICT) from e return await PydanticResponse.create( content=SimpleEmptyBody.construct(), diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index b756b185bf1..4168b1d4d5d 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -22,6 +22,7 @@ CSVRuntimeParamPaths, ) +from robot_server.error_recovery.settings.store import ErrorRecoverySettingStore from robot_server.protocols.protocol_store import ProtocolResource from robot_server.service.task_runner import TaskRunner from robot_server.service.notifications import RunsPublisher @@ -35,6 +36,9 @@ from opentrons.protocol_engine.types import DeckConfigurationType, RunTimeParameter +_INITIAL_ERROR_RECOVERY_RULES: list[ErrorRecoveryRule] = [] + + def _build_run( run_resource: Union[RunResource, BadRunResource], state_summary: Union[StateSummary, BadStateSummary], @@ -145,11 +149,13 @@ def __init__( self, run_orchestrator_store: RunOrchestratorStore, run_store: RunStore, + error_recovery_setting_store: ErrorRecoverySettingStore, task_runner: TaskRunner, runs_publisher: RunsPublisher, ) -> None: self._run_orchestrator_store = run_orchestrator_store self._run_store = run_store + self._error_recovery_setting_store = error_recovery_setting_store self._task_runner = task_runner self._runs_publisher = runs_publisher @@ -190,6 +196,7 @@ async def create( """ prev_run_id = self._run_orchestrator_store.current_run_id if prev_run_id is not None: + # Allow clear() to propagate RunConflictError. prev_run_result = await self._run_orchestrator_store.clear() self._run_store.update_run_state( run_id=prev_run_id, @@ -197,9 +204,18 @@ async def create( commands=prev_run_result.commands, run_time_parameters=prev_run_result.parameters, ) + + error_recovery_is_enabled = self._error_recovery_setting_store.get_is_enabled() + initial_error_recovery_policy = ( + error_recovery_mapping.create_error_recovery_policy_from_rules( + _INITIAL_ERROR_RECOVERY_RULES, error_recovery_is_enabled + ) + ) + state_summary = await self._run_orchestrator_store.create( run_id=run_id, labware_offsets=labware_offsets, + initial_error_recovery_policy=initial_error_recovery_policy, deck_configuration=deck_configuration, protocol=protocol, run_time_param_values=run_time_param_values, @@ -215,6 +231,7 @@ async def create( self._run_store.insert_csv_rtp( run_id=run_id, run_time_parameters=run_time_parameters ) + self._runs_publisher.start_publishing_for_run( get_current_command=self.get_current_command, get_recovery_target_command=self.get_recovery_target_command, @@ -498,16 +515,23 @@ def get_all_commands_as_preserialized_list( run_id, include_fixit_commands ) - def set_policies(self, run_id: str, policies: List[ErrorRecoveryRule]) -> None: - """Create run policy rules for error recovery.""" + def set_error_recovery_rules( + self, run_id: str, rules: List[ErrorRecoveryRule] + ) -> None: + """Set the run's error recovery policy. + + The input rules get combined with the global error recovery enabled/disabled + setting, which this method retrieves automatically. + """ if run_id != self._run_orchestrator_store.current_run_id: raise RunNotCurrentError( f"Cannot update {run_id} because it is not the current run." ) - policy = error_recovery_mapping.create_error_recovery_policy_from_rules( - policies + is_enabled = self._error_recovery_setting_store.get_is_enabled() + mapped_policy = error_recovery_mapping.create_error_recovery_policy_from_rules( + rules, is_enabled ) - self._run_orchestrator_store.set_error_recovery_policy(policy=policy) + self._run_orchestrator_store.set_error_recovery_policy(policy=mapped_policy) def _get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary]: if run_id == self._run_orchestrator_store.current_run_id: diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 46a384b96ea..03af7315ef9 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -53,8 +53,6 @@ ) from opentrons_shared_data.labware.types import LabwareUri -from .error_recovery_mapping import default_error_recovery_policy - _log = logging.getLogger(__name__) @@ -119,8 +117,6 @@ def run_handler_in_engine_thread_from_hardware_thread( class RunOrchestratorStore: """Factory and in-memory storage for ProtocolEngine.""" - _run_orchestrator: Optional[RunOrchestrator] = None - def __init__( self, hardware_api: HardwareControlAPI, @@ -138,6 +134,7 @@ def __init__( self._hardware_api = hardware_api self._robot_type = robot_type self._deck_type = deck_type + self._run_orchestrator: Optional[RunOrchestrator] = None self._default_run_orchestrator: Optional[RunOrchestrator] = None hardware_api.register_callback(_get_estop_listener(self)) @@ -194,6 +191,7 @@ async def create( self, run_id: str, labware_offsets: List[LabwareOffsetCreate], + initial_error_recovery_policy: error_recovery_policy.ErrorRecoveryPolicy, deck_configuration: DeckConfigurationType, notify_publishers: Callable[[], None], protocol: Optional[ProtocolResource], @@ -235,7 +233,7 @@ async def create( RobotTypeEnum.robot_literal_to_enum(self._robot_type) ), ), - error_recovery_policy=default_error_recovery_policy, + error_recovery_policy=initial_error_recovery_policy, load_fixed_trash=load_fixed_trash, deck_configuration=deck_configuration, notify_publishers=notify_publishers, diff --git a/robot-server/tests/error_recovery/settings/test_store.py b/robot-server/tests/error_recovery/settings/test_store.py index cc69f5d307f..e67f5e72ee9 100644 --- a/robot-server/tests/error_recovery/settings/test_store.py +++ b/robot-server/tests/error_recovery/settings/test_store.py @@ -17,7 +17,7 @@ def subject( def test_error_recovery_setting_store(subject: ErrorRecoverySettingStore) -> None: """Test `ErrorRecoverySettingStore`.""" - assert subject.get_is_enabled() is None + assert subject.get_is_enabled() is True subject.set_is_enabled(is_enabled=False) assert subject.get_is_enabled() is False @@ -26,4 +26,4 @@ def test_error_recovery_setting_store(subject: ErrorRecoverySettingStore) -> Non assert subject.get_is_enabled() is True subject.set_is_enabled(is_enabled=None) - assert subject.get_is_enabled() is None + assert subject.get_is_enabled() is True diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 37e5cd6dd3d..8a10af1940d 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -698,8 +698,8 @@ async def test_create_policies( run_data_manager=mock_run_data_manager, ) decoy.verify( - mock_run_data_manager.set_policies( - run_id="rud-id", policies=policies.policyRules + mock_run_data_manager.set_error_recovery_rules( + run_id="rud-id", rules=policies.policyRules ) ) @@ -710,8 +710,8 @@ async def test_create_policies_raises_not_active_run( """It should raise that the run is not current.""" policies = decoy.mock(cls=ErrorRecoveryPolicy) decoy.when( - mock_run_data_manager.set_policies( - run_id="rud-id", policies=policies.policyRules + mock_run_data_manager.set_error_recovery_rules( + run_id="rud-id", rules=policies.policyRules ) ).then_raise(RunNotCurrentError()) with pytest.raises(ApiError) as exc_info: diff --git a/robot-server/tests/runs/test_error_recovery_mapping.py b/robot-server/tests/runs/test_error_recovery_mapping.py index a142fbc5e30..fba8e4315d9 100644 --- a/robot-server/tests/runs/test_error_recovery_mapping.py +++ b/robot-server/tests/runs/test_error_recovery_mapping.py @@ -71,7 +71,7 @@ def test_create_error_recovery_policy_with_rules( mock_rule: ErrorRecoveryRule, ) -> None: """Should return IGNORE_AND_CONTINUE if that's what we specify as the rule.""" - policy = create_error_recovery_policy_from_rules([mock_rule]) + policy = create_error_recovery_policy_from_rules([mock_rule], enabled=True) exampleConfig = Config( robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, @@ -86,7 +86,7 @@ def test_create_error_recovery_policy_undefined_error( decoy: Decoy, mock_command: LiquidProbe ) -> None: """Should return a FAIL_RUN policy when error is not defined.""" - policy = create_error_recovery_policy_from_rules(rules=[]) + policy = create_error_recovery_policy_from_rules(rules=[], enabled=True) exampleConfig = Config( robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, @@ -99,7 +99,7 @@ def test_create_error_recovery_policy_defined_error( decoy: Decoy, mock_command: LiquidProbe, mock_error_data: CommandDefinedErrorData ) -> None: """Should return a WAIT_FOR_RECOVERY policy when error is defined.""" - policy = create_error_recovery_policy_from_rules(rules=[]) + policy = create_error_recovery_policy_from_rules(rules=[], enabled=True) exampleConfig = Config( robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 49399447597..981a0e7177c 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -1,10 +1,10 @@ """Tests for RunDataManager.""" from datetime import datetime from typing import Optional, List, Dict +from unittest.mock import sentinel import pytest from decoy import Decoy, matchers -from pathlib import Path from opentrons.protocol_engine import ( EngineStatus, @@ -21,16 +21,15 @@ LabwareOffset, Liquid, ) -from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryPolicy from opentrons.protocol_engine.types import BooleanParameter, CSVParameter from opentrons.protocol_runner import RunResult -from opentrons.types import DeckSlotName from opentrons.hardware_control.nozzle_manager import NozzleMap from opentrons_shared_data.errors.exceptions import InvalidStoredData from opentrons_shared_data.labware.labware_definition import LabwareDefinition +from robot_server.error_recovery.settings.store import ErrorRecoverySettingStore from robot_server.protocols.protocol_models import ProtocolKind from robot_server.protocols.protocol_store import ProtocolResource from robot_server.runs import error_recovery_mapping @@ -74,6 +73,12 @@ def mock_run_store(decoy: Decoy) -> RunStore: return decoy.mock(cls=RunStore) +@pytest.fixture +def mock_error_recovery_setting_store(decoy: Decoy) -> ErrorRecoverySettingStore: + """Get a mock ErrorRecoverySettingStore.""" + return decoy.mock(cls=ErrorRecoverySettingStore) + + @pytest.fixture() def mock_task_runner(decoy: Decoy) -> TaskRunner: """Get a mock background TaskRunner.""" @@ -102,6 +107,20 @@ def engine_state_summary() -> StateSummary: ) +@pytest.fixture(autouse=True) +def patch_error_recovery_mapping(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None: + """Replace the members of the error_recovery_mapping module with mocks.""" + monkeypatch.setattr( + error_recovery_mapping, + "create_error_recovery_policy_from_rules", + decoy.mock( + func=decoy.mock( + func=error_recovery_mapping.create_error_recovery_policy_from_rules + ) + ), + ) + + @pytest.fixture() def run_time_parameters() -> List[pe_types.RunTimeParameter]: """Get a RunTimeParameter list.""" @@ -150,6 +169,7 @@ def run_command() -> commands.Command: def subject( mock_run_orchestrator_store: RunOrchestratorStore, mock_run_store: RunStore, + mock_error_recovery_setting_store: ErrorRecoverySettingStore, mock_task_runner: TaskRunner, mock_runs_publisher: RunsPublisher, ) -> RunDataManager: @@ -157,6 +177,7 @@ def subject( return RunDataManager( run_orchestrator_store=mock_run_orchestrator_store, run_store=mock_run_store, + error_recovery_setting_store=mock_error_recovery_setting_store, task_runner=mock_task_runner, runs_publisher=mock_runs_publisher, ) @@ -166,6 +187,7 @@ async def test_create( decoy: Decoy, mock_run_orchestrator_store: RunOrchestratorStore, mock_run_store: RunStore, + mock_error_recovery_setting_store: ErrorRecoverySettingStore, subject: RunDataManager, engine_state_summary: StateSummary, run_resource: RunResource, @@ -173,102 +195,33 @@ async def test_create( """It should create an engine and a persisted run resource.""" run_id = "hello world" created_at = datetime(year=2021, month=1, day=1) - - decoy.when( - await mock_run_orchestrator_store.create( - run_id=run_id, - labware_offsets=[], - protocol=None, - deck_configuration=[], - run_time_param_values=None, - run_time_param_paths=None, - notify_publishers=mock_notify_publishers, - ) - ).then_return(engine_state_summary) - - decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return([]) - - decoy.when( - mock_run_store.insert( - run_id=run_id, - protocol_id=None, - created_at=created_at, - ) - ).then_return(run_resource) - - decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return([]) - - result = await subject.create( - run_id=run_id, - created_at=created_at, - labware_offsets=[], - protocol=None, - deck_configuration=[], - run_time_param_values=None, - run_time_param_paths=None, - notify_publishers=mock_notify_publishers, - ) - - assert result == Run( - id=run_resource.run_id, - protocolId=run_resource.protocol_id, - createdAt=run_resource.created_at, - current=True, - actions=run_resource.actions, - status=engine_state_summary.status, - errors=engine_state_summary.errors, - hasEverEnteredErrorRecovery=engine_state_summary.hasEverEnteredErrorRecovery, - labware=engine_state_summary.labware, - labwareOffsets=engine_state_summary.labwareOffsets, - pipettes=engine_state_summary.pipettes, - modules=engine_state_summary.modules, - liquids=engine_state_summary.liquids, - ) - decoy.verify(mock_run_store.insert_csv_rtp(run_id=run_id, run_time_parameters=[])) - - -async def test_create_with_options( - decoy: Decoy, - mock_run_orchestrator_store: RunOrchestratorStore, - mock_run_store: RunStore, - subject: RunDataManager, - engine_state_summary: StateSummary, - run_resource: RunResource, -) -> None: - """It should handle creation with a protocol, labware offsets and parameters.""" - run_id = "hello world" - created_at = datetime(year=2021, month=1, day=1) - protocol = ProtocolResource( - protocol_id="protocol-id", + protocol_id=sentinel.protocol_id, created_at=datetime(year=2022, month=2, day=2), source=None, # type: ignore[arg-type] protocol_key=None, protocol_kind=ProtocolKind.STANDARD, ) - labware_offset = pe_types.LabwareOffsetCreate( - definitionUri="namespace/load_name/version", - location=pe_types.LabwareOffsetLocation(slotName=DeckSlotName.SLOT_5), - vector=pe_types.LabwareOffsetVector(x=1, y=2, z=3), - ) - decoy.when( await mock_run_orchestrator_store.create( run_id=run_id, - labware_offsets=[labware_offset], + labware_offsets=sentinel.labware_offsets, + initial_error_recovery_policy=sentinel.initial_error_recovery_policy, protocol=protocol, - deck_configuration=[], - run_time_param_values={"foo": "bar"}, - run_time_param_paths={"xyzzy": Path("zork")}, + deck_configuration=sentinel.deck_configuration, + run_time_param_values=sentinel.run_time_param_values, + run_time_param_paths=sentinel.run_time_param_paths, notify_publishers=mock_notify_publishers, ) ).then_return(engine_state_summary) + decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return([]) + decoy.when( mock_run_store.insert( run_id=run_id, - protocol_id="protocol-id", + protocol_id=protocol.protocol_id, created_at=created_at, ) ).then_return(run_resource) @@ -276,21 +229,30 @@ async def test_create_with_options( bool_parameter = BooleanParameter( displayName="foo", variableName="bar", default=True, value=False ) - file_parameter = CSVParameter(displayName="my_file", variableName="file-id") - decoy.when(mock_run_orchestrator_store.get_run_time_parameters()).then_return( [bool_parameter, file_parameter] ) + expected_initial_error_recovery_rules: list[ErrorRecoveryRule] = [] + decoy.when(mock_error_recovery_setting_store.get_is_enabled()).then_return( + sentinel.error_recovery_enabled + ) + decoy.when( + error_recovery_mapping.create_error_recovery_policy_from_rules( + rules=expected_initial_error_recovery_rules, + enabled=sentinel.error_recovery_enabled, + ) + ).then_return(sentinel.initial_error_recovery_policy) + result = await subject.create( run_id=run_id, created_at=created_at, - labware_offsets=[labware_offset], + labware_offsets=sentinel.labware_offsets, protocol=protocol, - deck_configuration=[], - run_time_param_values={"foo": "bar"}, - run_time_param_paths={"xyzzy": Path("zork")}, + deck_configuration=sentinel.deck_configuration, + run_time_param_values=sentinel.run_time_param_values, + run_time_param_paths=sentinel.run_time_param_paths, notify_publishers=mock_notify_publishers, ) @@ -321,12 +283,24 @@ async def test_create_engine_error( decoy: Decoy, mock_run_orchestrator_store: RunOrchestratorStore, mock_run_store: RunStore, + mock_error_recovery_setting_store: ErrorRecoverySettingStore, subject: RunDataManager, ) -> None: """It should not create a resource if engine creation fails.""" run_id = "hello world" created_at = datetime(year=2021, month=1, day=1) + expected_initial_error_recovery_rules: list[ErrorRecoveryRule] = [] + decoy.when(mock_error_recovery_setting_store.get_is_enabled()).then_return( + sentinel.error_recovery_enabled + ) + decoy.when( + error_recovery_mapping.create_error_recovery_policy_from_rules( + rules=expected_initial_error_recovery_rules, + enabled=sentinel.error_recovery_enabled, + ) + ).then_return(sentinel.initial_error_recovery_policy) + decoy.when( await mock_run_orchestrator_store.create( run_id, @@ -336,6 +310,7 @@ async def test_create_engine_error( run_time_param_values=None, run_time_param_paths=None, notify_publishers=mock_notify_publishers, + initial_error_recovery_policy=matchers.Anything(), ) ).then_raise(RunConflictError("oh no")) @@ -783,6 +758,7 @@ async def test_create_archives_existing( run_command: commands.Command, mock_run_orchestrator_store: RunOrchestratorStore, mock_run_store: RunStore, + mock_error_recovery_setting_store: ErrorRecoverySettingStore, subject: RunDataManager, ) -> None: """It should persist the previously current run when a new run is created.""" @@ -798,11 +774,23 @@ async def test_create_archives_existing( ) ) + expected_initial_error_recovery_rules: list[ErrorRecoveryRule] = [] + decoy.when(mock_error_recovery_setting_store.get_is_enabled()).then_return( + sentinel.error_recovery_enabled + ) + decoy.when( + error_recovery_mapping.create_error_recovery_policy_from_rules( + rules=expected_initial_error_recovery_rules, + enabled=sentinel.error_recovery_enabled, + ) + ).then_return(sentinel.initial_error_recovery_policy) + decoy.when( await mock_run_orchestrator_store.create( run_id=run_id_new, labware_offsets=[], protocol=None, + initial_error_recovery_policy=sentinel.initial_error_recovery_policy, deck_configuration=[], run_time_param_values=None, run_time_param_paths=None, @@ -1126,35 +1114,36 @@ async def test_create_policies_raises_run_not_current( "not-current-run-id" ) with pytest.raises(RunNotCurrentError): - subject.set_policies( - run_id="run-id", policies=decoy.mock(cls=List[ErrorRecoveryRule]) + subject.set_error_recovery_rules( + run_id="run-id", rules=decoy.mock(cls=List[ErrorRecoveryRule]) ) async def test_create_policies_translates_and_calls_orchestrator( decoy: Decoy, - monkeypatch: pytest.MonkeyPatch, mock_run_orchestrator_store: RunOrchestratorStore, + mock_error_recovery_setting_store: ErrorRecoverySettingStore, subject: RunDataManager, ) -> None: """Should translate rules into policy and call orchestrator.""" - monkeypatch.setattr( - error_recovery_mapping, - "create_error_recovery_policy_from_rules", - decoy.mock( - func=decoy.mock( - func=error_recovery_mapping.create_error_recovery_policy_from_rules - ) - ), + decoy.when(mock_error_recovery_setting_store.get_is_enabled()).then_return( + sentinel.is_enabled ) - input_rules = decoy.mock(cls=List[ErrorRecoveryRule]) - expected_output = decoy.mock(cls=ErrorRecoveryPolicy) decoy.when( - error_recovery_mapping.create_error_recovery_policy_from_rules(input_rules) - ).then_return(expected_output) - decoy.when(mock_run_orchestrator_store.current_run_id).then_return("run-id") - subject.set_policies(run_id="run-id", policies=input_rules) - decoy.verify(mock_run_orchestrator_store.set_error_recovery_policy(expected_output)) + error_recovery_mapping.create_error_recovery_policy_from_rules( + rules=sentinel.input_rules, + enabled=sentinel.is_enabled, + ) + ).then_return(sentinel.expected_output) + decoy.when(mock_run_orchestrator_store.current_run_id).then_return( + sentinel.current_run_id + ) + subject.set_error_recovery_rules( + run_id=sentinel.current_run_id, rules=sentinel.input_rules + ) + decoy.verify( + mock_run_orchestrator_store.set_error_recovery_policy(sentinel.expected_output) + ) def test_get_nozzle_map_current_run( diff --git a/robot-server/tests/runs/test_engine_store.py b/robot-server/tests/runs/test_run_orchestrator_store.py similarity index 94% rename from robot-server/tests/runs/test_engine_store.py rename to robot-server/tests/runs/test_run_orchestrator_store.py index 46f25f3edb4..e34c1340359 100644 --- a/robot-server/tests/runs/test_engine_store.py +++ b/robot-server/tests/runs/test_run_orchestrator_store.py @@ -6,6 +6,7 @@ from opentrons_shared_data import get_shared_data_root from opentrons_shared_data.robot.types import RobotType +from opentrons.protocol_engine.error_recovery_policy import never_recover from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.types import DeckSlotName from opentrons.hardware_control import HardwareControlAPI, API @@ -58,6 +59,7 @@ async def test_create_engine(decoy: Decoy, subject: RunOrchestratorStore) -> Non result = await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, protocol=None, deck_configuration=[], notify_publishers=mock_notify_publishers, @@ -85,6 +87,7 @@ async def test_create_engine_uses_robot_type( await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -106,6 +109,7 @@ async def test_create_engine_with_labware_offsets( result = await subject.create( run_id="run-id", labware_offsets=[labware_offset], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -129,6 +133,7 @@ async def test_archives_state_if_engine_already_exists( await subject.create( run_id="run-id-1", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -138,6 +143,7 @@ async def test_archives_state_if_engine_already_exists( await subject.create( run_id="run-id-2", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -151,6 +157,7 @@ async def test_clear_engine(subject: RunOrchestratorStore) -> None: await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -172,6 +179,7 @@ async def test_clear_engine_not_stopped_or_idle( await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -187,6 +195,7 @@ async def test_clear_idle_engine(subject: RunOrchestratorStore) -> None: await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -238,6 +247,7 @@ async def test_get_default_orchestrator_current_unstarted( await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -252,6 +262,7 @@ async def test_get_default_orchestrator_conflict(subject: RunOrchestratorStore) await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, @@ -269,6 +280,7 @@ async def test_get_default_orchestrator_run_stopped( await subject.create( run_id="run-id", labware_offsets=[], + initial_error_recovery_policy=never_recover, deck_configuration=[], protocol=None, notify_publishers=mock_notify_publishers, From 7badfeed5cec147f0d69fb88980d108a2792c975 Mon Sep 17 00:00:00 2001 From: TamarZanzouri Date: Mon, 7 Oct 2024 11:44:55 -0400 Subject: [PATCH 007/101] feat(api): allow ungrip gripper labware while door is open (#16394) # Overview closes [EXEC-734](https://opentrons.atlassian.net/browse/EXEC-734). add ungrip command and allow queuing it and executing it while door is open. ## Test Plan and Hands on Testing 1. upload a protocol that will enter ER mode. 2. open door. 3. issue an ungrip command: ``` { "data": { "commandType": "unsafe/ungripLabware", "intent": "fixit", "params": { } } } ``` 4. make sure the gripper opens its jaw. tested with dev server and the command succeeded but still need to test with an actual gripper (@SyntaxColoring thank you Max) ## Changelog 1. added an ungrip command `unsafe/ungripLabware` 2. added logic for queuing while the door is open. 3. added logic for allowing to execute while door is open. ## Review requests 1. gripper command makes sense? 2. logic changes make sense? ## Risk assessment medium. added a new command but need to make sure nothing has changed with the door saftey. [EXEC-734]: https://opentrons.atlassian.net/browse/EXEC-734?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Max Marrone --- .../commands/command_unions.py | 5 ++ .../commands/unsafe/__init__.py | 15 ++++ .../commands/unsafe/unsafe_ungrip_labware.py | 73 +++++++++++++++++++ .../protocol_engine/state/commands.py | 51 +++++++++++-- .../commands/unsafe/test_ungrip_labware.py | 40 ++++++++++ .../state/test_command_state.py | 73 +++++++++++++++++++ .../state/test_command_view_old.py | 15 ++++ robot-server/simulators/test-flex.json | 4 + shared-data/command/schemas/10.json | 42 ++++++++++- 9 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py create mode 100644 api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 604fca50e14..f01e10aa63e 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -392,6 +392,7 @@ unsafe.UnsafeDropTipInPlace, unsafe.UpdatePositionEstimators, unsafe.UnsafeEngageAxes, + unsafe.UnsafeUngripLabware, ], Field(discriminator="commandType"), ] @@ -467,6 +468,7 @@ unsafe.UnsafeDropTipInPlaceParams, unsafe.UpdatePositionEstimatorsParams, unsafe.UnsafeEngageAxesParams, + unsafe.UnsafeUngripLabwareParams, ] CommandType = Union[ @@ -540,6 +542,7 @@ unsafe.UnsafeDropTipInPlaceCommandType, unsafe.UpdatePositionEstimatorsCommandType, unsafe.UnsafeEngageAxesCommandType, + unsafe.UnsafeUngripLabwareCommandType, ] CommandCreate = Annotated[ @@ -614,6 +617,7 @@ unsafe.UnsafeDropTipInPlaceCreate, unsafe.UpdatePositionEstimatorsCreate, unsafe.UnsafeEngageAxesCreate, + unsafe.UnsafeUngripLabwareCreate, ], Field(discriminator="commandType"), ] @@ -689,6 +693,7 @@ unsafe.UnsafeDropTipInPlaceResult, unsafe.UpdatePositionEstimatorsResult, unsafe.UnsafeEngageAxesResult, + unsafe.UnsafeUngripLabwareResult, ] # todo(mm, 2024-06-12): Ideally, command return types would have specific diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py index 6b92cc2e18e..72698a3b0f2 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -31,6 +31,15 @@ UnsafeEngageAxesCreate, ) +from .unsafe_ungrip_labware import ( + UnsafeUngripLabwareCommandType, + UnsafeUngripLabwareParams, + UnsafeUngripLabwareResult, + UnsafeUngripLabware, + UnsafeUngripLabwareCreate, +) + + __all__ = [ # Unsafe blow-out-in-place command models "UnsafeBlowOutInPlaceCommandType", @@ -56,4 +65,10 @@ "UnsafeEngageAxesResult", "UnsafeEngageAxes", "UnsafeEngageAxesCreate", + # Unsafe ungrip labware + "UnsafeUngripLabwareCommandType", + "UnsafeUngripLabwareParams", + "UnsafeUngripLabwareResult", + "UnsafeUngripLabware", + "UnsafeUngripLabwareCreate", ] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py new file mode 100644 index 00000000000..e64beaa7ea7 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_ungrip_labware.py @@ -0,0 +1,73 @@ +"""Ungrip labware payload, result, and implementaiton.""" + +from __future__ import annotations +from opentrons.protocol_engine.errors.exceptions import GripperNotAttachedError +from pydantic import BaseModel +from typing import Optional, Type +from typing_extensions import Literal + +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware + +from opentrons.hardware_control import HardwareControlAPI + + +UnsafeUngripLabwareCommandType = Literal["unsafe/ungripLabware"] + + +class UnsafeUngripLabwareParams(BaseModel): + """Payload required for an UngripLabware command.""" + + +class UnsafeUngripLabwareResult(BaseModel): + """Result data from the execution of an UngripLabware command.""" + + +class UnsafeUngripLabwareImplementation( + AbstractCommandImpl[ + UnsafeUngripLabwareParams, + SuccessData[UnsafeUngripLabwareResult, None], + ] +): + """Ungrip labware command implementation.""" + + def __init__( + self, + hardware_api: HardwareControlAPI, + **kwargs: object, + ) -> None: + self._hardware_api = hardware_api + + async def execute( + self, params: UnsafeUngripLabwareParams + ) -> SuccessData[UnsafeUngripLabwareResult, None]: + """Ungrip Labware.""" + ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) + if not ot3_hardware_api.has_gripper(): + raise GripperNotAttachedError("No gripper found to perform ungrip.") + await ot3_hardware_api.ungrip() + return SuccessData(public=UnsafeUngripLabwareResult(), private=None) + + +class UnsafeUngripLabware( + BaseCommand[UnsafeUngripLabwareParams, UnsafeUngripLabwareResult, ErrorOccurrence] +): + """UnsafeUngripLabware command model.""" + + commandType: UnsafeUngripLabwareCommandType = "unsafe/ungripLabware" + params: UnsafeUngripLabwareParams + result: Optional[UnsafeUngripLabwareResult] + + _ImplementationCls: Type[ + UnsafeUngripLabwareImplementation + ] = UnsafeUngripLabwareImplementation + + +class UnsafeUngripLabwareCreate(BaseCommandCreate[UnsafeUngripLabwareParams]): + """UnsafeEngageAxes command request model.""" + + commandType: UnsafeUngripLabwareCommandType = "unsafe/ungripLabware" + params: UnsafeUngripLabwareParams + + _CommandCls: Type[UnsafeUngripLabware] = UnsafeUngripLabware diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index d01926862de..6723c521892 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -17,6 +17,9 @@ RunCommandAction, SetErrorRecoveryPolicyAction, ) +from opentrons.protocol_engine.commands.unsafe.unsafe_ungrip_labware import ( + UnsafeUngripLabwareCommandType, +) from opentrons.protocol_engine.error_recovery_policy import ( ErrorRecoveryPolicy, ErrorRecoveryType, @@ -36,7 +39,7 @@ DoorChangeAction, ) -from ..commands import Command, CommandStatus, CommandIntent +from ..commands import Command, CommandStatus, CommandIntent, CommandCreate from ..errors import ( RunStoppedError, ErrorOccurrence, @@ -95,7 +98,9 @@ class QueueStatus(enum.Enum): AWAITING_RECOVERY_PAUSED = enum.auto() """Execution of fixit commands has been paused. - New protocol and fixit commands may be enqueued, but will wait to execute. + New protocol and fixit commands may be enqueued, but will usually wait to execute. + There are certain exceptions where fixit commands will still run. + New setup commands may not be enqueued. """ @@ -740,6 +745,12 @@ def get_next_to_execute(self) -> Optional[str]: next_fixit_cmd = self._state.command_history.get_fixit_queue_ids().head(None) if next_fixit_cmd and self._state.queue_status == QueueStatus.AWAITING_RECOVERY: return next_fixit_cmd + if ( + next_fixit_cmd + and self._state.queue_status == QueueStatus.AWAITING_RECOVERY_PAUSED + and self._may_run_with_door_open(fixit_command=self.get(next_fixit_cmd)) + ): + return next_fixit_cmd # if there is a setup command queued, prioritize it next_setup_cmd = self._state.command_history.get_setup_queue_ids().head(None) @@ -970,12 +981,23 @@ def validate_action_allowed( # noqa: C901 "Setup commands are not allowed after run has started." ) elif action.request.intent == CommandIntent.FIXIT: - if self._state.queue_status != QueueStatus.AWAITING_RECOVERY: + if self.get_status() == EngineStatus.AWAITING_RECOVERY: + return action + elif self.get_status() in ( + EngineStatus.AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + EngineStatus.AWAITING_RECOVERY_PAUSED, + ): + if self._may_run_with_door_open(fixit_command=action.request): + return action + else: + raise FixitCommandNotAllowedError( + f"{action.request.commandType} fixit command may not run" + " until the door is closed and the run is played again." + ) + else: raise FixitCommandNotAllowedError( "Fixit commands are not allowed when the run is not in a recoverable state." ) - else: - return action else: return action @@ -1060,3 +1082,22 @@ def get_error_recovery_policy(self) -> ErrorRecoveryPolicy: higher-level code. """ return self._state.error_recovery_policy + + def _may_run_with_door_open( + self, *, fixit_command: Command | CommandCreate + ) -> bool: + """Return whether the given fixit command is exempt from the usual open-door auto pause. + + This is required for certain error recovery flows, where we want the robot to + do stuff while the door is open. + """ + # CommandIntent.PROTOCOL and CommandIntent.SETUP have their own rules for whether + # they run while the door is open. Passing one of those commands to this function + # is probably a mistake in the caller's logic. + assert fixit_command.intent == CommandIntent.FIXIT + + # This type annotation is to make sure the string constant stays in sync and isn't typo'd. + required_command_type: UnsafeUngripLabwareCommandType = "unsafe/ungripLabware" + # todo(mm, 2024-10-04): Instead of allowlisting command types, maybe we should + # add a `mayRunWithDoorOpen: bool` field to command requests. + return fixit_command.commandType == required_command_type diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py new file mode 100644 index 00000000000..1a41244d556 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_ungrip_labware.py @@ -0,0 +1,40 @@ +"""Test update-position-estimator commands.""" +from decoy import Decoy + +from opentrons.protocol_engine.commands.unsafe.unsafe_ungrip_labware import ( + UnsafeUngripLabwareParams, + UnsafeUngripLabwareResult, + UnsafeUngripLabwareImplementation, +) +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.errors.exceptions import GripperNotAttachedError +from opentrons.hardware_control import OT3HardwareControlAPI +import pytest + + +async def test_ungrip_labware_implementation( + decoy: Decoy, ot3_hardware_api: OT3HardwareControlAPI +) -> None: + """Test UngripLabware command execution.""" + subject = UnsafeUngripLabwareImplementation(hardware_api=ot3_hardware_api) + + decoy.when(ot3_hardware_api.has_gripper()).then_return(True) + + result = await subject.execute(params=UnsafeUngripLabwareParams()) + + assert result == SuccessData(public=UnsafeUngripLabwareResult(), private=None) + + decoy.verify( + await ot3_hardware_api.ungrip(), + ) + + +async def test_ungrip_labware_implementation_raises_no_gripper_attached( + decoy: Decoy, ot3_hardware_api: OT3HardwareControlAPI +) -> None: + """Test UngripLabware command execution.""" + subject = UnsafeUngripLabwareImplementation(hardware_api=ot3_hardware_api) + + decoy.when(ot3_hardware_api.has_gripper()).then_return(False) + with pytest.raises(GripperNotAttachedError): + await subject.execute(params=UnsafeUngripLabwareParams()) diff --git a/api/tests/opentrons/protocol_engine/state/test_command_state.py b/api/tests/opentrons/protocol_engine/state/test_command_state.py index afafcc3cabe..6f090612a74 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_state.py @@ -557,6 +557,79 @@ def test_door_during_error_recovery() -> None: assert subject.state.failed_command_errors == [expected_error_occurance] +@pytest.mark.parametrize("close_door_before_queueing", [False, True]) +def test_door_ungrip_labware(close_door_before_queueing: bool) -> None: + """Ungrip commands should be able to run even when the door is open.""" + subject = CommandStore( + is_door_open=False, + error_recovery_policy=_placeholder_error_recovery_policy, + config=Config( + block_on_door_open=True, + # Choice of robot and deck type are arbitrary. + robot_type="OT-2 Standard", + deck_type=DeckType.OT2_STANDARD, + ), + ) + subject_view = CommandView(subject.state) + + # Fail a command to put the subject in recovery mode. + queue_failing = actions.QueueCommandAction( + request=commands.CommentCreate( + params=commands.CommentParams(message=""), key="command-key-1" + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="failing-command-id", + ) + subject.handle_action(queue_failing) + run_failing = actions.RunCommandAction( + command_id="failing-command-id", + started_at=datetime(year=2022, month=2, day=2), + ) + subject.handle_action(run_failing) + expected_error = errors.ProtocolEngineError(message="oh no") + fail_failing = actions.FailCommandAction( + command_id="failing-command-id", + running_command=subject_view.get("failing-command-id"), + error_id="error-id", + failed_at=datetime(year=2023, month=3, day=3), + error=expected_error, + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ) + subject.handle_action(fail_failing) + + # Open the door: + subject.handle_action(actions.DoorChangeAction(DoorState.OPEN)) + assert ( + subject_view.get_status() == EngineStatus.AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR + ) + assert subject_view.get_next_to_execute() is None + + if close_door_before_queueing: + subject.handle_action(actions.DoorChangeAction(DoorState.CLOSED)) + + assert subject_view.get_status() in ( + EngineStatus.AWAITING_RECOVERY_PAUSED, # If we closed the door. + EngineStatus.AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, # If we didn't. + ) + + # Make sure the special ungrip command can be queued and that it will be returned + # as next to execute: + queue_fixit = actions.QueueCommandAction( + request=commands.unsafe.UnsafeUngripLabwareCreate( + params=commands.unsafe.UnsafeUngripLabwareParams(), + intent=CommandIntent.FIXIT, + ), + request_hash=None, + created_at=datetime(year=2021, month=1, day=1), + command_id="fixit-command-id", + ) + subject_view.validate_action_allowed(queue_fixit) + subject.handle_action(queue_fixit) + assert subject_view.get_next_to_execute() == "fixit-command-id" + + @pytest.mark.parametrize( ("door_initially_open", "expected_engine_status_after_play"), [ diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py index 48918eec6eb..06318cb8d36 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py @@ -595,6 +595,21 @@ class ActionAllowedSpec(NamedTuple): action=ResumeFromRecoveryAction(), expected_error=errors.ResumeFromRecoveryNotAllowedError, ), + ActionAllowedSpec( + subject=get_command_view( + queue_status=QueueStatus.AWAITING_RECOVERY_PAUSED, is_door_blocking=True + ), + action=QueueCommandAction( + request=cmd.unsafe.UnsafeUngripLabwareCreate( + params=cmd.unsafe.UnsafeUngripLabwareParams(), + intent=cmd.CommandIntent.FIXIT, + ), + request_hash=None, + command_id="command-id", + created_at=datetime(year=2021, month=1, day=1), + ), + expected_error=None, + ), ] diff --git a/robot-server/simulators/test-flex.json b/robot-server/simulators/test-flex.json index ed694a8cb92..34544cfa099 100644 --- a/robot-server/simulators/test-flex.json +++ b/robot-server/simulators/test-flex.json @@ -9,6 +9,10 @@ "left": { "model": "p50_single_3.4", "id": "123" + }, + "gripper":{ + "model": "gripper_1.3", + "id": "1234" } }, "attached_modules": { diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index d83ec30e388..07afff241a1 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -74,7 +74,8 @@ "unsafe/blowOutInPlace": "#/definitions/UnsafeBlowOutInPlaceCreate", "unsafe/dropTipInPlace": "#/definitions/UnsafeDropTipInPlaceCreate", "unsafe/updatePositionEstimators": "#/definitions/UpdatePositionEstimatorsCreate", - "unsafe/engageAxes": "#/definitions/UnsafeEngageAxesCreate" + "unsafe/engageAxes": "#/definitions/UnsafeEngageAxesCreate", + "unsafe/ungripLabware": "#/definitions/UnsafeUngripLabwareCreate" } }, "oneOf": [ @@ -287,6 +288,9 @@ }, { "$ref": "#/definitions/UnsafeEngageAxesCreate" + }, + { + "$ref": "#/definitions/UnsafeUngripLabwareCreate" } ], "definitions": { @@ -4526,6 +4530,42 @@ } }, "required": ["params"] + }, + "UnsafeUngripLabwareParams": { + "title": "UnsafeUngripLabwareParams", + "description": "Payload required for an UngripLabware command.", + "type": "object", + "properties": {} + }, + "UnsafeUngripLabwareCreate": { + "title": "UnsafeUngripLabwareCreate", + "description": "UnsafeEngageAxes command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/ungripLabware", + "enum": ["unsafe/ungripLabware"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UnsafeUngripLabwareParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] } }, "$id": "opentronsCommandSchemaV10", From fb6e437b95b7649142c593e1b80b59b06db7be24 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Mon, 7 Oct 2024 13:49:11 -0400 Subject: [PATCH 008/101] refactor(api, robot-server): remove/ update outdated TODOs (#16413) # Overview Removes TODOs that have been addressed or are no longer relevant. Updates outdated ones. Details in comments. ## Risk assessment None. --- api/src/opentrons/execute.py | 3 +-- .../hardware_control/modules/heater_shaker.py | 1 - .../opentrons/protocol_api/core/engine/protocol.py | 1 - .../protocol_api/core/legacy/legacy_protocol_core.py | 1 - api/src/opentrons/protocol_api/core/protocol.py | 1 - api/src/opentrons/protocol_api/protocol_context.py | 1 - .../protocol_engine/commands/move_labware.py | 1 - .../execution/heater_shaker_movement_flagger.py | 3 --- .../protocol_engine/execution/labware_movement.py | 2 -- api/src/opentrons/protocol_engine/state/modules.py | 1 - api/src/opentrons/simulate.py | 3 +-- .../robot_server/instruments/instrument_models.py | 5 ----- robot-server/robot_server/instruments/router.py | 11 ++++++++--- .../maintenance_runs/maintenance_run_data_manager.py | 2 +- .../maintenance_runs/router/commands_router.py | 3 --- .../robot_server/service/legacy/routers/motors.py | 2 -- robot-server/tests/instruments/test_router.py | 2 -- 17 files changed, 11 insertions(+), 32 deletions(-) diff --git a/api/src/opentrons/execute.py b/api/src/opentrons/execute.py index 1e8d3771f59..ade74b1aadd 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -609,8 +609,6 @@ def _run_file_non_pe( context.home() try: - # TODO (spp, 2024-03-18): use true run-time param overrides once enabled - # for cli protocol simulation/ execution execute_apiv2.run_protocol( protocol, context, run_time_parameters_with_overrides=None ) @@ -626,6 +624,7 @@ def _run_file_pe( """Run a protocol file with Protocol Engine.""" async def run(protocol_source: ProtocolSource) -> None: + # TODO (spp, 2024-03-18): use run-time param overrides once enabled for cli protocol execution hardware_api_wrapped = hardware_api.wrapped() protocol_engine = await create_protocol_engine( hardware_api=hardware_api_wrapped, diff --git a/api/src/opentrons/hardware_control/modules/heater_shaker.py b/api/src/opentrons/hardware_control/modules/heater_shaker.py index e52de0bf49c..cc592d3c514 100644 --- a/api/src/opentrons/hardware_control/modules/heater_shaker.py +++ b/api/src/opentrons/hardware_control/modules/heater_shaker.py @@ -201,7 +201,6 @@ def device_info(self) -> Mapping[str, str]: @property def live_data(self) -> LiveData: return { - # TODO (spp, 2022-2-22): Revise what status includes "status": self.status.value, "data": { "temperatureStatus": self.temperature_status.value, diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 04ddaf55a48..360740cbfcd 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -311,7 +311,6 @@ def load_adapter( return labware_core - # TODO (spp, 2022-12-14): https://opentrons.atlassian.net/browse/RLAB-237 def move_labware( self, labware_core: LabwareCore, diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py index d698604ac30..eb1a7386e38 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_protocol_core.py @@ -267,7 +267,6 @@ def load_adapter( """Load an adapter using its identifying parameters""" raise APIVersionError(api_element="Loading adapter") - # TODO (spp, 2022-12-14): https://opentrons.atlassian.net/browse/RLAB-237 def move_labware( self, labware_core: LegacyLabwareCore, diff --git a/api/src/opentrons/protocol_api/core/protocol.py b/api/src/opentrons/protocol_api/core/protocol.py index a8403cc40da..687291c390a 100644 --- a/api/src/opentrons/protocol_api/core/protocol.py +++ b/api/src/opentrons/protocol_api/core/protocol.py @@ -93,7 +93,6 @@ def load_adapter( """Load an adapter using its identifying parameters""" ... - # TODO (spp, 2022-12-14): https://opentrons.atlassian.net/browse/RLAB-237 @abstractmethod def move_labware( self, diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index 64a73457608..5c21f662a62 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -661,7 +661,6 @@ def loaded_labwares(self) -> Dict[int, Labware]: if slot is not None } - # TODO (spp, 2022-12-14): https://opentrons.atlassian.net/browse/RLAB-237 @requires_version(2, 15) def move_labware( self, diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index c4381dd6865..d5c188d219f 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -47,7 +47,6 @@ _TRASH_CHUTE_DROP_BUFFER_MM = 8 -# TODO (spp, 2022-12-14): https://opentrons.atlassian.net/browse/RLAB-237 class MoveLabwareParams(BaseModel): """Input parameters for a ``moveLabware`` command.""" diff --git a/api/src/opentrons/protocol_engine/execution/heater_shaker_movement_flagger.py b/api/src/opentrons/protocol_engine/execution/heater_shaker_movement_flagger.py index 78b8f2e9bfa..efe8190f04a 100644 --- a/api/src/opentrons/protocol_engine/execution/heater_shaker_movement_flagger.py +++ b/api/src/opentrons/protocol_engine/execution/heater_shaker_movement_flagger.py @@ -61,9 +61,6 @@ async def raise_if_labware_latched_on_heater_shaker( return # Labware on a module, but not a Heater-Shaker. if hs_substate.labware_latch_status == HeaterShakerLatchStatus.CLOSED: - # TODO (spp, 2022-10-27): This only raises if latch status is 'idle_closed'. - # We need to update the flagger to raise if latch status is anything other - # than 'idle_open' raise HeaterShakerLabwareLatchNotOpenError( "Heater-Shaker labware latch must be open when moving labware to/from it." ) diff --git a/api/src/opentrons/protocol_engine/execution/labware_movement.py b/api/src/opentrons/protocol_engine/execution/labware_movement.py index b7d03914793..30feb6517ff 100644 --- a/api/src/opentrons/protocol_engine/execution/labware_movement.py +++ b/api/src/opentrons/protocol_engine/execution/labware_movement.py @@ -37,8 +37,6 @@ _GRIPPER_HOMED_POSITION_Z = 166.125 # Height of the center of the gripper critical point from the deck when homed -# TODO (spp, 2022-10-20): name this GripperMovementHandler if it doesn't handle -# any non-gripper implementations class LabwareMovementHandler: """Implementation logic for labware movement.""" diff --git a/api/src/opentrons/protocol_engine/state/modules.py b/api/src/opentrons/protocol_engine/state/modules.py index 7120025da89..ca8973b405c 100644 --- a/api/src/opentrons/protocol_engine/state/modules.py +++ b/api/src/opentrons/protocol_engine/state/modules.py @@ -580,7 +580,6 @@ def _handle_thermocycler_module_commands( target_block_temperature=block_temperature, target_lid_temperature=None, ) - # TODO (spp, 2022-08-01): set is_lid_open to False upon lid commands' failure elif isinstance(command.result, thermocycler.OpenLidResult): self._state.substate_by_module_id[module_id] = ThermocyclerModuleSubState( module_id=ThermocyclerModuleId(module_id), diff --git a/api/src/opentrons/simulate.py b/api/src/opentrons/simulate.py index 62806edb048..23f6c7fdfb9 100644 --- a/api/src/opentrons/simulate.py +++ b/api/src/opentrons/simulate.py @@ -883,8 +883,6 @@ def _run_file_non_pe( context.home() with scraper.scrape(): try: - # TODO (spp, 2024-03-18): use true run-time param overrides once enabled - # for cli protocol simulation/ execution execute.run_protocol( protocol, context, run_time_parameters_with_overrides=None ) @@ -914,6 +912,7 @@ def _run_file_pe( log_level: str, ) -> _SimulateResult: """Run a protocol file with Protocol Engine.""" + # TODO (spp, 2024-03-18): use run-time param overrides once enabled for cli protocol simulation. async def run(protocol_source: ProtocolSource) -> _SimulateResult: hardware_api_wrapped = hardware_api.wrapped() diff --git a/robot-server/robot_server/instruments/instrument_models.py b/robot-server/robot_server/instruments/instrument_models.py index 3bd9885d26d..f6a8ed0f82f 100644 --- a/robot-server/robot_server/instruments/instrument_models.py +++ b/robot-server/robot_server/instruments/instrument_models.py @@ -76,8 +76,6 @@ class GripperData(BaseModel): """Data from attached gripper.""" jawState: str = Field(..., description="Gripper Jaw state.") - # TODO (spp, 2023-01-03): update calibration field as decided after - # spike https://opentrons.atlassian.net/browse/RSS-167 calibratedOffset: Optional[InstrumentCalibrationData] = Field( None, description="Calibrated gripper offset." ) @@ -93,9 +91,6 @@ class PipetteData(BaseModel): None, description="Calibrated pipette offset." ) - # TODO (spp, 2022-12-20): update/ add fields according to client needs. - # add calibration data as decided by https://opentrons.atlassian.net/browse/RSS-167 - class PipetteState(BaseModel): """State from an attached pipette.""" diff --git a/robot-server/robot_server/instruments/router.py b/robot-server/robot_server/instruments/router.py index b34a5d0c749..a9a3e3bbed3 100644 --- a/robot-server/robot_server/instruments/router.py +++ b/robot-server/robot_server/instruments/router.py @@ -267,10 +267,15 @@ async def _get_attached_instruments_ot2( async def get_attached_instruments( hardware: Annotated[HardwareControlAPI, Depends(get_hardware)], ) -> PydanticResponse[SimpleMultiBody[AttachedItem]]: - """Get a list of all attached instruments.""" + """Get a list of all attached instruments. + + Note: This endpoint returns the full AttachedItem data for Flex instruments only. + + On an OT-2, this endpoint will provide partial data of the OT-2 pipettes + (no pipette fw and calibration data), and will not fetch new data after + a pipette attachment/ removal. + """ try: - # TODO (spp, 2023-01-06): revise according to - # https://opentrons.atlassian.net/browse/RET-1295 ot3_hardware = ensure_ot3_hardware(hardware_api=hardware) return await _get_attached_instruments_ot3(ot3_hardware) except HardwareNotSupportedError: diff --git a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py index c1c733a8e12..589aaf5614d 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py @@ -39,7 +39,7 @@ def _build_run( id=run_id, createdAt=created_at, status=state_summary.status, - actions=[], # TODO (spp, 2023-04-23): wire up actions once they are allowed + actions=[], errors=state_summary.errors, labware=state_summary.labware, labwareOffsets=state_summary.labwareOffsets, diff --git a/robot-server/robot_server/maintenance_runs/router/commands_router.py b/robot-server/robot_server/maintenance_runs/router/commands_router.py index 9df5f9630b9..afc5e03779b 100644 --- a/robot-server/robot_server/maintenance_runs/router/commands_router.py +++ b/robot-server/robot_server/maintenance_runs/router/commands_router.py @@ -156,9 +156,6 @@ async def create_run_command( # behavior is to pass through `command_intent` without overriding it command_intent = pe_commands.CommandIntent.SETUP command_create = request_body.data.copy(update={"intent": command_intent}) - - # TODO (spp): re-add `RunStoppedError` exception catching if/when maintenance runs - # have actions. command = await run_orchestrator_store.add_command_and_wait_for_interval( request=command_create, wait_until_complete=waitUntilComplete, timeout=timeout ) diff --git a/robot-server/robot_server/service/legacy/routers/motors.py b/robot-server/robot_server/service/legacy/routers/motors.py index d142ab2466f..1bcae9a2c38 100644 --- a/robot-server/robot_server/service/legacy/routers/motors.py +++ b/robot-server/robot_server/service/legacy/routers/motors.py @@ -30,8 +30,6 @@ async def get_engaged_motors( hardware: Annotated[HardwareControlAPI, Depends(get_hardware)], ) -> model.EngagedMotors: - # TODO (spp, 2023-07-06): Implement fetching Flex's engaged motors - # https://opentrons.atlassian.net/browse/RET-1371 try: engaged_axes = hardware.engaged_axes axes_dict = { diff --git a/robot-server/tests/instruments/test_router.py b/robot-server/tests/instruments/test_router.py index 01ba0cdf7c6..fe401828284 100644 --- a/robot-server/tests/instruments/test_router.py +++ b/robot-server/tests/instruments/test_router.py @@ -100,8 +100,6 @@ async def test_get_instruments_empty( assert result.status_code == 200 -# TODO (spp, 2022-01-17): remove xfail once robot server test flow is set up to handle -# OT2 vs OT3 tests correclty @pytest.mark.ot3_only async def test_get_all_attached_instruments( decoy: Decoy, From 528326fd7f1f7314b9fa07d890e7b29b1a7e75e4 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Mon, 7 Oct 2024 15:01:37 -0400 Subject: [PATCH 009/101] test(robot-server): Add more unit tests for error_recovery_mapping.py (#16426) --- .../tests/runs/test_error_recovery_mapping.py | 95 +++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/robot-server/tests/runs/test_error_recovery_mapping.py b/robot-server/tests/runs/test_error_recovery_mapping.py index fba8e4315d9..a125d12649d 100644 --- a/robot-server/tests/runs/test_error_recovery_mapping.py +++ b/robot-server/tests/runs/test_error_recovery_mapping.py @@ -2,6 +2,7 @@ import pytest from decoy import Decoy +from opentrons_shared_data.robot.types import RobotType from opentrons.protocol_engine.commands.pipetting_common import LiquidNotFoundError from opentrons.protocol_engine.commands.command import ( @@ -12,6 +13,7 @@ from opentrons.protocol_engine.error_recovery_policy import ErrorRecoveryType from opentrons.protocol_engine.state.config import Config from opentrons.protocol_engine.types import DeckType + from robot_server.runs.error_recovery_mapping import ( create_error_recovery_policy_from_rules, ) @@ -72,12 +74,12 @@ def test_create_error_recovery_policy_with_rules( ) -> None: """Should return IGNORE_AND_CONTINUE if that's what we specify as the rule.""" policy = create_error_recovery_policy_from_rules([mock_rule], enabled=True) - exampleConfig = Config( + example_config = Config( robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, ) assert ( - policy(exampleConfig, mock_command, mock_error_data) + policy(example_config, mock_command, mock_error_data) == ErrorRecoveryType.IGNORE_AND_CONTINUE ) @@ -87,12 +89,12 @@ def test_create_error_recovery_policy_undefined_error( ) -> None: """Should return a FAIL_RUN policy when error is not defined.""" policy = create_error_recovery_policy_from_rules(rules=[], enabled=True) - exampleConfig = Config( + example_config = Config( robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, ) - assert policy(exampleConfig, mock_command, None) == ErrorRecoveryType.FAIL_RUN + assert policy(example_config, mock_command, None) == ErrorRecoveryType.FAIL_RUN def test_create_error_recovery_policy_defined_error( @@ -100,12 +102,93 @@ def test_create_error_recovery_policy_defined_error( ) -> None: """Should return a WAIT_FOR_RECOVERY policy when error is defined.""" policy = create_error_recovery_policy_from_rules(rules=[], enabled=True) - exampleConfig = Config( + example_config = Config( robot_type="OT-3 Standard", deck_type=DeckType.OT3_STANDARD, ) assert ( - policy(exampleConfig, mock_command, mock_error_data) + policy(example_config, mock_command, mock_error_data) == ErrorRecoveryType.WAIT_FOR_RECOVERY ) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_enabled_boolean(enabled: bool) -> None: + """enabled=False should override any rules and always fail the run.""" + command = LiquidProbe.construct() # type: ignore[call-arg] + error_data = DefinedErrorData[LiquidNotFoundError]( + public=LiquidNotFoundError.construct() # type: ignore[call-arg] + ) + + rules = [ + ErrorRecoveryRule( + matchCriteria=MatchCriteria( + command=CommandMatcher( + commandType=command.commandType, + error=ErrorMatcher(errorType=error_data.public.errorType), + ), + ), + ifMatch=ReactionIfMatch.IGNORE_AND_CONTINUE, + ) + ] + + example_config = Config( + robot_type="OT-3 Standard", + deck_type=DeckType.OT3_STANDARD, + ) + + policy = create_error_recovery_policy_from_rules(rules, enabled) + result = policy(example_config, command, error_data) + expected_result = ( + ErrorRecoveryType.IGNORE_AND_CONTINUE if enabled else ErrorRecoveryType.FAIL_RUN + ) + assert result == expected_result + + +@pytest.mark.parametrize( + ( + "robot_type", + "expect_error_recovery_to_be_enabled", + ), + [ + ("OT-2 Standard", False), + ("OT-3 Standard", True), + ], +) +def test_enabled_on_flex_disabled_on_ot2( + robot_type: RobotType, expect_error_recovery_to_be_enabled: bool +) -> None: + """On OT-2s, the run should always fail regardless of any input rules.""" + command = LiquidProbe.construct() # type: ignore[call-arg] + error_data = DefinedErrorData[LiquidNotFoundError]( + public=LiquidNotFoundError.construct() # type: ignore[call-arg] + ) + + rules = [ + ErrorRecoveryRule( + matchCriteria=MatchCriteria( + command=CommandMatcher( + commandType=command.commandType, + error=ErrorMatcher(errorType=error_data.public.errorType), + ), + ), + ifMatch=ReactionIfMatch.IGNORE_AND_CONTINUE, + ) + ] + + example_config = Config( + robot_type=robot_type, + # This is a "wrong" deck_type that doesn't necessarily match robot_type + # but that shouldn't matter for our purposes. + deck_type=DeckType.OT3_STANDARD, + ) + + policy = create_error_recovery_policy_from_rules(rules, enabled=True) + result = policy(example_config, command, error_data) + expected_result = ( + ErrorRecoveryType.IGNORE_AND_CONTINUE + if expect_error_recovery_to_be_enabled + else ErrorRecoveryType.FAIL_RUN + ) + assert result == expected_result From d1de8082a27ea7c76a39f663db19cd429bb42869 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Mon, 7 Oct 2024 16:04:22 -0500 Subject: [PATCH 010/101] fix(app): get back unboxingFlow path update function (#16411) (#16428) ## Cherry-pick 4bca02a947 #16411 - [ ] Reviewer please validate that I resolved the conflicts correctly as edge has the import pathing updates. Co-authored-by: koji --- app/src/pages/ODD/RobotDashboard/WelcomeModal.tsx | 10 ++++++++++ .../ODD/RobotDashboard/__tests__/WelcomeModal.test.tsx | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/app/src/pages/ODD/RobotDashboard/WelcomeModal.tsx b/app/src/pages/ODD/RobotDashboard/WelcomeModal.tsx index 99c1ff41b21..450bdb689bf 100644 --- a/app/src/pages/ODD/RobotDashboard/WelcomeModal.tsx +++ b/app/src/pages/ODD/RobotDashboard/WelcomeModal.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' import { COLORS, @@ -14,10 +15,12 @@ import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { SmallButton } from '/app/atoms/buttons' import { OddModal } from '/app/molecules/OddModal' +import { updateConfigValue } from '/app/redux/config' import welcomeModalImage from '/app/assets/images/on-device-display/welcome_dashboard_modal.png' import type { SetStatusBarCreateCommand } from '@opentrons/shared-data' +import type { Dispatch } from '/app/redux/types' interface WelcomeModalProps { setShowWelcomeModal: (showWelcomeModal: boolean) => void @@ -27,6 +30,7 @@ export function WelcomeModal({ setShowWelcomeModal, }: WelcomeModalProps): JSX.Element { const { t } = useTranslation(['device_details', 'shared']) + const dispatch = useDispatch() const { createLiveCommand } = useCreateLiveCommandMutation() const animationCommand: SetStatusBarCreateCommand = { @@ -44,6 +48,12 @@ export function WelcomeModal({ } const handleCloseModal = (): void => { + dispatch( + updateConfigValue( + 'onDeviceDisplaySettings.unfinishedUnboxingFlowRoute', + null + ) + ) setShowWelcomeModal(false) } diff --git a/app/src/pages/ODD/RobotDashboard/__tests__/WelcomeModal.test.tsx b/app/src/pages/ODD/RobotDashboard/__tests__/WelcomeModal.test.tsx index 9d9f59d75b5..7fc9bf46ea2 100644 --- a/app/src/pages/ODD/RobotDashboard/__tests__/WelcomeModal.test.tsx +++ b/app/src/pages/ODD/RobotDashboard/__tests__/WelcomeModal.test.tsx @@ -6,6 +6,7 @@ import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' +import { updateConfigValue } from '/app/redux/config' import { WelcomeModal } from '../WelcomeModal' import type { SetStatusBarCreateCommand } from '@opentrons/shared-data' @@ -61,6 +62,10 @@ describe('WelcomeModal', () => { it('should call a mock function when tapping next button', () => { render(props) fireEvent.click(screen.getByText('Next')) + expect(vi.mocked(updateConfigValue)).toHaveBeenCalledWith( + 'onDeviceDisplaySettings.unfinishedUnboxingFlowRoute', + null + ) expect(props.setShowWelcomeModal).toHaveBeenCalled() }) }) From a9b219e395c75c014680e161236775560991215a Mon Sep 17 00:00:00 2001 From: koji Date: Tue, 8 Oct 2024 09:22:04 -0400 Subject: [PATCH 011/101] fix(protocol-designer): fix issues in select pipette screen (#16404) * fix(protocol-designer): fix issues in select pipette screen --- .../localization/en/create_new_protocol.json | 2 +- .../localization/en/protocol_steps.json | 2 +- .../organisms/EditInstrumentsModal/index.tsx | 2 - .../__tests__/PipetteInfoItem.test.tsx | 19 +- .../src/organisms/PipetteInfoItem/index.tsx | 44 ++-- .../CreateNewProtocolWizard/SelectGripper.tsx | 4 + .../SelectPipettes.tsx | 194 +++++++++++------- .../__tests__/SelectGripper.test.tsx | 11 + .../__tests__/SelectPipettes.test.tsx | 10 + 9 files changed, 175 insertions(+), 113 deletions(-) diff --git a/protocol-designer/src/assets/localization/en/create_new_protocol.json b/protocol-designer/src/assets/localization/en/create_new_protocol.json index a10a1653685..52c03ca7b50 100644 --- a/protocol-designer/src/assets/localization/en/create_new_protocol.json +++ b/protocol-designer/src/assets/localization/en/create_new_protocol.json @@ -32,7 +32,7 @@ "show_tips": "Show incompatible tips", "slots_limit_reached": "Slots limit reached", "stagingArea": "Staging area", - "swap": "Swap pipettes", + "swap_pipettes": "Swap pipettes", "tell_us": "Tell us about your protocol", "trash_required": "A trash entity is required", "trashBin": "Trash Bin", diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 3ab8b590dcc..3b00d2f7d5c 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -6,8 +6,8 @@ "delete": "Delete step", "dispensed": "Dispensed", "duplicate": "Duplicate step", - "engage_height": "Engage height", "edit_step": "Edit step", + "engage_height": "Engage height", "final_deck_state": "Final deck state", "from": "from", "heater_shaker_settings": "Heater-shaker settings", diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx index c01e4ee859f..c11a62a678c 100644 --- a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx +++ b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx @@ -242,7 +242,6 @@ export function EditInstrumentsModal( leftInfo != null ? ( { @@ -277,7 +276,6 @@ export function EditInstrumentsModal( rightInfo != null ? ( { diff --git a/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx b/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx index 8a35eb66e87..5b6f51e414c 100644 --- a/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx +++ b/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx @@ -25,10 +25,6 @@ describe('PipetteInfoItem', () => { tiprackDefURIs: ['mockDefUri'], pipetteName: 'p1000_single', mount: 'left', - formPipettesByMount: { - left: { pipetteName: 'p1000_single' }, - right: { pipetteName: 'p50_single' }, - }, } vi.mocked(getLabwareDefsByURI).mockReturnValue({ @@ -45,4 +41,19 @@ describe('PipetteInfoItem', () => { fireEvent.click(screen.getByText('Remove')) expect(props.cleanForm).toHaveBeenCalled() }) + + it('renders pipette with edit and remove buttons right pipette', () => { + props = { + ...props, + mount: 'right', + } + render(props) + screen.getByText('P1000 Single-Channel GEN1') + screen.getByText('Right pipette') + screen.getByText('mock display name') + fireEvent.click(screen.getByText('Edit')) + expect(props.editClick).toHaveBeenCalled() + fireEvent.click(screen.getByText('Remove')) + expect(props.cleanForm).toHaveBeenCalled() + }) }) diff --git a/protocol-designer/src/organisms/PipetteInfoItem/index.tsx b/protocol-designer/src/organisms/PipetteInfoItem/index.tsx index 25c9855ea95..220b08cb823 100644 --- a/protocol-designer/src/organisms/PipetteInfoItem/index.tsx +++ b/protocol-designer/src/organisms/PipetteInfoItem/index.tsx @@ -15,7 +15,6 @@ import { getPipetteSpecsV2 } from '@opentrons/shared-data' import { BUTTON_LINK_STYLE } from '../../atoms' import { getLabwareDefsByURI } from '../../labware-defs/selectors' import type { PipetteMount, PipetteName } from '@opentrons/shared-data' -import type { FormPipettesByMount, PipetteOnDeck } from '../../step-forms' interface PipetteInfoItemProps { mount: PipetteMount @@ -23,22 +22,11 @@ interface PipetteInfoItemProps { tiprackDefURIs: string[] editClick: () => void cleanForm: () => void - formPipettesByMount?: FormPipettesByMount - pipetteOnDeck?: PipetteOnDeck[] } export function PipetteInfoItem(props: PipetteInfoItemProps): JSX.Element { - const { - mount, - pipetteName, - tiprackDefURIs, - editClick, - cleanForm, - formPipettesByMount, - pipetteOnDeck, - } = props + const { mount, pipetteName, tiprackDefURIs, editClick, cleanForm } = props const { t, i18n } = useTranslation('create_new_protocol') - const oppositeMount = mount === 'left' ? 'right' : 'left' const allLabware = useSelector(getLabwareDefsByURI) const is96Channel = pipetteName === 'p1000_96' return ( @@ -73,33 +61,31 @@ export function PipetteInfoItem(props: PipetteInfoItemProps): JSX.Element { {t('edit')} - {(formPipettesByMount != null && - formPipettesByMount[oppositeMount].pipetteName == null) || - (pipetteOnDeck != null && pipetteOnDeck.length === 1) ? null : ( - { - cleanForm() - }} - textDecoration={TYPOGRAPHY.textDecorationUnderline} - css={BUTTON_LINK_STYLE} - > - - {t('remove')} - - - )} + { + cleanForm() + }} + textDecoration={TYPOGRAPHY.textDecorationUnderline} + css={BUTTON_LINK_STYLE} + padding={SPACING.spacing4} + > + + {t('remove')} + + diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx index f4067689fa4..88dc6ab031d 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import without from 'lodash/without' +import { useLocation } from 'react-router-dom' + import { Flex, SPACING, @@ -16,6 +18,7 @@ import type { WizardTileProps } from './types' export function SelectGripper(props: WizardTileProps): JSX.Element | null { const { goBack, setValue, proceed, watch } = props const { t } = useTranslation(['create_new_protocol', 'shared']) + const location = useLocation() const [gripperStatus, setGripperStatus] = useState<'yes' | 'no' | null>(null) const additionalEquipment = watch('additionalEquipment') @@ -44,6 +47,7 @@ export function SelectGripper(props: WizardTileProps): JSX.Element | null { header={t('add_gripper')} disabled={gripperStatus == null} goBack={() => { + location.state = 'gripper' goBack(1) }} proceed={handleProceed} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx index 7602b091c8f..b9f16f5e23c 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { useDispatch, useSelector } from 'react-redux' +import { useLocation } from 'react-router-dom' import { FLEX_ROBOT_TYPE, getAllPipetteNames, @@ -57,6 +58,7 @@ const MAX_TIPRACKS_ALLOWED = 3 export function SelectPipettes(props: WizardTileProps): JSX.Element | null { const { goBack, proceed, watch, setValue } = props const { t } = useTranslation(['create_new_protocol', 'shared']) + const location = useLocation() const pipettesByMount = watch('pipettesByMount') const fields = watch('fields') const { makeSnackbar } = useKitchen() @@ -101,8 +103,21 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { } }, [pipetteType, pipetteGen, pipetteVolume, selectedPipetteName]) + const noPipette = + (pipettesByMount.left.pipetteName == null || + pipettesByMount.left.tiprackDefURI == null) && + (pipettesByMount.right.pipetteName == null || + pipettesByMount.right.tiprackDefURI == null) + const isDisabled = - page === 'add' && pipettesByMount[defaultMount].tiprackDefURI == null + (page === 'add' && pipettesByMount[defaultMount].tiprackDefURI == null) || + noPipette + + const targetPipetteMount = + pipettesByMount.left.pipetteName == null || + pipettesByMount.left.tiprackDefURI == null + ? 'left' + : 'right' const handleProceed = (): void => { if (!isDisabled) { @@ -113,6 +128,34 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { } } } + + const handleGoBack = (): void => { + if (page === 'add') { + resetFields() + setValue(`pipettesByMount.${defaultMount}.pipetteName`, undefined) + setValue(`pipettesByMount.${defaultMount}.tiprackDefURI`, undefined) + if ( + pipettesByMount.left.pipetteName != null || + pipettesByMount.left.tiprackDefURI != null || + pipettesByMount.right.pipetteName != null || + pipettesByMount.right.tiprackDefURI != null + ) { + setPage('overview') + } else { + goBack(1) + } + } + if (page === 'overview') { + setPage('add') + } + } + + useEffect(() => { + if (location.state === 'gripper') { + setPage('overview') + } + }, [location]) + return ( <> {showIncompatibleTip ? ( @@ -129,17 +172,7 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { subHeader={page === 'add' ? t('which_pipette') : undefined} proceed={handleProceed} goBack={() => { - if (page === 'add') { - resetFields() - setValue(`pipettesByMount.${defaultMount}.pipetteName`, undefined) - setValue( - `pipettesByMount.${defaultMount}.tiprackDefURI`, - undefined - ) - goBack(1) - } else { - setPage('add') - } + handleGoBack() }} disabled={isDisabled} > @@ -375,7 +408,11 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { {t('your_pipettes')} - {has96Channel ? null : ( + {has96Channel || + (pipettesByMount.left.pipetteName == null && + pipettesByMount.right.pipetteName == null) || + (pipettesByMount.left.tiprackDefURI == null && + pipettesByMount.right.tiprackDefURI == null) ? null : ( { @@ -411,76 +448,81 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { transform="rotate(90deg)" /> - {t('swap')} + {t('swap_pipettes')} )} - {pipettesByMount.left.pipetteName != null && - pipettesByMount.left.tiprackDefURI != null ? ( - { - setPage('add') - setMount('left') - }} - cleanForm={() => { - setValue(`pipettesByMount.left.pipetteName`, undefined) - setValue(`pipettesByMount.left.tiprackDefURI`, undefined) + + {pipettesByMount.left.pipetteName != null && + pipettesByMount.left.tiprackDefURI != null ? ( + { + setPage('add') + setMount('left') + }} + cleanForm={() => { + setValue(`pipettesByMount.left.pipetteName`, undefined) + setValue( + `pipettesByMount.left.tiprackDefURI`, + undefined + ) - resetFields() - }} - /> - ) : ( - { - setPage('add') - setMount('left') - resetFields() - }} - text={t('add_pipette')} - textAlignment="left" - iconName="plus" - /> - )} - {pipettesByMount.right.pipetteName != null && - pipettesByMount.right.tiprackDefURI != null ? ( - { - setPage('add') - setMount('right') - }} - cleanForm={() => { - setValue(`pipettesByMount.right.pipetteName`, undefined) - setValue(`pipettesByMount.right.tiprackDefURI`, undefined) - resetFields() - }} - /> - ) : has96Channel ? null : ( - { - setPage('add') - setMount('right') - resetFields() - }} - text={t('add_pipette')} - textAlignment="left" - iconName="plus" - /> - )} + resetFields() + }} + /> + ) : null} + {pipettesByMount.right.pipetteName != null && + pipettesByMount.right.tiprackDefURI != null ? ( + { + setPage('add') + setMount('right') + }} + cleanForm={() => { + setValue(`pipettesByMount.right.pipetteName`, undefined) + setValue( + `pipettesByMount.right.tiprackDefURI`, + undefined + ) + resetFields() + }} + /> + ) : null} + + <> + {has96Channel || + (pipettesByMount.left.pipetteName != null && + pipettesByMount.right.pipetteName != null && + pipettesByMount.left.tiprackDefURI != null && + pipettesByMount.right.tiprackDefURI != null) ? null : ( + { + setPage('add') + setMount(targetPipetteMount) + resetFields() + }} + text={t('add_pipette')} + textAlignment="left" + iconName="plus" + /> + )} + )} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectGripper.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectGripper.test.tsx index d6c25eef7d1..87ab1bb07e3 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectGripper.test.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectGripper.test.tsx @@ -7,8 +7,19 @@ import { i18n } from '../../../assets/localization' import { renderWithProviders } from '../../../__testing-utils__' import { SelectGripper } from '../SelectGripper' +import type { NavigateFunction } from 'react-router-dom' import type { WizardFormState, WizardTileProps } from '../types' +const mockLocation = vi.fn() + +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useLocation: () => mockLocation, + } +}) + const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx index 6426b9ee083..922c48a0959 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx @@ -12,6 +12,7 @@ import { createCustomTiprackDef } from '../../../labware-defs/actions' import { SelectPipettes } from '../SelectPipettes' import { getTiprackOptions } from '../utils' +import type { NavigateFunction } from 'react-router-dom' import type { WizardFormState, WizardTileProps } from '../types' vi.mock('../../../labware-defs/selectors') @@ -19,6 +20,15 @@ vi.mock('../../../feature-flags/selectors') vi.mock('../../../organisms') vi.mock('../../../labware-defs/actions') vi.mock('../utils') +const mockLocation = vi.fn() + +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + useLocation: () => mockLocation, + } +}) const render = (props: React.ComponentProps) => { return renderWithProviders(, { From 519482dc34bfcf60015c558546553494b7f47125 Mon Sep 17 00:00:00 2001 From: koji Date: Tue, 8 Oct 2024 09:23:14 -0400 Subject: [PATCH 012/101] fix(protocol-designer): fix EditInstrumentsModal issue (#16421) * fix(protocol-designer): fix EditInstrumentsModal issue --- .../localization/en/create_new_protocol.json | 2 +- .../src/assets/localization/en/shared.json | 3 +- .../organisms/EditInstrumentsModal/index.tsx | 147 +++++++++--------- .../SelectPipettes.tsx | 78 +++++----- .../__tests__/SelectPipettes.test.tsx | 4 +- 5 files changed, 120 insertions(+), 114 deletions(-) diff --git a/protocol-designer/src/assets/localization/en/create_new_protocol.json b/protocol-designer/src/assets/localization/en/create_new_protocol.json index 52c03ca7b50..b75005152d7 100644 --- a/protocol-designer/src/assets/localization/en/create_new_protocol.json +++ b/protocol-designer/src/assets/localization/en/create_new_protocol.json @@ -3,7 +3,7 @@ "add_fixtures": "Add your fixtures", "add_gripper": "Add a gripper", "add_modules": "Add your modules", - "add_pipette": "Add a pipette and tips", + "add_pipette": "Add a pipette", "author_org": "Author/Organization", "basics": "Let’s start with the basics", "description": "Description", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 4da0ce66196..fcf63f043da 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -14,7 +14,8 @@ "developer_ff": "Developer feature flags", "done": "Done", "edit_existing": "Edit existing protocol", - "edit_instruments": "Edit instruments", + "edit_instruments": "Edit Instruments", + "edit_pipette": "Edit Pipette", "edit_protocol_metadata": "Edit protocol metadata", "edit": "edit", "eight_channel": "8-Channel", diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx index c11a62a678c..6f1729a2110 100644 --- a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx +++ b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx @@ -6,6 +6,7 @@ import styled, { css } from 'styled-components' import mapValues from 'lodash/mapValues' import { ALIGN_CENTER, + ALIGN_STRETCH, Box, Btn, Checkbox, @@ -17,11 +18,13 @@ import { DISPLAY_INLINE_BLOCK, EmptySelectorButton, Flex, + FLEX_MAX_CONTENT, Icon, JUSTIFY_END, JUSTIFY_SPACE_BETWEEN, ListItem, Modal, + OVERFLOW_AUTO, PrimaryButton, PRODUCT, RadioButton, @@ -141,11 +144,24 @@ export function EditInstrumentsModal( ? getSectionsFromPipetteName(leftPip.name, leftPip.spec) : null + const removeOpentronsPhrases = (input: string): string => { + const phrasesToRemove = ['Opentrons Flex 96', 'Opentrons OT-2 96'] + + return phrasesToRemove + .reduce((text, phrase) => { + return text.replace(new RegExp(phrase, 'gi'), '') + }, input) + .trim() + } + return createPortal( { resetFields() onClose() @@ -154,7 +170,7 @@ export function EditInstrumentsModal( {page === 'overview' ? null : ( {page === 'overview' ? ( - <> - + + @@ -307,13 +322,9 @@ export function EditInstrumentsModal( {robotType === FLEX_ROBOT_TYPE ? ( - + @@ -368,18 +379,15 @@ export function EditInstrumentsModal( ) : null} - + ) : ( - <> - + + {t('pipette_type')} @@ -400,16 +408,10 @@ export function EditInstrumentsModal( ) })} - + {pipetteType != null && robotType === OT2_ROBOT_TYPE ? ( - - + + {t('pipette_gen')} @@ -435,12 +437,10 @@ export function EditInstrumentsModal( robotType === OT2_ROBOT_TYPE) ? ( - + {t('pipette_vol')} @@ -488,19 +488,16 @@ export function EditInstrumentsModal( {allPipetteOptions.includes(selectedPip as PipetteName) ? (() => { const tiprackOptions = getTiprackOptions({ - allLabware: allLabware, - allowAllTipracks: allowAllTipracks, + allLabware, + allowAllTipracks, selectedPipetteName: selectedPip, }) return ( - + {t('pipette_tips')} {tiprackOptions.map(option => ( @@ -518,7 +518,7 @@ export function EditInstrumentsModal( !selectedTips.includes(option.value) } isChecked={selectedTips.includes(option.value)} - labelText={option.name} + labelText={removeOpentronsPhrases(option.name)} onClick={() => { const updatedTips = selectedTips.includes( option.value @@ -529,41 +529,42 @@ export function EditInstrumentsModal( }} /> ))} - - - - - {t('add_custom_tips')} - - dispatch(createCustomTiprackDef(e))} - /> - - {pipetteVolume === 'p1000' && - robotType === FLEX_ROBOT_TYPE ? null : ( - { - dispatch( - setFeatureFlags({ - OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, - }) - ) - }} - textDecoration={TYPOGRAPHY.textDecorationUnderline} - > + + - {allowAllTipracks - ? t('show_default_tips') - : t('show_all_tips')} + {t('add_custom_tips')} - - )} - + dispatch(createCustomTiprackDef(e))} + /> + + {pipetteVolume === 'p1000' && + robotType === FLEX_ROBOT_TYPE ? null : ( + { + dispatch( + setFeatureFlags({ + OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, + }) + ) + }} + textDecoration={TYPOGRAPHY.textDecorationUnderline} + > + + {allowAllTipracks + ? t('show_default_tips') + : t('show_all_tips')} + + + )} + + ) })() diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx index b9f16f5e23c..f63d8532e60 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx @@ -10,6 +10,7 @@ import { } from '@opentrons/shared-data' import { ALIGN_CENTER, + ALIGN_STRETCH, Box, Btn, Checkbox, @@ -323,6 +324,9 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { gap: ${SPACING.spacing4}; display: ${DISPLAY_FLEX}; flex-wrap: ${WRAP}; + align-items: ${ALIGN_CENTER}; + align-content: ${ALIGN_CENTER}; + align-self: ${ALIGN_STRETCH}; `} > {Object.entries(tiprackOptions).map( @@ -354,45 +358,45 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { ) )} - - - - - {t('add_custom_tips')} - - - dispatch(createCustomTiprackDef(e)) - } - /> - - {pipetteVolume === 'p1000' && - robotType === FLEX_ROBOT_TYPE ? null : ( - { - if (allowAllTipracks) { - dispatch( - setFeatureFlags({ - OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, - }) - ) - } else { - setIncompatibleTip(true) - } - }} - textDecoration={ - TYPOGRAPHY.textDecorationUnderline - } - > + + - {allowAllTipracks - ? t('show_default_tips') - : t('show_all_tips')} + {t('add_custom_tips')} - - )} + + dispatch(createCustomTiprackDef(e)) + } + /> + + {pipetteVolume === 'p1000' && + robotType === FLEX_ROBOT_TYPE ? null : ( + { + if (allowAllTipracks) { + dispatch( + setFeatureFlags({ + OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, + }) + ) + } else { + setIncompatibleTip(true) + } + }} + textDecoration={ + TYPOGRAPHY.textDecorationUnderline + } + > + + {allowAllTipracks + ? t('show_default_tips') + : t('show_all_tips')} + + + )} + ) diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx index 922c48a0959..3efc8ec11b4 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectPipettes.test.tsx @@ -77,7 +77,7 @@ describe('SelectPipettes', () => { it('renders the first page of select pipettes for a Flex', () => { render(props) screen.getByText('Step 2') - screen.getByText('Add a pipette and tips') + screen.getByText('Add a pipette') screen.getByText( 'Pick your first pipette. If you need a second pipette, you can add it next.' ) @@ -125,7 +125,7 @@ describe('SelectPipettes', () => { } render(props) screen.getByText('Step 2') - screen.getByText('Add a pipette and tips') + screen.getByText('Add a pipette') screen.getByText( 'Pick your first pipette. If you need a second pipette, you can add it next.' ) From 9d16384320715389a1418014694d2a7da0c59a60 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 8 Oct 2024 09:31:54 -0400 Subject: [PATCH 013/101] fix(app,api): Display thermocycler profile cycles (#16414) We identified a simple bug with the app: it displays wonky numbers for thermocycler profile cycles: ![image (1)](https://github.com/user-attachments/assets/8005c0b6-c55e-4b48-bf50-7d90f91fa63b) This wouldn't repeat anything at all, what's going on? Well, it turns out that when Protocol Designer added thermocycler support, they wanted something a bit more in depth than the Python protocol API's "list of steps, number of times to repeat the cycle" format. They wanted users to be able to add single steps and cycles, in arbitrary order. To make the two styles work with the engine, we made the engine's `thermocycler/runProfile` command take a flat list of steps - any of the structure of repeated commands was removed. In the app's `CommandText` display, we then had to fix up the way we displayed profiles to remove references to a `repetitions` value that now didn't exist... and we just didn't, instead setting the `repetitions` element to just be the number of steps in the profile. Fixing this ended up being a bit involved. ## summary - ad172608c64c8d00844a7748ae282a1c8284b745 Make a new interface for the hardware controller `execute_profile` function of the thermocycler, since it's important that thermocycler cycles execute inside an asyncio worker so that they won't be interrupted by e.g. pausing. This new interface takes the PD style of protocol. It relies under the hood on the same function that actually sends stuff to the thermocycler, so there shouldn't be any functional execution changes. - 335e25ad61ec36927060993a21342c20ec325c52 Make a new `thermocycler/runExtendedProfile` command in the protocol engine, that takes as its parameters a structured list of either steps or cycles, adhering to PD's expectations since the PAPI's expectations are a strict subset of PD. This implements its command by using the new hardware controller. - e842a2a47da30a62437c093516292b48a676369b Use the new structured data to implement a command text for the new command that can render the equivalent of what PD makes (and, again, what the PAPI does, which is a strict subset) - e842a2a47da30a62437c093516292b48a676369b Also make the old command text not use a "repetitions" keyword that doesn't exist - 0043ab7cf281b4dff4277c1350831671232559ea Switch the PAPI to emitting the new command in api 2.21 ## new UI These UI changes will be on https://s3-us-west-2.amazonaws.com/opentrons-components/rqa-2771-thermocycler-extended-profiles/index.html?path=/docs/app-molecules-command-commandtext--docs for the desktop and general text and https://s3-us-west-2.amazonaws.com/opentrons-components/rqa-2771-thermocycler-extended-profiles/index.html?path=/docs/app-molecules-command-command--docs for the ODD colored-background elements whenever they upload. Here's a screenshot of what they were like when the PR was opened: `CommandText` of `thermocycler/runProfile` : ![runProfileCommandText](https://github.com/user-attachments/assets/dcad4ce8-2f0e-418d-8618-066a084dc832) Note the change to the first line. This needs wordsmithing. `CommandText` of `thermocycler/runExtendedProfile`: ![runProfileExtended](https://github.com/user-attachments/assets/6b1a1977-9200-4d78-84dc-2961d76cfff9) Note the two-level rendering. The common case will probably be that there's only cycles ## todo - [x] wordsmith - ~[ ] add to PD?~ no, but structured in a way that it will be easy eventually - [x] Test. Oh boy Closes RQA-2771 --------- Co-authored-by: Ed Cormany --- .../hardware_control/modules/thermocycler.py | 61 +- .../hardware_control/modules/types.py | 5 + .../protocol_api/core/engine/module_core.py | 56 +- .../opentrons/protocol_api/module_contexts.py | 10 +- api/src/opentrons/protocol_api/validation.py | 3 +- .../commands/command_unions.py | 5 + .../commands/thermocycler/__init__.py | 19 + .../thermocycler/run_extended_profile.py | 166 + .../opentrons/protocols/api_support/types.py | 15 +- .../modules/test_hc_thermocycler.py | 61 + .../core/engine/test_thermocycler_core.py | 81 +- .../thermocycler/test_run_extended_profile.py | 115 + .../en/protocol_command_text.json | 6 +- app/src/molecules/Command/Command.stories.tsx | 22 +- app/src/molecules/Command/CommandText.tsx | 163 +- .../Command/__fixtures__/doItAllV10.json | 4863 +++++++++++++++++ .../molecules/Command/__fixtures__/index.ts | 2 +- .../Command/__tests__/CommandText.test.tsx | 130 +- app/src/molecules/Command/hooks/index.ts | 2 + .../hooks/useCommandTextString/index.tsx | 58 +- .../getTCRunExtendedProfileCommandText.ts | 67 + .../utils/getTCRunProfileCommandText.ts | 15 +- .../hooks/useCommandTextString/utils/index.ts | 1 + .../CategorizedStepContent.stories.tsx | 14 +- .../__tests__/useRecoveryToasts.test.tsx | 34 +- .../hooks/useRecoveryToasts.ts | 24 +- .../__tests__/formatDuration.test.tsx | 55 +- .../transformations/formatDuration.ts | 39 +- shared-data/command/schemas/10.json | 106 + shared-data/command/types/module.ts | 23 + 30 files changed, 6132 insertions(+), 89 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py create mode 100644 api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py create mode 100644 app/src/molecules/Command/__fixtures__/doItAllV10.json create mode 100644 app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts diff --git a/api/src/opentrons/hardware_control/modules/thermocycler.py b/api/src/opentrons/hardware_control/modules/thermocycler.py index bcaac8650d9..4a1b2fe038b 100644 --- a/api/src/opentrons/hardware_control/modules/thermocycler.py +++ b/api/src/opentrons/hardware_control/modules/thermocycler.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Callable, Optional, List, Dict, Mapping +from typing import Callable, Optional, List, Dict, Mapping, Union, cast from opentrons.drivers.rpi_drivers.types import USBPort from opentrons.drivers.types import ThermocyclerLidStatus, Temperature, PlateTemperature from opentrons.hardware_control.modules.lid_temp_status import LidTemperatureStatus @@ -363,6 +363,39 @@ async def cycle_temperatures( self.make_cancellable(task) await task + async def execute_profile( + self, + profile: List[Union[types.ThermocyclerCycle, types.ThermocyclerStep]], + volume: Optional[float] = None, + ) -> None: + """Begin a set temperature profile, with both repeating and non-repeating steps. + + Args: + profile: The temperature profile to follow. + volume: Optional volume + + Returns: None + """ + await self.wait_for_is_running() + self._total_cycle_count = 0 + self._total_step_count = 0 + self._current_cycle_index = 0 + self._current_step_index = 0 + for step_or_cycle in profile: + if "steps" in step_or_cycle: + # basically https://github.com/python/mypy/issues/14766 + this_cycle = cast(types.ThermocyclerCycle, step_or_cycle) + self._total_cycle_count += this_cycle["repetitions"] + self._total_step_count += ( + len(this_cycle["steps"]) * this_cycle["repetitions"] + ) + else: + self._total_step_count += 1 + self._total_cycle_count += 1 + task = self._loop.create_task(self._execute_profile(profile, volume)) + self.make_cancellable(task) + await task + async def set_lid_temperature(self, temperature: float) -> None: """Set the lid temperature in degrees Celsius""" await self.wait_for_is_running() @@ -574,7 +607,7 @@ async def _execute_cycles( self, steps: List[types.ThermocyclerStep], repetitions: int, - volume: Optional[float] = None, + volume: Optional[float], ) -> None: """ Execute cycles. @@ -592,6 +625,30 @@ async def _execute_cycles( self._current_step_index = step_idx + 1 # science starts at 1 await self._execute_cycle_step(step, volume) + async def _execute_profile( + self, + profile: List[Union[types.ThermocyclerCycle, types.ThermocyclerStep]], + volume: Optional[float], + ) -> None: + """ + Execute profiles. + + Profiles command a thermocycler pattern that can contain multiple cycles and out-of-cycle steps. + """ + self._current_cycle_index = 0 + self._current_step_index = 0 + for step_or_cycle in profile: + self._current_cycle_index += 1 + if "repetitions" in step_or_cycle: + # basically https://github.com/python/mypy/issues/14766 + this_cycle = cast(types.ThermocyclerCycle, step_or_cycle) + for rep in range(this_cycle["repetitions"]): + for step in this_cycle["steps"]: + self._current_step_index += 1 + await self._execute_cycle_step(step, volume) + else: + await self._execute_cycle_step(step_or_cycle, volume) + # TODO(mc, 2022-10-13): why does this exist? # Do the driver and poller really need to be disconnected? # Could we accomplish the same thing by latching the error state diff --git a/api/src/opentrons/hardware_control/modules/types.py b/api/src/opentrons/hardware_control/modules/types.py index 6c9f6a3e915..9b7c33058d4 100644 --- a/api/src/opentrons/hardware_control/modules/types.py +++ b/api/src/opentrons/hardware_control/modules/types.py @@ -39,6 +39,11 @@ class ThermocyclerStep(ThermocyclerStepBase, total=False): hold_time_minutes: float +class ThermocyclerCycle(TypedDict): + steps: List[ThermocyclerStep] + repetitions: int + + UploadFunction = Callable[[str, str, Dict[str, Any]], Awaitable[Tuple[bool, str]]] diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 729037425a8..47b49c54e23 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -1,14 +1,13 @@ """Protocol API module implementation logic.""" from __future__ import annotations -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Union from opentrons.hardware_control import SynchronousAdapter, modules as hw_modules from opentrons.hardware_control.modules.types import ( ModuleModel, TemperatureStatus, MagneticStatus, - ThermocyclerStep, SpeedStatus, module_model_from_string, ) @@ -27,7 +26,7 @@ CannotPerformModuleAction, ) -from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.api_support.types import APIVersion, ThermocyclerStep from ... import validation from ..module import ( @@ -327,15 +326,13 @@ def wait_for_lid_temperature(self) -> None: cmd.thermocycler.WaitForLidTemperatureParams(moduleId=self.module_id) ) - def execute_profile( + def _execute_profile_pre_221( self, steps: List[ThermocyclerStep], repetitions: int, - block_max_volume: Optional[float] = None, + block_max_volume: Optional[float], ) -> None: - """Execute a Thermocycler Profile.""" - self._repetitions = repetitions - self._step_count = len(steps) + """Execute a thermocycler profile using thermocycler/runProfile and flattened steps.""" engine_steps = [ cmd.thermocycler.RunProfileStepParams( celsius=step["temperature"], @@ -352,6 +349,49 @@ def execute_profile( ) ) + def _execute_profile_post_221( + self, + steps: List[ThermocyclerStep], + repetitions: int, + block_max_volume: Optional[float], + ) -> None: + """Execute a thermocycler profile using thermocycler/runExtendedProfile.""" + engine_steps: List[ + Union[cmd.thermocycler.ProfileCycle, cmd.thermocycler.ProfileStep] + ] = [ + cmd.thermocycler.ProfileCycle( + repetitions=repetitions, + steps=[ + cmd.thermocycler.ProfileStep( + celsius=step["temperature"], + holdSeconds=step["hold_time_seconds"], + ) + for step in steps + ], + ) + ] + self._engine_client.execute_command( + cmd.thermocycler.RunExtendedProfileParams( + moduleId=self.module_id, + profileElements=engine_steps, + blockMaxVolumeUl=block_max_volume, + ) + ) + + def execute_profile( + self, + steps: List[ThermocyclerStep], + repetitions: int, + block_max_volume: Optional[float] = None, + ) -> None: + """Execute a Thermocycler Profile.""" + self._repetitions = repetitions + self._step_count = len(steps) + if self.api_version >= APIVersion(2, 21): + return self._execute_profile_post_221(steps, repetitions, block_max_volume) + else: + return self._execute_profile_pre_221(steps, repetitions, block_max_volume) + def deactivate_lid(self) -> None: """Turn off the heated lid.""" self._engine_client.execute_command( diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index f9fcc18ca00..5d182843dcc 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -8,10 +8,9 @@ from opentrons_shared_data.module.types import ModuleModel, ModuleType from opentrons.legacy_broker import LegacyBroker -from opentrons.hardware_control.modules import ThermocyclerStep from opentrons.legacy_commands import module_commands as cmds from opentrons.legacy_commands.publisher import CommandPublisher, publish -from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.api_support.types import APIVersion, ThermocyclerStep from opentrons.protocols.api_support.util import ( APIVersionError, requires_version, @@ -629,6 +628,13 @@ def execute_profile( ``hold_time_minutes`` and ``hold_time_seconds`` must be defined and for each step. + .. note: + + Before API Version 2.21, Thermocycler profiles run with this command + would be listed in the app as having a number of repetitions equal to + their step count. At or above API Version 2.21, the structure of the + Thermocycler cycles is preserved. + """ repetitions = validation.ensure_thermocycler_repetition_count(repetitions) validated_steps = validation.ensure_thermocycler_profile_steps(steps) diff --git a/api/src/opentrons/protocol_api/validation.py b/api/src/opentrons/protocol_api/validation.py index 08e56fdef8f..43c83eca2e0 100644 --- a/api/src/opentrons/protocol_api/validation.py +++ b/api/src/opentrons/protocol_api/validation.py @@ -18,7 +18,7 @@ from opentrons_shared_data.pipette.types import PipetteNameType from opentrons_shared_data.robot.types import RobotType -from opentrons.protocols.api_support.types import APIVersion +from opentrons.protocols.api_support.types import APIVersion, ThermocyclerStep from opentrons.protocols.api_support.util import APIVersionError from opentrons.protocols.models import LabwareDefinition from opentrons.types import Mount, DeckSlotName, StagingSlotName, Location @@ -30,7 +30,6 @@ HeaterShakerModuleModel, MagneticBlockModel, AbsorbanceReaderModel, - ThermocyclerStep, ) from .disposal_locations import TrashBin, WasteChute diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index f01e10aa63e..2c7f768945f 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -380,6 +380,7 @@ thermocycler.OpenLid, thermocycler.CloseLid, thermocycler.RunProfile, + thermocycler.RunExtendedProfile, absorbance_reader.CloseLid, absorbance_reader.OpenLid, absorbance_reader.Initialize, @@ -456,6 +457,7 @@ thermocycler.OpenLidParams, thermocycler.CloseLidParams, thermocycler.RunProfileParams, + thermocycler.RunExtendedProfileParams, absorbance_reader.CloseLidParams, absorbance_reader.OpenLidParams, absorbance_reader.InitializeParams, @@ -530,6 +532,7 @@ thermocycler.OpenLidCommandType, thermocycler.CloseLidCommandType, thermocycler.RunProfileCommandType, + thermocycler.RunExtendedProfileCommandType, absorbance_reader.CloseLidCommandType, absorbance_reader.OpenLidCommandType, absorbance_reader.InitializeCommandType, @@ -605,6 +608,7 @@ thermocycler.OpenLidCreate, thermocycler.CloseLidCreate, thermocycler.RunProfileCreate, + thermocycler.RunExtendedProfileCreate, absorbance_reader.CloseLidCreate, absorbance_reader.OpenLidCreate, absorbance_reader.InitializeCreate, @@ -681,6 +685,7 @@ thermocycler.OpenLidResult, thermocycler.CloseLidResult, thermocycler.RunProfileResult, + thermocycler.RunExtendedProfileResult, absorbance_reader.CloseLidResult, absorbance_reader.OpenLidResult, absorbance_reader.InitializeResult, diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py b/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py index b0ffdd53ce9..60e5c62591c 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py @@ -73,6 +73,16 @@ RunProfileCreate, ) +from .run_extended_profile import ( + RunExtendedProfileCommandType, + RunExtendedProfileParams, + RunExtendedProfileResult, + RunExtendedProfile, + RunExtendedProfileCreate, + ProfileCycle, + ProfileStep, +) + __all__ = [ # Set target block temperature command models @@ -130,4 +140,13 @@ "RunProfileResult", "RunProfile", "RunProfileCreate", + # Run extended profile command models. + "RunExtendedProfileCommandType", + "RunExtendedProfileParams", + "RunExtendedProfileStepParams", + "RunExtendedProfileResult", + "RunExtendedProfile", + "RunExtendedProfileCreate", + "ProfileCycle", + "ProfileStep", ] diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py b/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py new file mode 100644 index 00000000000..3cf8a67bf41 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py @@ -0,0 +1,166 @@ +"""Command models to execute a Thermocycler profile.""" +from __future__ import annotations +from typing import List, Optional, TYPE_CHECKING, overload, Union +from typing_extensions import Literal, Type + +from pydantic import BaseModel, Field + +from opentrons.hardware_control.modules.types import ThermocyclerStep, ThermocyclerCycle + +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence + +if TYPE_CHECKING: + from opentrons.protocol_engine.state.state import StateView + from opentrons.protocol_engine.execution import EquipmentHandler + from opentrons.protocol_engine.state.module_substates.thermocycler_module_substate import ( + ThermocyclerModuleSubState, + ) + + +RunExtendedProfileCommandType = Literal["thermocycler/runExtendedProfile"] + + +class ProfileStep(BaseModel): + """An individual step in a Thermocycler extended profile.""" + + celsius: float = Field(..., description="Target temperature in °C.") + holdSeconds: float = Field( + ..., description="Time to hold target temperature in seconds." + ) + + +class ProfileCycle(BaseModel): + """An individual cycle in a Thermocycler extended profile.""" + + steps: List[ProfileStep] = Field(..., description="Steps to repeat.") + repetitions: int = Field(..., description="Number of times to repeat the steps.") + + +class RunExtendedProfileParams(BaseModel): + """Input parameters for an individual Thermocycler profile step.""" + + moduleId: str = Field(..., description="Unique ID of the Thermocycler.") + profileElements: List[Union[ProfileStep, ProfileCycle]] = Field( + ..., + description="Elements of the profile. Each can be either a step or a cycle.", + ) + blockMaxVolumeUl: Optional[float] = Field( + None, + description="Amount of liquid in uL of the most-full well" + " in labware loaded onto the thermocycler.", + ) + + +class RunExtendedProfileResult(BaseModel): + """Result data from running a Thermocycler profile.""" + + +def _transform_profile_step( + step: ProfileStep, thermocycler_state: ThermocyclerModuleSubState +) -> ThermocyclerStep: + + return ThermocyclerStep( + temperature=thermocycler_state.validate_target_block_temperature(step.celsius), + hold_time_seconds=step.holdSeconds, + ) + + +@overload +def _transform_profile_element( + element: ProfileStep, thermocycler_state: ThermocyclerModuleSubState +) -> ThermocyclerStep: + ... + + +@overload +def _transform_profile_element( + element: ProfileCycle, thermocycler_state: ThermocyclerModuleSubState +) -> ThermocyclerCycle: + ... + + +def _transform_profile_element( + element: Union[ProfileStep, ProfileCycle], + thermocycler_state: ThermocyclerModuleSubState, +) -> Union[ThermocyclerStep, ThermocyclerCycle]: + if isinstance(element, ProfileStep): + return _transform_profile_step(element, thermocycler_state) + else: + return ThermocyclerCycle( + steps=[ + _transform_profile_step(step, thermocycler_state) + for step in element.steps + ], + repetitions=element.repetitions, + ) + + +class RunExtendedProfileImpl( + AbstractCommandImpl[ + RunExtendedProfileParams, SuccessData[RunExtendedProfileResult, None] + ] +): + """Execution implementation of a Thermocycler's run profile command.""" + + def __init__( + self, + state_view: StateView, + equipment: EquipmentHandler, + **unused_dependencies: object, + ) -> None: + self._state_view = state_view + self._equipment = equipment + + async def execute( + self, params: RunExtendedProfileParams + ) -> SuccessData[RunExtendedProfileResult, None]: + """Run a Thermocycler profile.""" + thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( + params.moduleId + ) + thermocycler_hardware = self._equipment.get_module_hardware_api( + thermocycler_state.module_id + ) + + profile = [ + _transform_profile_element(element, thermocycler_state) + for element in params.profileElements + ] + target_volume: Optional[float] + if params.blockMaxVolumeUl is not None: + target_volume = thermocycler_state.validate_max_block_volume( + params.blockMaxVolumeUl + ) + else: + target_volume = None + + if thermocycler_hardware is not None: + # TODO(jbl 2022-06-27) hardcoded constant 1 for `repetitions` should be + # moved from HardwareControlAPI to the Python ProtocolContext + await thermocycler_hardware.execute_profile( + profile=profile, volume=target_volume + ) + + return SuccessData(public=RunExtendedProfileResult(), private=None) + + +class RunExtendedProfile( + BaseCommand[RunExtendedProfileParams, RunExtendedProfileResult, ErrorOccurrence] +): + """A command to execute a Thermocycler profile run.""" + + commandType: RunExtendedProfileCommandType = "thermocycler/runExtendedProfile" + params: RunExtendedProfileParams + result: Optional[RunExtendedProfileResult] + + _ImplementationCls: Type[RunExtendedProfileImpl] = RunExtendedProfileImpl + + +class RunExtendedProfileCreate(BaseCommandCreate[RunExtendedProfileParams]): + """A request to execute a Thermocycler profile run.""" + + commandType: RunExtendedProfileCommandType = "thermocycler/runExtendedProfile" + params: RunExtendedProfileParams + + _CommandCls: Type[RunExtendedProfile] = RunExtendedProfile diff --git a/api/src/opentrons/protocols/api_support/types.py b/api/src/opentrons/protocols/api_support/types.py index 6d3af89bcf9..d16fa8ddf73 100644 --- a/api/src/opentrons/protocols/api_support/types.py +++ b/api/src/opentrons/protocols/api_support/types.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import NamedTuple +from typing import NamedTuple, TypedDict class APIVersion(NamedTuple): @@ -17,3 +17,16 @@ def from_string(cls, inp: str) -> APIVersion: def __str__(self) -> str: return f"{self.major}.{self.minor}" + + +class ThermocyclerStepBase(TypedDict): + """Required elements of a thermocycler step: the temperature.""" + + temperature: float + + +class ThermocyclerStep(ThermocyclerStepBase, total=False): + """Optional elements of a thermocycler step: the hold time. One of these must be present.""" + + hold_time_seconds: float + hold_time_minutes: float diff --git a/api/tests/opentrons/hardware_control/modules/test_hc_thermocycler.py b/api/tests/opentrons/hardware_control/modules/test_hc_thermocycler.py index d893d9912d0..6e90068ac1f 100644 --- a/api/tests/opentrons/hardware_control/modules/test_hc_thermocycler.py +++ b/api/tests/opentrons/hardware_control/modules/test_hc_thermocycler.py @@ -329,6 +329,67 @@ async def test_cycle_temperature( ) +async def test_execute_profile( + set_temperature_subject: modules.Thermocycler, set_plate_temp_spy: mock.AsyncMock +) -> None: + """It should send a series of set_plate_temperatures from a profile.""" + await set_temperature_subject.execute_profile( + [ + {"temperature": 42, "hold_time_seconds": 30}, + { + "repetitions": 5, + "steps": [ + {"temperature": 20, "hold_time_minutes": 1}, + {"temperature": 30, "hold_time_seconds": 1}, + ], + }, + {"temperature": 90, "hold_time_seconds": 2}, + { + "repetitions": 10, + "steps": [ + {"temperature": 10, "hold_time_minutes": 2}, + {"temperature": 20, "hold_time_seconds": 5}, + ], + }, + ], + volume=123, + ) + assert set_plate_temp_spy.call_args_list == [ + mock.call(temp=42, hold_time=30, volume=123), + mock.call(temp=20, hold_time=60, volume=123), + mock.call(temp=30, hold_time=1, volume=123), + mock.call(temp=20, hold_time=60, volume=123), + mock.call(temp=30, hold_time=1, volume=123), + mock.call(temp=20, hold_time=60, volume=123), + mock.call(temp=30, hold_time=1, volume=123), + mock.call(temp=20, hold_time=60, volume=123), + mock.call(temp=30, hold_time=1, volume=123), + mock.call(temp=20, hold_time=60, volume=123), + mock.call(temp=30, hold_time=1, volume=123), + mock.call(temp=90, hold_time=2, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + mock.call(temp=10, hold_time=120, volume=123), + mock.call(temp=20, hold_time=5, volume=123), + ] + + async def test_sync_error_response_to_poller( subject_mocked_driver: modules.Thermocycler, mock_driver: mock.AsyncMock, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_thermocycler_core.py b/api/tests/opentrons/protocol_api/core/engine/test_thermocycler_core.py index eb429065d0a..1ee868ad84b 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_thermocycler_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_thermocycler_core.py @@ -1,5 +1,7 @@ """Tests for the engine based Protocol API module core implementations.""" +from typing import cast import pytest +from _pytest.fixtures import SubRequest from decoy import Decoy from opentrons.drivers.types import ThermocyclerLidStatus @@ -13,6 +15,8 @@ from opentrons.protocol_engine.clients import SyncClient as EngineClient from opentrons.protocol_api.core.engine.module_core import ThermocyclerModuleCore from opentrons.protocol_api import MAX_SUPPORTED_VERSION +from opentrons.protocols.api_support.types import APIVersion +from ... import versions_below, versions_at_or_above SyncThermocyclerHardware = SynchronousAdapter[Thermocycler] @@ -34,7 +38,7 @@ def subject( mock_engine_client: EngineClient, mock_sync_module_hardware: SyncThermocyclerHardware, ) -> ThermocyclerModuleCore: - """Get a HeaterShakerModuleCore test subject.""" + """Get a ThermocyclerModuleCore test subject.""" return ThermocyclerModuleCore( module_id="1234", engine_client=mock_engine_client, @@ -43,6 +47,36 @@ def subject( ) +@pytest.fixture(params=versions_below(APIVersion(2, 21), flex_only=False)) +def subject_below_221( + request: SubRequest, + mock_engine_client: EngineClient, + mock_sync_module_hardware: SyncThermocyclerHardware, +) -> ThermocyclerModuleCore: + """Get a ThermocyclerCore below API version 2.21.""" + return ThermocyclerModuleCore( + module_id="1234", + engine_client=mock_engine_client, + api_version=cast(APIVersion, request.param), + sync_module_hardware=mock_sync_module_hardware, + ) + + +@pytest.fixture(params=versions_at_or_above(APIVersion(2, 21))) +def subject_at_or_above_221( + request: SubRequest, + mock_engine_client: EngineClient, + mock_sync_module_hardware: SyncThermocyclerHardware, +) -> ThermocyclerModuleCore: + """Get a ThermocyclerCore below API version 2.21.""" + return ThermocyclerModuleCore( + module_id="1234", + engine_client=mock_engine_client, + api_version=cast(APIVersion, request.param), + sync_module_hardware=mock_sync_module_hardware, + ) + + def test_create( decoy: Decoy, mock_engine_client: EngineClient, @@ -159,11 +193,13 @@ def test_wait_for_lid_temperature( ) -def test_execute_profile( - decoy: Decoy, mock_engine_client: EngineClient, subject: ThermocyclerModuleCore +def test_execute_profile_below_221( + decoy: Decoy, + mock_engine_client: EngineClient, + subject_below_221: ThermocyclerModuleCore, ) -> None: """It should run a thermocycler profile with the engine client.""" - subject.execute_profile( + subject_below_221.execute_profile( steps=[{"temperature": 45.6, "hold_time_seconds": 12.3}], repetitions=2, block_max_volume=78.9, @@ -187,6 +223,43 @@ def test_execute_profile( ) +def test_execute_profile_above_221( + decoy: Decoy, + mock_engine_client: EngineClient, + subject_at_or_above_221: ThermocyclerModuleCore, +) -> None: + """It should run a thermocycler profile with the engine client.""" + subject_at_or_above_221.execute_profile( + steps=[ + {"temperature": 45.6, "hold_time_seconds": 12.3}, + {"temperature": 78.9, "hold_time_seconds": 45.6}, + ], + repetitions=2, + block_max_volume=25, + ) + decoy.verify( + mock_engine_client.execute_command( + cmd.thermocycler.RunExtendedProfileParams( + moduleId="1234", + profileElements=[ + cmd.thermocycler.ProfileCycle( + repetitions=2, + steps=[ + cmd.thermocycler.ProfileStep( + celsius=45.6, holdSeconds=12.3 + ), + cmd.thermocycler.ProfileStep( + celsius=78.9, holdSeconds=45.6 + ), + ], + ) + ], + blockMaxVolumeUl=25, + ) + ) + ) + + def test_deactivate_lid( decoy: Decoy, mock_engine_client: EngineClient, subject: ThermocyclerModuleCore ) -> None: diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py new file mode 100644 index 00000000000..9dcefceb9f1 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_extended_profile.py @@ -0,0 +1,115 @@ +"""Test Thermocycler run profile command implementation.""" +from typing import List, Union + +from decoy import Decoy + +from opentrons.hardware_control.modules import Thermocycler + +from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.state.module_substates import ( + ThermocyclerModuleSubState, + ThermocyclerModuleId, +) +from opentrons.protocol_engine.execution import EquipmentHandler +from opentrons.protocol_engine.commands import thermocycler as tc_commands +from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.thermocycler.run_extended_profile import ( + RunExtendedProfileImpl, + ProfileStep, + ProfileCycle, +) + + +async def test_run_extended_profile( + decoy: Decoy, + state_view: StateView, + equipment: EquipmentHandler, +) -> None: + """It should be able to execute the specified module's profile run.""" + subject = RunExtendedProfileImpl(state_view=state_view, equipment=equipment) + + step_data: List[Union[ProfileStep, ProfileCycle]] = [ + ProfileStep(celsius=12.3, holdSeconds=45), + ProfileCycle( + steps=[ + ProfileStep(celsius=78.9, holdSeconds=910), + ProfileStep(celsius=12, holdSeconds=1), + ], + repetitions=2, + ), + ProfileStep(celsius=45.6, holdSeconds=78), + ProfileCycle( + steps=[ + ProfileStep(celsius=56, holdSeconds=11), + ProfileStep(celsius=34, holdSeconds=10), + ], + repetitions=1, + ), + ] + data = tc_commands.RunExtendedProfileParams( + moduleId="input-thermocycler-id", + profileElements=step_data, + blockMaxVolumeUl=56.7, + ) + expected_result = tc_commands.RunExtendedProfileResult() + + tc_module_substate = decoy.mock(cls=ThermocyclerModuleSubState) + tc_hardware = decoy.mock(cls=Thermocycler) + + decoy.when( + state_view.modules.get_thermocycler_module_substate("input-thermocycler-id") + ).then_return(tc_module_substate) + + decoy.when(tc_module_substate.module_id).then_return( + ThermocyclerModuleId("thermocycler-id") + ) + + # Stub temperature validation from hs module view + decoy.when(tc_module_substate.validate_target_block_temperature(12.3)).then_return( + 32.1 + ) + decoy.when(tc_module_substate.validate_target_block_temperature(78.9)).then_return( + 78.9 + ) + decoy.when(tc_module_substate.validate_target_block_temperature(12)).then_return(12) + decoy.when(tc_module_substate.validate_target_block_temperature(45.6)).then_return( + 65.4 + ) + decoy.when(tc_module_substate.validate_target_block_temperature(56)).then_return(56) + decoy.when(tc_module_substate.validate_target_block_temperature(34)).then_return(34) + + # Stub volume validation from hs module view + decoy.when(tc_module_substate.validate_max_block_volume(56.7)).then_return(76.5) + + # Get attached hardware modules + decoy.when( + equipment.get_module_hardware_api(ThermocyclerModuleId("thermocycler-id")) + ).then_return(tc_hardware) + + result = await subject.execute(data) + + decoy.verify( + await tc_hardware.execute_profile( + profile=[ + {"temperature": 32.1, "hold_time_seconds": 45}, + { + "steps": [ + {"temperature": 78.9, "hold_time_seconds": 910}, + {"temperature": 12, "hold_time_seconds": 1}, + ], + "repetitions": 2, + }, + {"temperature": 65.4, "hold_time_seconds": 78}, + { + "steps": [ + {"temperature": 56, "hold_time_seconds": 11}, + {"temperature": 34, "hold_time_seconds": 10}, + ], + "repetitions": 1, + }, + ], + volume=76.5, + ), + times=1, + ) + assert result == SuccessData(public=expected_result, private=None) diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index 17d60a8f967..c78aef13785 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -71,8 +71,10 @@ "slot": "Slot {{slot_name}}", "target_temperature": "target temperature", "tc_awaiting_for_duration": "Waiting for Thermocycler profile to complete", - "tc_run_profile_steps": "temperature: {{celsius}}°C, seconds: {{seconds}}", - "tc_starting_profile": "Thermocycler starting {{repetitions}} repetitions of cycle composed of the following steps:", + "tc_run_profile_steps": "Temperature: {{celsius}}°C, hold time: {{duration}}", + "tc_starting_extended_profile_cycle": "{{repetitions}} repetitions of the following steps:", + "tc_starting_extended_profile": "Running thermocycler profile with {{elementCount}} total steps and cycles:", + "tc_starting_profile": "Running thermocycler profile with {{stepCount}} steps:", "touch_tip": "Touching tip", "trash_bin_in_slot": "Trash Bin in {{slot_name}}", "unlatching_hs_latch": "Unlatching labware on Heater-Shaker", diff --git a/app/src/molecules/Command/Command.stories.tsx b/app/src/molecules/Command/Command.stories.tsx index 1fe64215ef3..43e81fa5541 100644 --- a/app/src/molecules/Command/Command.stories.tsx +++ b/app/src/molecules/Command/Command.stories.tsx @@ -18,7 +18,7 @@ interface StorybookArgs { } const availableCommandTypes = uniq( - Fixtures.mockQIASeqTextData.commands.map(command => command.commandType) + Fixtures.mockDoItAllTextData.commands.map(command => command.commandType) ) const commandsByType: Partial> = {} @@ -26,7 +26,7 @@ function commandsOfType(type: CommandType): RunTimeCommand[] { if (type in commandsByType) { return commandsByType[type] } - commandsByType[type] = Fixtures.mockQIASeqTextData.commands.filter( + commandsByType[type] = Fixtures.mockDoItAllTextData.commands.filter( command => command.commandType === type ) return commandsByType[type] @@ -43,8 +43,8 @@ function safeCommandOfType(type: CommandType, index: number): RunTimeCommand { function Wrapper(props: StorybookArgs): JSX.Element { const command = props.selectCommandBy === 'protocol index' - ? Fixtures.mockQIASeqTextData.commands[ - props.commandIndex < Fixtures.mockQIASeqTextData.commands.length + ? Fixtures.mockDoItAllTextData.commands[ + props.commandIndex < Fixtures.mockDoItAllTextData.commands.length ? props.commandIndex : -1 ] @@ -52,7 +52,7 @@ function Wrapper(props: StorybookArgs): JSX.Element { return command == null ? null : ( = { control: { type: 'range', min: 0, - max: Fixtures.mockQIASeqTextData.commands.length - 1, + max: Fixtures.mockDoItAllTextData.commands.length - 1, }, defaultValue: 0, if: { arg: 'selectCommandBy', eq: 'protocol index' }, @@ -161,6 +161,16 @@ export const ThermocyclerProfile: Story = { }, } +export const ThermocyclerExtendedProfile: Story = { + args: { + selectCommandBy: 'command type', + commandType: 'thermocycler/runExtendedProfile', + commandTypeIndex: 0, + aligned: 'left', + state: 'current', + }, +} + export const VeryLongCommand: Story = { args: { selectCommandBy: 'command type', diff --git a/app/src/molecules/Command/CommandText.tsx b/app/src/molecules/Command/CommandText.tsx index 70fb5281817..00c7337104b 100644 --- a/app/src/molecules/Command/CommandText.tsx +++ b/app/src/molecules/Command/CommandText.tsx @@ -16,6 +16,10 @@ import { useCommandTextString } from './hooks' import type { RobotType, RunTimeCommand } from '@opentrons/shared-data' import type { StyleProps } from '@opentrons/components' import type { CommandTextData } from './types' +import type { + GetTCRunExtendedProfileCommandTextResult, + GetTCRunProfileCommandTextResult, +} from './hooks' interface LegacySTProps { as?: React.ComponentProps['as'] @@ -39,22 +43,35 @@ interface BaseProps extends StyleProps { propagateTextLimit?: boolean } export function CommandText(props: BaseProps & STProps): JSX.Element | null { - const { commandText, stepTexts } = useCommandTextString({ + const commandText = useCommandTextString({ ...props, }) - switch (props.command.commandType) { + switch (commandText.kind) { case 'thermocycler/runProfile': { return ( + ) + } + case 'thermocycler/runExtendedProfile': { + return ( + ) } default: { - return {commandText} + return ( + + {commandText.commandText} + + ) } } } @@ -91,11 +108,18 @@ function CommandStyledText( } } +const shouldPropagateCenter = ( + propagateCenter: boolean, + isOnDevice?: boolean +): boolean => isOnDevice === true || propagateCenter +const shouldPropagateTextLimit = ( + propagateTextLimit: boolean, + isOnDevice?: boolean +): boolean => isOnDevice === true || propagateTextLimit + type ThermocyclerRunProfileProps = BaseProps & - STProps & { - commandText: string - stepTexts?: string[] - } + STProps & + Omit function ThermocyclerRunProfile( props: ThermocyclerRunProfileProps @@ -109,9 +133,6 @@ function ThermocyclerRunProfile( ...styleProps } = props - const shouldPropagateCenter = isOnDevice === true || propagateCenter - const shouldPropagateTextLimit = isOnDevice === true || propagateTextLimit - // TODO(sfoster): Command sometimes wraps this in a cascaded display: -webkit-box // to achieve multiline text clipping with an automatically inserted ellipsis, which works // everywhere except for here where it overrides this property in the flex since this is @@ -124,7 +145,11 @@ function ThermocyclerRunProfile(
    - {shouldPropagateTextLimit ? ( + {shouldPropagateTextLimit(propagateTextLimit, isOnDevice) ? (
  • - {stepTexts?.[0]} + {stepTexts[0]}
  • ) : ( - stepTexts?.map((step: string, index: number) => ( + stepTexts.map((step: string, index: number) => (
  • ) } + +type ThermocyclerRunExtendedProfileProps = BaseProps & + STProps & + Omit + +function ThermocyclerRunExtendedProfile( + props: ThermocyclerRunExtendedProfileProps +): JSX.Element { + const { + isOnDevice, + propagateCenter = false, + propagateTextLimit = false, + commandText, + profileElementTexts, + ...styleProps + } = props + + // TODO(sfoster): Command sometimes wraps this in a cascaded display: -webkit-box + // to achieve multiline text clipping with an automatically inserted ellipsis, which works + // everywhere except for here where it overrides this property in the flex since this is + // the only place where CommandText uses a flex. + // The right way to handle this is probably to take the css that's in Command and make it + // live here instead, but that should be done in a followup since it would touch everything. + // See also the margin-left on the
  • s, which is needed to prevent their bullets from + // clipping if a container set overflow: hidden. + return ( + + + {commandText} + + +
      + {shouldPropagateTextLimit(propagateTextLimit, isOnDevice) ? ( +
    • + {profileElementTexts[0].kind === 'step' + ? profileElementTexts[0].stepText + : profileElementTexts[0].cycleText} +
    • + ) : ( + profileElementTexts.map((element, index: number) => + element.kind === 'step' ? ( +
    • + {' '} + {element.stepText} +
    • + ) : ( +
    • + {element.cycleText} +
        + {element.stepTexts.map( + ({ stepText }, stepIndex: number) => ( +
      • + {' '} + {stepText} +
      • + ) + )} +
      +
    • + ) + ) + )} +
    +
    +
    + ) +} diff --git a/app/src/molecules/Command/__fixtures__/doItAllV10.json b/app/src/molecules/Command/__fixtures__/doItAllV10.json new file mode 100644 index 00000000000..83030179e79 --- /dev/null +++ b/app/src/molecules/Command/__fixtures__/doItAllV10.json @@ -0,0 +1,4863 @@ +{ + "id": "lasdlakjjflaksjdlkajsldkasd", + "result": "ok", + "status": "completed", + "createdAt": "2024-06-12T17:21:56.919263+00:00", + "files": [ + { "name": "doItAllV8.json", "role": "main" }, + { "name": "cpx_4_tuberack_100ul.json", "role": "labware" } + ], + "config": { "protocolType": "json", "schemaVersion": 8 }, + "metadata": { + "protocolName": "doItAllV10", + "author": "", + "description": "", + "created": 1701659107408, + "lastModified": 1714570438503, + "category": null, + "subcategory": null, + "tags": [] + }, + "robotType": "OT-3 Standard", + "runTimeParameters": [], + "commands": [ + { + "id": "70fbbc6c-d86a-4e66-9361-25de75552da6", + "createdAt": "2024-06-12T17:21:57.915484+00:00", + "commandType": "thermocycler/runProfile", + "key": "5ec88b6a-2c2c-4ffc-961f-c6e0dd300b49", + "status": "succeeded", + "params": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType", + "profile": [ + { "holdSeconds": 1, "celsius": 9 }, + { "holdSeconds": 2, "celsius": 10 }, + { "holdSeconds": 3, "celsius": 11 }, + { "holdSeconds": 4, "celsius": 12 }, + { "holdSeconds": 5, "celsius": 13 }, + { "holdSeconds": 6, "celsius": 14 }, + { "holdSeconds": 7, "celsius": 15 }, + { "holdSeconds": 8, "celsius": 16 }, + { "holdSeconds": 9, "celsius": 17 }, + { "holdSeconds": 10, "celsius": 18 }, + { "holdSeconds": 11, "celsius": 19 }, + { "holdSeconds": 12, "celsius": 20 }, + { "holdSeconds": 1, "celsius": 9 }, + { "holdSeconds": 2, "celsius": 10 }, + { "holdSeconds": 3, "celsius": 11 }, + { "holdSeconds": 4, "celsius": 12 }, + { "holdSeconds": 5, "celsius": 13 }, + { "holdSeconds": 6, "celsius": 14 }, + { "holdSeconds": 7, "celsius": 15 }, + { "holdSeconds": 8, "celsius": 16 }, + { "holdSeconds": 9, "celsius": 17 }, + { "holdSeconds": 10, "celsius": 18 }, + { "holdSeconds": 11, "celsius": 19 }, + { "holdSeconds": 12, "celsius": 20 } + ], + "blockMaxVolumeUl": 10 + }, + "result": {}, + "startedAt": "2024-06-12T17:21:57.915517+00:00", + "completedAt": "2024-06-12T17:21:57.915543+00:00", + "notes": [] + }, + { + "id": "70fbbc6c-d86a-4e66-9361-25de75552da6", + "createdAt": "2024-06-12T17:21:57.915484+00:00", + "commandType": "thermocycler/runExtendedProfile", + "key": "5ec22b6a-2c2c-4bbc-961f-c6e0aa211b49", + "status": "succeeded", + "params": { + "moduleId": "f99da9f1-d63b-414b-929e-c646b23790fd:thermocyclerModuleType", + "profileElements": [ + { + "repetitions": 10, + "steps": [ + { "holdSeconds": 2, "celsius": 10 }, + { "holdSeconds": 3, "celsius": 11 }, + { "holdSeconds": 4, "celsius": 12 } + ] + }, + { "holdSeconds": 1, "celsius": 9 }, + { "holdSeconds": 5, "celsius": 13 }, + { + "repetitions": 20, + "steps": [ + { "holdSeconds": 6, "celsius": 14 }, + { "holdSeconds": 7, "celsius": 15 }, + { "holdSeconds": 8, "celsius": 16 } + ] + }, + { "holdSeconds": 9, "celsius": 17 }, + { + "repetitions": 30, + "steps": [ + { "holdSeconds": 10, "celsius": 18 }, + { "holdSeconds": 11, "celsius": 19 }, + { "holdSeconds": 12, "celsius": 20 } + ] + }, + { "holdSeconds": 1, "celsius": 9 }, + { + "repetitions": 40, + "steps": [ + { "holdSeconds": 2, "celsius": 10 }, + { "holdSeconds": 3, "celsius": 11 }, + { "holdSeconds": 4, "celsius": 12 } + ] + }, + { "holdSeconds": 5, "celsius": 13 }, + { + "repetitions": 50, + "steps": [ + { "holdSeconds": 6, "celsius": 14 }, + { "holdSeconds": 7, "celsius": 15 }, + { "holdSeconds": 8, "celsius": 16 } + ] + }, + { "holdSeconds": 9, "celsius": 17 }, + { + "repetitions": 60, + "steps": [ + { "holdSeconds": 10, "celsius": 18 }, + { "holdSeconds": 11, "celsius": 19 }, + { "holdSeconds": 12, "celsius": 20 } + ] + } + ], + "blockMaxVolumeUl": 10 + }, + "result": {}, + "startedAt": "2024-06-12T17:21:57.915517+00:00", + "completedAt": "2024-06-12T17:21:57.915543+00:00", + "notes": [] + }, + { + "id": "8f0be368-dc25-4f2a-92b9-a734ad622d4b", + "createdAt": "2024-06-12T17:21:56.894269+00:00", + "commandType": "home", + "key": "50c7ae73a4e3f7129874f39dfb514803", + "status": "succeeded", + "params": {}, + "result": {}, + "startedAt": "2024-06-12T17:21:56.894479+00:00", + "completedAt": "2024-06-12T17:21:56.894522+00:00", + "notes": [] + }, + { + "id": "c744767e-f5a4-408d-bc28-d46a2bd17297", + "createdAt": "2024-06-12T17:21:56.894706+00:00", + "commandType": "loadPipette", + "key": "a1b95079-5b17-428d-b40c-a8236a9890c5", + "status": "succeeded", + "params": { + "pipetteName": "p1000_single_flex", + "mount": "left", + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "startedAt": "2024-06-12T17:21:56.894767+00:00", + "completedAt": "2024-06-12T17:21:56.898018+00:00", + "notes": [] + }, + { + "id": "684793b1-7401-4291-ac0f-e95d61e49e07", + "createdAt": "2024-06-12T17:21:56.898252+00:00", + "commandType": "loadModule", + "key": "6f1e3ad3-8f03-4583-8031-be6be2fcd903", + "status": "succeeded", + "params": { + "model": "heaterShakerModuleV1", + "location": { "slotName": "D1" }, + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType", + "definition": { + "otSharedSchema": "module/schemas/2", + "moduleType": "heaterShakerModuleType", + "model": "heaterShakerModuleV1", + "labwareOffset": { "x": -0.125, "y": 1.125, "z": 68.275 }, + "dimensions": { "bareOverallHeight": 82.0, "overLabwareHeight": 0.0 }, + "calibrationPoint": { "x": 12.0, "y": 8.75, "z": 68.275 }, + "displayName": "Heater-Shaker Module GEN1", + "quirks": [], + "slotTransforms": { + "ot2_standard": { + "3": { + "labwareOffset": [ + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + }, + "6": { + "labwareOffset": [ + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + }, + "9": { + "labwareOffset": [ + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + } + }, + "ot2_short_trash": { + "3": { + "labwareOffset": [ + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + }, + "6": { + "labwareOffset": [ + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + }, + "9": { + "labwareOffset": [ + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + } + }, + "ot3_standard": { + "D1": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + }, + "C1": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + }, + "B1": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + }, + "A1": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + }, + "D3": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + }, + "C3": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + }, + "B3": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + }, + "A3": { + "labwareOffset": [ + [1, 0, 0, 0.125], + [0, 1, 0, -1.125], + [0, 0, 1, -49.325], + [0, 0, 0, 1] + ] + } + } + }, + "compatibleWith": [], + "gripperOffsets": { + "default": { + "pickUpOffset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "dropOffset": { "x": 0.0, "y": 0.0, "z": 1.0 } + } + } + }, + "model": "heaterShakerModuleV1", + "serialNumber": "fake-serial-number-0d06c2c1-3ee4-467d-b1cb-31ce8a260ff5" + }, + "startedAt": "2024-06-12T17:21:56.898296+00:00", + "completedAt": "2024-06-12T17:21:56.898586+00:00", + "notes": [] + }, + { + "id": "a72762a6-d894-4a48-91a0-1e0e46ae41e4", + "createdAt": "2024-06-12T17:21:56.898811+00:00", + "commandType": "loadModule", + "key": "4997a543-7788-434f-8eae-1c4aa3a2a805", + "status": "succeeded", + "params": { + "model": "thermocyclerModuleV2", + "location": { "slotName": "B1" }, + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" + }, + "result": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType", + "definition": { + "otSharedSchema": "module/schemas/2", + "moduleType": "thermocyclerModuleType", + "model": "thermocyclerModuleV2", + "labwareOffset": { "x": 0.0, "y": 68.8, "z": 108.96 }, + "dimensions": { + "bareOverallHeight": 108.96, + "overLabwareHeight": 0.0, + "lidHeight": 61.7 + }, + "calibrationPoint": { "x": 14.4, "y": 64.93, "z": 97.8 }, + "displayName": "Thermocycler Module GEN2", + "quirks": [], + "slotTransforms": { + "ot3_standard": { + "B1": { + "labwareOffset": [ + [1, 0, 0, -20.005], + [0, 1, 0, -0.84], + [0, 0, 1, -98], + [0, 0, 0, 1] + ], + "cornerOffsetFromSlot": [ + [1, 0, 0, -20.005], + [0, 1, 0, -0.84], + [0, 0, 1, -98], + [0, 0, 0, 1] + ] + } + } + }, + "compatibleWith": [], + "gripperOffsets": { + "default": { + "pickUpOffset": { "x": 0.0, "y": 0.0, "z": 4.6 }, + "dropOffset": { "x": 0.0, "y": 0.0, "z": 5.6 } + } + } + }, + "model": "thermocyclerModuleV2", + "serialNumber": "fake-serial-number-abe43d5b-1dd7-4fa9-bf8b-b089236d5adb" + }, + "startedAt": "2024-06-12T17:21:56.898850+00:00", + "completedAt": "2024-06-12T17:21:56.899485+00:00", + "notes": [] + }, + { + "id": "e141cd5f-7c92-4c89-aea6-79057400ee5d", + "createdAt": "2024-06-12T17:21:56.899614+00:00", + "commandType": "loadLabware", + "key": "8bfb6d48-4d08-4ea0-8ce7-f8efe90e202c", + "status": "succeeded", + "params": { + "location": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "loadName": "opentrons_96_pcr_adapter", + "namespace": "opentrons", + "version": 1, + "labwareId": "7c4d59fa-0e50-442f-adce-9e4b0c7f0b88:opentrons/opentrons_96_pcr_adapter/1", + "displayName": "Opentrons 96 PCR Heater-Shaker Adapter" + }, + "result": { + "labwareId": "7c4d59fa-0e50-442f-adce-9e4b0c7f0b88:opentrons/opentrons_96_pcr_adapter/1", + "definition": { + "schemaVersion": 2, + "version": 1, + "namespace": "opentrons", + "metadata": { + "displayName": "Opentrons 96 PCR Heater-Shaker Adapter", + "displayCategory": "adapter", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "brand": { "brand": "Opentrons", "brandId": [] }, + "parameters": { + "format": "96Standard", + "quirks": [], + "isTiprack": false, + "loadName": "opentrons_96_pcr_adapter", + "isMagneticModuleCompatible": false + }, + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"], + ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"], + ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + ], + "cornerOffsetFromSlot": { "x": 8.5, "y": 5.5, "z": 0 }, + "dimensions": { + "yDimension": 75, + "zDimension": 13.85, + "xDimension": 111 + }, + "wells": { + "A1": { + "depth": 12, + "x": 6, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B1": { + "depth": 12, + "x": 6, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C1": { + "depth": 12, + "x": 6, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D1": { + "depth": 12, + "x": 6, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E1": { + "depth": 12, + "x": 6, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F1": { + "depth": 12, + "x": 6, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G1": { + "depth": 12, + "x": 6, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H1": { + "depth": 12, + "x": 6, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A2": { + "depth": 12, + "x": 15, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B2": { + "depth": 12, + "x": 15, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C2": { + "depth": 12, + "x": 15, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D2": { + "depth": 12, + "x": 15, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E2": { + "depth": 12, + "x": 15, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F2": { + "depth": 12, + "x": 15, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G2": { + "depth": 12, + "x": 15, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H2": { + "depth": 12, + "x": 15, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A3": { + "depth": 12, + "x": 24, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B3": { + "depth": 12, + "x": 24, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C3": { + "depth": 12, + "x": 24, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D3": { + "depth": 12, + "x": 24, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E3": { + "depth": 12, + "x": 24, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F3": { + "depth": 12, + "x": 24, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G3": { + "depth": 12, + "x": 24, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H3": { + "depth": 12, + "x": 24, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A4": { + "depth": 12, + "x": 33, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B4": { + "depth": 12, + "x": 33, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C4": { + "depth": 12, + "x": 33, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D4": { + "depth": 12, + "x": 33, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E4": { + "depth": 12, + "x": 33, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F4": { + "depth": 12, + "x": 33, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G4": { + "depth": 12, + "x": 33, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H4": { + "depth": 12, + "x": 33, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A5": { + "depth": 12, + "x": 42, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B5": { + "depth": 12, + "x": 42, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C5": { + "depth": 12, + "x": 42, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D5": { + "depth": 12, + "x": 42, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E5": { + "depth": 12, + "x": 42, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F5": { + "depth": 12, + "x": 42, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G5": { + "depth": 12, + "x": 42, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H5": { + "depth": 12, + "x": 42, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A6": { + "depth": 12, + "x": 51, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B6": { + "depth": 12, + "x": 51, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C6": { + "depth": 12, + "x": 51, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D6": { + "depth": 12, + "x": 51, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E6": { + "depth": 12, + "x": 51, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F6": { + "depth": 12, + "x": 51, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G6": { + "depth": 12, + "x": 51, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H6": { + "depth": 12, + "x": 51, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A7": { + "depth": 12, + "x": 60, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B7": { + "depth": 12, + "x": 60, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C7": { + "depth": 12, + "x": 60, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D7": { + "depth": 12, + "x": 60, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E7": { + "depth": 12, + "x": 60, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F7": { + "depth": 12, + "x": 60, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G7": { + "depth": 12, + "x": 60, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H7": { + "depth": 12, + "x": 60, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A8": { + "depth": 12, + "x": 69, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B8": { + "depth": 12, + "x": 69, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C8": { + "depth": 12, + "x": 69, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D8": { + "depth": 12, + "x": 69, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E8": { + "depth": 12, + "x": 69, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F8": { + "depth": 12, + "x": 69, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G8": { + "depth": 12, + "x": 69, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H8": { + "depth": 12, + "x": 69, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A9": { + "depth": 12, + "x": 78, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B9": { + "depth": 12, + "x": 78, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C9": { + "depth": 12, + "x": 78, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D9": { + "depth": 12, + "x": 78, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E9": { + "depth": 12, + "x": 78, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F9": { + "depth": 12, + "x": 78, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G9": { + "depth": 12, + "x": 78, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H9": { + "depth": 12, + "x": 78, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A10": { + "depth": 12, + "x": 87, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B10": { + "depth": 12, + "x": 87, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C10": { + "depth": 12, + "x": 87, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D10": { + "depth": 12, + "x": 87, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E10": { + "depth": 12, + "x": 87, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F10": { + "depth": 12, + "x": 87, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G10": { + "depth": 12, + "x": 87, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H10": { + "depth": 12, + "x": 87, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A11": { + "depth": 12, + "x": 96, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B11": { + "depth": 12, + "x": 96, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C11": { + "depth": 12, + "x": 96, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D11": { + "depth": 12, + "x": 96, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E11": { + "depth": 12, + "x": 96, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F11": { + "depth": 12, + "x": 96, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G11": { + "depth": 12, + "x": 96, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H11": { + "depth": 12, + "x": 96, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "A12": { + "depth": 12, + "x": 105, + "y": 69, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "B12": { + "depth": 12, + "x": 105, + "y": 60, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "C12": { + "depth": 12, + "x": 105, + "y": 51, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "D12": { + "depth": 12, + "x": 105, + "y": 42, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "E12": { + "depth": 12, + "x": 105, + "y": 33, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "F12": { + "depth": 12, + "x": 105, + "y": 24, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "G12": { + "depth": 12, + "x": 105, + "y": 15, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + }, + "H12": { + "depth": 12, + "x": 105, + "y": 6, + "z": 1.85, + "totalLiquidVolume": 0, + "diameter": 5.64, + "shape": "circular" + } + }, + "groups": [ + { + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + "metadata": { "wellBottomShape": "v" } + } + ], + "allowedRoles": ["adapter"], + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "gripperOffsets": { + "default": { + "pickUpOffset": { "x": 0, "y": 0, "z": 0 }, + "dropOffset": { "x": 0, "y": 0, "z": 1 } + } + } + } + }, + "startedAt": "2024-06-12T17:21:56.899718+00:00", + "completedAt": "2024-06-12T17:21:56.899792+00:00", + "notes": [] + }, + { + "id": "edd39a48-10ea-4cf1-848f-9484c136714f", + "createdAt": "2024-06-12T17:21:56.899904+00:00", + "commandType": "loadLabware", + "key": "988395e3-9b85-4bb0-89a4-3afc1d7330fd", + "status": "succeeded", + "params": { + "location": { "slotName": "C2" }, + "loadName": "opentrons_flex_96_tiprack_1000ul", + "namespace": "opentrons", + "version": 1, + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "displayName": "Opentrons Flex 96 Tip Rack 1000 \u00b5L" + }, + "result": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "definition": { + "schemaVersion": 2, + "version": 1, + "namespace": "opentrons", + "metadata": { + "displayName": "Opentrons Flex 96 Tip Rack 1000 \u00b5L", + "displayCategory": "tipRack", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "brand": { "brand": "Opentrons", "brandId": [] }, + "parameters": { + "format": "96Standard", + "quirks": [], + "isTiprack": true, + "tipLength": 95.6, + "tipOverlap": 10.5, + "loadName": "opentrons_flex_96_tiprack_1000ul", + "isMagneticModuleCompatible": false + }, + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"], + ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"], + ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + ], + "cornerOffsetFromSlot": { "x": 0, "y": 0, "z": 0 }, + "dimensions": { + "yDimension": 85.75, + "zDimension": 99, + "xDimension": 127.75 + }, + "wells": { + "A1": { + "depth": 97.5, + "x": 14.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B1": { + "depth": 97.5, + "x": 14.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C1": { + "depth": 97.5, + "x": 14.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D1": { + "depth": 97.5, + "x": 14.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E1": { + "depth": 97.5, + "x": 14.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F1": { + "depth": 97.5, + "x": 14.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G1": { + "depth": 97.5, + "x": 14.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H1": { + "depth": 97.5, + "x": 14.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A2": { + "depth": 97.5, + "x": 23.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B2": { + "depth": 97.5, + "x": 23.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C2": { + "depth": 97.5, + "x": 23.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D2": { + "depth": 97.5, + "x": 23.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E2": { + "depth": 97.5, + "x": 23.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F2": { + "depth": 97.5, + "x": 23.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G2": { + "depth": 97.5, + "x": 23.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H2": { + "depth": 97.5, + "x": 23.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A3": { + "depth": 97.5, + "x": 32.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B3": { + "depth": 97.5, + "x": 32.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C3": { + "depth": 97.5, + "x": 32.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D3": { + "depth": 97.5, + "x": 32.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E3": { + "depth": 97.5, + "x": 32.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F3": { + "depth": 97.5, + "x": 32.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G3": { + "depth": 97.5, + "x": 32.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H3": { + "depth": 97.5, + "x": 32.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A4": { + "depth": 97.5, + "x": 41.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B4": { + "depth": 97.5, + "x": 41.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C4": { + "depth": 97.5, + "x": 41.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D4": { + "depth": 97.5, + "x": 41.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E4": { + "depth": 97.5, + "x": 41.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F4": { + "depth": 97.5, + "x": 41.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G4": { + "depth": 97.5, + "x": 41.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H4": { + "depth": 97.5, + "x": 41.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A5": { + "depth": 97.5, + "x": 50.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B5": { + "depth": 97.5, + "x": 50.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C5": { + "depth": 97.5, + "x": 50.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D5": { + "depth": 97.5, + "x": 50.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E5": { + "depth": 97.5, + "x": 50.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F5": { + "depth": 97.5, + "x": 50.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G5": { + "depth": 97.5, + "x": 50.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H5": { + "depth": 97.5, + "x": 50.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A6": { + "depth": 97.5, + "x": 59.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B6": { + "depth": 97.5, + "x": 59.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C6": { + "depth": 97.5, + "x": 59.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D6": { + "depth": 97.5, + "x": 59.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E6": { + "depth": 97.5, + "x": 59.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F6": { + "depth": 97.5, + "x": 59.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G6": { + "depth": 97.5, + "x": 59.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H6": { + "depth": 97.5, + "x": 59.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A7": { + "depth": 97.5, + "x": 68.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B7": { + "depth": 97.5, + "x": 68.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C7": { + "depth": 97.5, + "x": 68.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D7": { + "depth": 97.5, + "x": 68.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E7": { + "depth": 97.5, + "x": 68.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F7": { + "depth": 97.5, + "x": 68.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G7": { + "depth": 97.5, + "x": 68.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H7": { + "depth": 97.5, + "x": 68.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A8": { + "depth": 97.5, + "x": 77.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B8": { + "depth": 97.5, + "x": 77.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C8": { + "depth": 97.5, + "x": 77.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D8": { + "depth": 97.5, + "x": 77.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E8": { + "depth": 97.5, + "x": 77.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F8": { + "depth": 97.5, + "x": 77.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G8": { + "depth": 97.5, + "x": 77.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H8": { + "depth": 97.5, + "x": 77.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A9": { + "depth": 97.5, + "x": 86.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B9": { + "depth": 97.5, + "x": 86.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C9": { + "depth": 97.5, + "x": 86.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D9": { + "depth": 97.5, + "x": 86.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E9": { + "depth": 97.5, + "x": 86.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F9": { + "depth": 97.5, + "x": 86.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G9": { + "depth": 97.5, + "x": 86.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H9": { + "depth": 97.5, + "x": 86.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A10": { + "depth": 97.5, + "x": 95.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B10": { + "depth": 97.5, + "x": 95.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C10": { + "depth": 97.5, + "x": 95.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D10": { + "depth": 97.5, + "x": 95.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E10": { + "depth": 97.5, + "x": 95.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F10": { + "depth": 97.5, + "x": 95.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G10": { + "depth": 97.5, + "x": 95.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H10": { + "depth": 97.5, + "x": 95.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A11": { + "depth": 97.5, + "x": 104.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B11": { + "depth": 97.5, + "x": 104.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C11": { + "depth": 97.5, + "x": 104.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D11": { + "depth": 97.5, + "x": 104.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E11": { + "depth": 97.5, + "x": 104.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F11": { + "depth": 97.5, + "x": 104.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G11": { + "depth": 97.5, + "x": 104.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H11": { + "depth": 97.5, + "x": 104.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "A12": { + "depth": 97.5, + "x": 113.38, + "y": 74.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "B12": { + "depth": 97.5, + "x": 113.38, + "y": 65.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "C12": { + "depth": 97.5, + "x": 113.38, + "y": 56.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "D12": { + "depth": 97.5, + "x": 113.38, + "y": 47.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "E12": { + "depth": 97.5, + "x": 113.38, + "y": 38.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "F12": { + "depth": 97.5, + "x": 113.38, + "y": 29.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "G12": { + "depth": 97.5, + "x": 113.38, + "y": 20.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + }, + "H12": { + "depth": 97.5, + "x": 113.38, + "y": 11.38, + "z": 1.5, + "totalLiquidVolume": 1000, + "diameter": 5.47, + "shape": "circular" + } + }, + "groups": [ + { + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + "metadata": {} + } + ], + "allowedRoles": [], + "stackingOffsetWithLabware": { + "opentrons_flex_96_tiprack_adapter": { "x": 0, "y": 0, "z": 121 } + }, + "stackingOffsetWithModule": {}, + "gripperOffsets": {}, + "gripHeightFromLabwareBottom": 23.9, + "gripForce": 16.0 + } + }, + "startedAt": "2024-06-12T17:21:56.899933+00:00", + "completedAt": "2024-06-12T17:21:56.900034+00:00", + "notes": [] + }, + { + "id": "5607c182-a90d-466e-96fd-45aafaf45b7b", + "createdAt": "2024-06-12T17:21:56.900159+00:00", + "commandType": "loadLabware", + "key": "0d60425e-5a6f-4205-ac59-d38a080f2e92", + "status": "succeeded", + "params": { + "location": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" + }, + "loadName": "opentrons_96_wellplate_200ul_pcr_full_skirt", + "namespace": "opentrons", + "version": 2, + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "displayName": "Opentrons Tough 96 Well Plate 200 \u00b5L PCR Full Skirt" + }, + "result": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "definition": { + "schemaVersion": 2, + "version": 2, + "namespace": "opentrons", + "metadata": { + "displayName": "Opentrons Tough 96 Well Plate 200 \u00b5L PCR Full Skirt", + "displayCategory": "wellPlate", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "brand": { + "brand": "Opentrons", + "brandId": ["991-00076"], + "links": [ + "https://shop.opentrons.com/tough-0.2-ml-96-well-pcr-plate-full-skirt/" + ] + }, + "parameters": { + "format": "96Standard", + "isTiprack": false, + "loadName": "opentrons_96_wellplate_200ul_pcr_full_skirt", + "isMagneticModuleCompatible": true + }, + "ordering": [ + ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"], + ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"], + ["A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3"], + ["A4", "B4", "C4", "D4", "E4", "F4", "G4", "H4"], + ["A5", "B5", "C5", "D5", "E5", "F5", "G5", "H5"], + ["A6", "B6", "C6", "D6", "E6", "F6", "G6", "H6"], + ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"], + ["A8", "B8", "C8", "D8", "E8", "F8", "G8", "H8"], + ["A9", "B9", "C9", "D9", "E9", "F9", "G9", "H9"], + ["A10", "B10", "C10", "D10", "E10", "F10", "G10", "H10"], + ["A11", "B11", "C11", "D11", "E11", "F11", "G11", "H11"], + ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] + ], + "cornerOffsetFromSlot": { "x": 0, "y": 0, "z": 0 }, + "dimensions": { + "yDimension": 85.48, + "zDimension": 16, + "xDimension": 127.76 + }, + "wells": { + "A1": { + "depth": 14.95, + "x": 14.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B1": { + "depth": 14.95, + "x": 14.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C1": { + "depth": 14.95, + "x": 14.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D1": { + "depth": 14.95, + "x": 14.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E1": { + "depth": 14.95, + "x": 14.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F1": { + "depth": 14.95, + "x": 14.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G1": { + "depth": 14.95, + "x": 14.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H1": { + "depth": 14.95, + "x": 14.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A2": { + "depth": 14.95, + "x": 23.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B2": { + "depth": 14.95, + "x": 23.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C2": { + "depth": 14.95, + "x": 23.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D2": { + "depth": 14.95, + "x": 23.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E2": { + "depth": 14.95, + "x": 23.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F2": { + "depth": 14.95, + "x": 23.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G2": { + "depth": 14.95, + "x": 23.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H2": { + "depth": 14.95, + "x": 23.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A3": { + "depth": 14.95, + "x": 32.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B3": { + "depth": 14.95, + "x": 32.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C3": { + "depth": 14.95, + "x": 32.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D3": { + "depth": 14.95, + "x": 32.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E3": { + "depth": 14.95, + "x": 32.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F3": { + "depth": 14.95, + "x": 32.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G3": { + "depth": 14.95, + "x": 32.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H3": { + "depth": 14.95, + "x": 32.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A4": { + "depth": 14.95, + "x": 41.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B4": { + "depth": 14.95, + "x": 41.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C4": { + "depth": 14.95, + "x": 41.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D4": { + "depth": 14.95, + "x": 41.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E4": { + "depth": 14.95, + "x": 41.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F4": { + "depth": 14.95, + "x": 41.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G4": { + "depth": 14.95, + "x": 41.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H4": { + "depth": 14.95, + "x": 41.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A5": { + "depth": 14.95, + "x": 50.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B5": { + "depth": 14.95, + "x": 50.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C5": { + "depth": 14.95, + "x": 50.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D5": { + "depth": 14.95, + "x": 50.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E5": { + "depth": 14.95, + "x": 50.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F5": { + "depth": 14.95, + "x": 50.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G5": { + "depth": 14.95, + "x": 50.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H5": { + "depth": 14.95, + "x": 50.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A6": { + "depth": 14.95, + "x": 59.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B6": { + "depth": 14.95, + "x": 59.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C6": { + "depth": 14.95, + "x": 59.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D6": { + "depth": 14.95, + "x": 59.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E6": { + "depth": 14.95, + "x": 59.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F6": { + "depth": 14.95, + "x": 59.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G6": { + "depth": 14.95, + "x": 59.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H6": { + "depth": 14.95, + "x": 59.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A7": { + "depth": 14.95, + "x": 68.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B7": { + "depth": 14.95, + "x": 68.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C7": { + "depth": 14.95, + "x": 68.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D7": { + "depth": 14.95, + "x": 68.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E7": { + "depth": 14.95, + "x": 68.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F7": { + "depth": 14.95, + "x": 68.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G7": { + "depth": 14.95, + "x": 68.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H7": { + "depth": 14.95, + "x": 68.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A8": { + "depth": 14.95, + "x": 77.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B8": { + "depth": 14.95, + "x": 77.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C8": { + "depth": 14.95, + "x": 77.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D8": { + "depth": 14.95, + "x": 77.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E8": { + "depth": 14.95, + "x": 77.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F8": { + "depth": 14.95, + "x": 77.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G8": { + "depth": 14.95, + "x": 77.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H8": { + "depth": 14.95, + "x": 77.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A9": { + "depth": 14.95, + "x": 86.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B9": { + "depth": 14.95, + "x": 86.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C9": { + "depth": 14.95, + "x": 86.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D9": { + "depth": 14.95, + "x": 86.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E9": { + "depth": 14.95, + "x": 86.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F9": { + "depth": 14.95, + "x": 86.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G9": { + "depth": 14.95, + "x": 86.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H9": { + "depth": 14.95, + "x": 86.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A10": { + "depth": 14.95, + "x": 95.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B10": { + "depth": 14.95, + "x": 95.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C10": { + "depth": 14.95, + "x": 95.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D10": { + "depth": 14.95, + "x": 95.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E10": { + "depth": 14.95, + "x": 95.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F10": { + "depth": 14.95, + "x": 95.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G10": { + "depth": 14.95, + "x": 95.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H10": { + "depth": 14.95, + "x": 95.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A11": { + "depth": 14.95, + "x": 104.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B11": { + "depth": 14.95, + "x": 104.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C11": { + "depth": 14.95, + "x": 104.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D11": { + "depth": 14.95, + "x": 104.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E11": { + "depth": 14.95, + "x": 104.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F11": { + "depth": 14.95, + "x": 104.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G11": { + "depth": 14.95, + "x": 104.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H11": { + "depth": 14.95, + "x": 104.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "A12": { + "depth": 14.95, + "x": 113.38, + "y": 74.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "B12": { + "depth": 14.95, + "x": 113.38, + "y": 65.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "C12": { + "depth": 14.95, + "x": 113.38, + "y": 56.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "D12": { + "depth": 14.95, + "x": 113.38, + "y": 47.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "E12": { + "depth": 14.95, + "x": 113.38, + "y": 38.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "F12": { + "depth": 14.95, + "x": 113.38, + "y": 29.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "G12": { + "depth": 14.95, + "x": 113.38, + "y": 20.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + }, + "H12": { + "depth": 14.95, + "x": 113.38, + "y": 11.24, + "z": 1.05, + "totalLiquidVolume": 200, + "diameter": 5.5, + "shape": "circular" + } + }, + "groups": [ + { + "wells": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ], + "metadata": { "wellBottomShape": "v" } + } + ], + "allowedRoles": [], + "stackingOffsetWithLabware": { + "opentrons_96_pcr_adapter": { "x": 0, "y": 0, "z": 10.95 }, + "opentrons_96_well_aluminum_block": { "x": 0, "y": 0, "z": 11.91 } + }, + "stackingOffsetWithModule": { + "magneticBlockV1": { "x": 0, "y": 0, "z": 3.54 }, + "thermocyclerModuleV2": { "x": 0, "y": 0, "z": 10.7 } + }, + "gripperOffsets": {}, + "gripHeightFromLabwareBottom": 10.0, + "gripForce": 15.0 + } + }, + "startedAt": "2024-06-12T17:21:56.900184+00:00", + "completedAt": "2024-06-12T17:21:56.900240+00:00", + "notes": [] + }, + { + "id": "8632c181-e545-42a6-8379-9f1feb0dc46f", + "createdAt": "2024-06-12T17:21:56.900318+00:00", + "commandType": "loadLabware", + "key": "eba272e9-3eed-46bb-91aa-d1aee8da58da", + "status": "succeeded", + "params": { + "location": { "addressableAreaName": "A4" }, + "loadName": "axygen_1_reservoir_90ml", + "namespace": "opentrons", + "version": 1, + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "displayName": "Axygen 1 Well Reservoir 90 mL" + }, + "result": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "definition": { + "schemaVersion": 2, + "version": 1, + "namespace": "opentrons", + "metadata": { + "displayName": "Axygen 1 Well Reservoir 90 mL", + "displayCategory": "reservoir", + "displayVolumeUnits": "mL", + "tags": [] + }, + "brand": { + "brand": "Axygen", + "brandId": ["RES-SW1-LP"], + "links": [ + "https://ecatalog.corning.com/life-sciences/b2c/US/en/Genomics-%26-Molecular-Biology/Automation-Consumables/Automation-Reservoirs/Axygen%C2%AE-Reagent-Reservoirs/p/RES-SW1-LP?clear=true" + ] + }, + "parameters": { + "format": "trough", + "quirks": ["centerMultichannelOnWells", "touchTipDisabled"], + "isTiprack": false, + "loadName": "axygen_1_reservoir_90ml", + "isMagneticModuleCompatible": false + }, + "ordering": [["A1"]], + "cornerOffsetFromSlot": { "x": 0, "y": 0, "z": 0 }, + "dimensions": { + "yDimension": 85.47, + "zDimension": 19.05, + "xDimension": 127.76 + }, + "wells": { + "A1": { + "depth": 12.42, + "x": 63.88, + "y": 42.735, + "z": 6.63, + "totalLiquidVolume": 90000, + "xDimension": 106.76, + "yDimension": 70.52, + "shape": "rectangular" + } + }, + "groups": [ + { "wells": ["A1"], "metadata": { "wellBottomShape": "flat" } } + ], + "allowedRoles": [], + "stackingOffsetWithLabware": {}, + "stackingOffsetWithModule": {}, + "gripperOffsets": {} + } + }, + "startedAt": "2024-06-12T17:21:56.900343+00:00", + "completedAt": "2024-06-12T17:21:56.900400+00:00", + "notes": [] + }, + { + "id": "30aea4e1-6750-4933-9937-525cf52352fc", + "createdAt": "2024-06-12T17:21:56.900502+00:00", + "commandType": "loadLiquid", + "key": "45d432f8-581b-4272-9813-e73b9168a0ad", + "status": "succeeded", + "params": { + "liquidId": "1", + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "volumeByWell": { + "A1": 100.0, + "B1": 100.0, + "C1": 100.0, + "D1": 100.0, + "E1": 100.0, + "F1": 100.0, + "G1": 100.0, + "H1": 100.0 + } + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.900534+00:00", + "completedAt": "2024-06-12T17:21:56.900568+00:00", + "notes": [] + }, + { + "id": "efe0a627-243f-4144-9eb4-6653b7592119", + "createdAt": "2024-06-12T17:21:56.900654+00:00", + "commandType": "loadLiquid", + "key": "7ec93f2a-3d22-4d30-b37a-e9f0d41a1847", + "status": "succeeded", + "params": { + "liquidId": "0", + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "volumeByWell": { "A1": 10000.0 } + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.900680+00:00", + "completedAt": "2024-06-12T17:21:56.900703+00:00", + "notes": [] + }, + { + "id": "b6461e22-2c6c-4869-8b12-3818bd259f7f", + "createdAt": "2024-06-12T17:21:56.900769+00:00", + "commandType": "thermocycler/openLid", + "key": "ba1731c6-2906-4987-b948-ea1931ad3e64", + "status": "succeeded", + "params": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.900799+00:00", + "completedAt": "2024-06-12T17:21:56.900827+00:00", + "notes": [] + }, + { + "id": "60097671-da13-4e65-8a39-b56ee46eaeaf", + "createdAt": "2024-06-12T17:21:56.900923+00:00", + "commandType": "moveLabware", + "key": "134cdae8-8ba1-45e4-98d7-cb931358eb01", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "newLocation": { "slotName": "C1" }, + "strategy": "usingGripper" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.900952+00:00", + "completedAt": "2024-06-12T17:21:56.901073+00:00", + "notes": [] + }, + { + "id": "9dcd7b3e-c893-4509-b7d3-29915655a3d6", + "createdAt": "2024-06-12T17:21:56.901207+00:00", + "commandType": "pickUpTip", + "key": "6a5f30cc-8bea-4899-b058-7bf2095efe86", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "A1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 181.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.901247+00:00", + "completedAt": "2024-06-12T17:21:56.901658+00:00", + "notes": [] + }, + { + "id": "14bca710-bce8-48ec-adf2-2667fbb7a84e", + "createdAt": "2024-06-12T17:21:56.901839+00:00", + "commandType": "aspirate", + "key": "71fc15e9-ad19-4c77-a32f-abba4ea5e6f9", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.901881+00:00", + "completedAt": "2024-06-12T17:21:56.902199+00:00", + "notes": [] + }, + { + "id": "d96a443f-7246-4666-81b5-fa1ffa345421", + "createdAt": "2024-06-12T17:21:56.902289+00:00", + "commandType": "dispense", + "key": "a94a08b1-ed23-4c91-a853-27192da2aa70", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 356.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.902318+00:00", + "completedAt": "2024-06-12T17:21:56.902634+00:00", + "notes": [] + }, + { + "id": "6f9b30ba-63ea-4528-9a5b-c03886bc1126", + "createdAt": "2024-06-12T17:21:56.902719+00:00", + "commandType": "moveToAddressableArea", + "key": "9f8c952b-88e2-4a6d-b6a2-e943f9b032e0", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.902754+00:00", + "completedAt": "2024-06-12T17:21:56.903132+00:00", + "notes": [] + }, + { + "id": "d9de157e-fad0-4ec5-950a-c0a96d1406de", + "createdAt": "2024-06-12T17:21:56.903240+00:00", + "commandType": "dropTipInPlace", + "key": "734f7c4e-be2c-4a45-ae26-d81fb6b58729", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.903270+00:00", + "completedAt": "2024-06-12T17:21:56.903297+00:00", + "notes": [] + }, + { + "id": "028f033a-64de-4e23-bb81-2de8a3c7e1e2", + "createdAt": "2024-06-12T17:21:56.903385+00:00", + "commandType": "pickUpTip", + "key": "e3f54bb0-ef58-4e56-ad44-1dc944d2ebd8", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "B1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 172.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.903411+00:00", + "completedAt": "2024-06-12T17:21:56.903695+00:00", + "notes": [] + }, + { + "id": "37b9334c-3e0c-47dd-8d64-f0ae37fa243d", + "createdAt": "2024-06-12T17:21:56.903769+00:00", + "commandType": "aspirate", + "key": "d5dee037-06a2-4f63-a5dd-08f285db802f", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.903798+00:00", + "completedAt": "2024-06-12T17:21:56.904073+00:00", + "notes": [] + }, + { + "id": "e3ccca85-cb2b-4a62-be22-f5d2d14c1d65", + "createdAt": "2024-06-12T17:21:56.904141+00:00", + "commandType": "dispense", + "key": "db77cb48-9d63-4eb9-bac9-82c7137c7940", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "B1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 347.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.904171+00:00", + "completedAt": "2024-06-12T17:21:56.904472+00:00", + "notes": [] + }, + { + "id": "22dd3872-7d25-4673-bbe1-e30b33388525", + "createdAt": "2024-06-12T17:21:56.904542+00:00", + "commandType": "moveToAddressableArea", + "key": "c4a205b9-6a31-4993-a7dd-de84e3c40fab", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.904571+00:00", + "completedAt": "2024-06-12T17:21:56.904811+00:00", + "notes": [] + }, + { + "id": "e034f4a1-ec53-46fb-8f99-a23d97641764", + "createdAt": "2024-06-12T17:21:56.904883+00:00", + "commandType": "dropTipInPlace", + "key": "c1a58bc4-c922-4989-8259-3a011cb6548e", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.904912+00:00", + "completedAt": "2024-06-12T17:21:56.904934+00:00", + "notes": [] + }, + { + "id": "9846e62c-626b-4fdc-a6dd-419841b0df4b", + "createdAt": "2024-06-12T17:21:56.905004+00:00", + "commandType": "pickUpTip", + "key": "4660a8b7-c24f-4cbd-b5f7-0fff091af818", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "C1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 163.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.905029+00:00", + "completedAt": "2024-06-12T17:21:56.905304+00:00", + "notes": [] + }, + { + "id": "cb7b007b-eebc-441b-b4be-8be684988eaf", + "createdAt": "2024-06-12T17:21:56.905374+00:00", + "commandType": "aspirate", + "key": "9ac1cb1d-2876-4816-87bc-bcbeb3d0cc45", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.905402+00:00", + "completedAt": "2024-06-12T17:21:56.905662+00:00", + "notes": [] + }, + { + "id": "9962d0e7-e491-4515-b243-35322e0c263c", + "createdAt": "2024-06-12T17:21:56.905725+00:00", + "commandType": "dispense", + "key": "1e8856de-95c7-483f-bf9a-a8a08dbd51b5", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "C1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 338.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.905754+00:00", + "completedAt": "2024-06-12T17:21:56.906051+00:00", + "notes": [] + }, + { + "id": "9167fce8-9c39-40d2-af5c-635f0b356428", + "createdAt": "2024-06-12T17:21:56.906114+00:00", + "commandType": "moveToAddressableArea", + "key": "df45d90b-b122-4a73-8166-7c36cb4b1739", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.906142+00:00", + "completedAt": "2024-06-12T17:21:56.906377+00:00", + "notes": [] + }, + { + "id": "8bc60fba-40d9-4693-b7e0-0a4f5f1e9137", + "createdAt": "2024-06-12T17:21:56.906448+00:00", + "commandType": "dropTipInPlace", + "key": "893249ff-853b-4294-bd2c-12da0e5cb8af", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.906475+00:00", + "completedAt": "2024-06-12T17:21:56.906496+00:00", + "notes": [] + }, + { + "id": "837a423d-9154-44f1-b872-9637498b8688", + "createdAt": "2024-06-12T17:21:56.906563+00:00", + "commandType": "pickUpTip", + "key": "2e4913f4-1f2e-4039-964b-ca6f8905e551", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "D1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 154.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.906589+00:00", + "completedAt": "2024-06-12T17:21:56.906909+00:00", + "notes": [] + }, + { + "id": "cfe2c27a-9840-443e-80c4-daf21ab4fc81", + "createdAt": "2024-06-12T17:21:56.907016+00:00", + "commandType": "aspirate", + "key": "bd2ac396-b44d-41a8-b050-ff8ab4a25575", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.907054+00:00", + "completedAt": "2024-06-12T17:21:56.907338+00:00", + "notes": [] + }, + { + "id": "561e682c-adfa-4317-8aa0-190d58bca085", + "createdAt": "2024-06-12T17:21:56.907406+00:00", + "commandType": "dispense", + "key": "df68ab20-61c0-4077-bf0e-b1ef2997251a", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "D1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 329.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.907432+00:00", + "completedAt": "2024-06-12T17:21:56.907727+00:00", + "notes": [] + }, + { + "id": "f23327d3-7e6f-44df-9917-73e9afe63ffc", + "createdAt": "2024-06-12T17:21:56.907789+00:00", + "commandType": "moveToAddressableArea", + "key": "4b7f1a58-2bf5-45e8-a312-e165130f208c", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.907816+00:00", + "completedAt": "2024-06-12T17:21:56.908053+00:00", + "notes": [] + }, + { + "id": "1d3981ee-2c87-4a95-b81c-1c7213809f07", + "createdAt": "2024-06-12T17:21:56.908118+00:00", + "commandType": "dropTipInPlace", + "key": "2fc06e3a-f20d-47b9-ac7f-0a062b45beeb", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.908144+00:00", + "completedAt": "2024-06-12T17:21:56.908164+00:00", + "notes": [] + }, + { + "id": "d9bfc9d0-2c6f-490d-900e-cf1c6d5b8a25", + "createdAt": "2024-06-12T17:21:56.908230+00:00", + "commandType": "pickUpTip", + "key": "9b4955da-0d09-40da-83b2-6c398dcf5e6e", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "E1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 145.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.908255+00:00", + "completedAt": "2024-06-12T17:21:56.908508+00:00", + "notes": [] + }, + { + "id": "b94fd309-ceea-4f83-a40a-86be60a5fb75", + "createdAt": "2024-06-12T17:21:56.908576+00:00", + "commandType": "aspirate", + "key": "05a4a082-6381-4107-bb26-0e64351d3263", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.908601+00:00", + "completedAt": "2024-06-12T17:21:56.908860+00:00", + "notes": [] + }, + { + "id": "614b8fb5-7979-4e01-9616-525906ed8b2a", + "createdAt": "2024-06-12T17:21:56.908927+00:00", + "commandType": "dispense", + "key": "a494e205-1cf5-4718-b5f0-43fe74c962bc", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "E1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 320.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.908956+00:00", + "completedAt": "2024-06-12T17:21:56.909249+00:00", + "notes": [] + }, + { + "id": "40abff26-69b1-4910-8d89-1cd97c3eb39a", + "createdAt": "2024-06-12T17:21:56.909312+00:00", + "commandType": "moveToAddressableArea", + "key": "e4cf4c42-d1c3-40e7-9848-3e02e01250a8", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.909338+00:00", + "completedAt": "2024-06-12T17:21:56.909575+00:00", + "notes": [] + }, + { + "id": "7d6c20be-e94a-4e87-a231-9b6a50827add", + "createdAt": "2024-06-12T17:21:56.909639+00:00", + "commandType": "dropTipInPlace", + "key": "397d6c15-97ae-4ab5-a2dc-e0fe75562d17", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.909665+00:00", + "completedAt": "2024-06-12T17:21:56.909684+00:00", + "notes": [] + }, + { + "id": "1e6e1c47-ac38-4233-8e1a-58b4524ae3cc", + "createdAt": "2024-06-12T17:21:56.909751+00:00", + "commandType": "pickUpTip", + "key": "86178307-33f6-4902-9207-51fc704d579c", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "F1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 136.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.909776+00:00", + "completedAt": "2024-06-12T17:21:56.910096+00:00", + "notes": [] + }, + { + "id": "d19101fe-7e76-4d06-a45e-16a6037e7b7b", + "createdAt": "2024-06-12T17:21:56.910198+00:00", + "commandType": "aspirate", + "key": "f2964ad3-9dac-4566-b636-afb59de61116", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.910236+00:00", + "completedAt": "2024-06-12T17:21:56.910619+00:00", + "notes": [] + }, + { + "id": "56cfc2a6-75e0-4e54-998f-a70f1ae513ce", + "createdAt": "2024-06-12T17:21:56.910690+00:00", + "commandType": "dispense", + "key": "68c9104b-3796-4ca1-9bc5-22afec8024d9", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "F1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 311.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.910718+00:00", + "completedAt": "2024-06-12T17:21:56.911011+00:00", + "notes": [] + }, + { + "id": "9c48d4ca-f623-48ef-91d6-3a551e1b80c3", + "createdAt": "2024-06-12T17:21:56.911135+00:00", + "commandType": "moveToAddressableArea", + "key": "9a10a801-1aaa-4238-89a9-c256f09deea0", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.911164+00:00", + "completedAt": "2024-06-12T17:21:56.911590+00:00", + "notes": [] + }, + { + "id": "453fc49c-d8c6-4d7d-b30e-7d0c85e839e3", + "createdAt": "2024-06-12T17:21:56.911701+00:00", + "commandType": "dropTipInPlace", + "key": "73d1b9c9-4c1f-40a2-8932-7f0110da78dc", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.911737+00:00", + "completedAt": "2024-06-12T17:21:56.911762+00:00", + "notes": [] + }, + { + "id": "076b217b-516b-4dcd-9e52-c8b3816612b3", + "createdAt": "2024-06-12T17:21:56.911841+00:00", + "commandType": "pickUpTip", + "key": "5818e249-0b61-4f76-af80-c835a4ad0033", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "G1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 127.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.911869+00:00", + "completedAt": "2024-06-12T17:21:56.912289+00:00", + "notes": [] + }, + { + "id": "76aea1a4-0b4f-4ecc-9833-e52849ec71f5", + "createdAt": "2024-06-12T17:21:56.912368+00:00", + "commandType": "aspirate", + "key": "38df8344-789d-4490-bd8a-cbe9121b2692", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.912396+00:00", + "completedAt": "2024-06-12T17:21:56.912745+00:00", + "notes": [] + }, + { + "id": "4ade0517-70d3-4003-8c19-8a3f0ab36d58", + "createdAt": "2024-06-12T17:21:56.912846+00:00", + "commandType": "dispense", + "key": "13593038-b554-447e-9963-0f3666ccd11a", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "G1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 302.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.912881+00:00", + "completedAt": "2024-06-12T17:21:56.913205+00:00", + "notes": [] + }, + { + "id": "837623e0-a69e-4b04-bba9-2e9668edfc8e", + "createdAt": "2024-06-12T17:21:56.913278+00:00", + "commandType": "moveToAddressableArea", + "key": "361985e0-7e23-4651-b0ed-5277cb5f1bec", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.913306+00:00", + "completedAt": "2024-06-12T17:21:56.913538+00:00", + "notes": [] + }, + { + "id": "375f3fae-d802-437b-92ea-48dcbb0c42b7", + "createdAt": "2024-06-12T17:21:56.913608+00:00", + "commandType": "dropTipInPlace", + "key": "0d1c0aa2-d5f6-45d9-9341-bc623c07f366", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.913640+00:00", + "completedAt": "2024-06-12T17:21:56.913662+00:00", + "notes": [] + }, + { + "id": "43d96227-c3bb-475d-a7f9-f53d28c9e6f6", + "createdAt": "2024-06-12T17:21:56.913730+00:00", + "commandType": "pickUpTip", + "key": "ef384b08-03fd-4ec1-8ea9-f7741ac9050e", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "wellName": "H1", + "wellLocation": { + "origin": "top", + "offset": { "x": 0, "y": 0, "z": 0 } + }, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 178.38, "y": 118.38, "z": 99.0 }, + "tipVolume": 1000.0, + "tipLength": 85.94999999999999, + "tipDiameter": 5.47 + }, + "startedAt": "2024-06-12T17:21:56.913755+00:00", + "completedAt": "2024-06-12T17:21:56.914009+00:00", + "notes": [] + }, + { + "id": "00a7bfbe-fe2e-41b4-ab1c-21f12b39fe5c", + "createdAt": "2024-06-12T17:21:56.914082+00:00", + "commandType": "aspirate", + "key": "29bcc74a-cbba-4d19-9150-889378a34530", + "status": "succeeded", + "params": { + "labwareId": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "wellName": "A1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { "x": 63.88, "y": 149.735, "z": 7.63 }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.914107+00:00", + "completedAt": "2024-06-12T17:21:56.914395+00:00", + "notes": [] + }, + { + "id": "56e93ce7-7974-4471-ac60-a02b05f279da", + "createdAt": "2024-06-12T17:21:56.914488+00:00", + "commandType": "dispense", + "key": "e1f51c21-1522-4538-af60-b97dc37d7b9a", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "wellName": "H1", + "wellLocation": { + "origin": "bottom", + "offset": { "x": 0.0, "y": 0.0, "z": 1.0 } + }, + "flowRate": 716.0, + "volume": 100.0, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" + }, + "result": { + "position": { + "x": -5.624999999999998, + "y": 293.2, + "z": 2.3100000000000014 + }, + "volume": 100.0 + }, + "startedAt": "2024-06-12T17:21:56.914523+00:00", + "completedAt": "2024-06-12T17:21:56.914832+00:00", + "notes": [] + }, + { + "id": "d6c42331-c874-4b92-ad6e-148493aab2f3", + "createdAt": "2024-06-12T17:21:56.914898+00:00", + "commandType": "moveToAddressableArea", + "key": "93516cec-406e-41e8-8c4c-9b2b145509f7", + "status": "succeeded", + "params": { + "forceDirect": false, + "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "addressableAreaName": "1ChannelWasteChute", + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "stayAtHighestPossibleZ": false + }, + "result": { "position": { "x": 392.0, "y": 36.0, "z": 114.5 } }, + "startedAt": "2024-06-12T17:21:56.914924+00:00", + "completedAt": "2024-06-12T17:21:56.915153+00:00", + "notes": [] + }, + { + "id": "694e494a-6df4-4780-9547-e09f902be8bf", + "createdAt": "2024-06-12T17:21:56.915216+00:00", + "commandType": "dropTipInPlace", + "key": "d9a0a1d2-f813-488e-a28a-daae69cbc072", + "status": "succeeded", + "params": { "pipetteId": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.915242+00:00", + "completedAt": "2024-06-12T17:21:56.915269+00:00", + "notes": [] + }, + { + "id": "c28f4796-8853-4438-958f-84a85a120cf1", + "createdAt": "2024-06-12T17:21:56.915340+00:00", + "commandType": "thermocycler/closeLid", + "key": "6c34d1f1-bfeb-46d9-9669-c9b71732b6ab", + "status": "succeeded", + "params": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.915370+00:00", + "completedAt": "2024-06-12T17:21:56.915396+00:00", + "notes": [] + }, + { + "id": "70faac6c-d96a-4d66-9361-25de74162da6", + "createdAt": "2024-06-12T17:21:56.915484+00:00", + "commandType": "thermocycler/setTargetBlockTemperature", + "key": "5ec65b6a-2b1c-4f8c-961f-c6e0ee700b49", + "status": "succeeded", + "params": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType", + "celsius": 40.0 + }, + "result": { "targetBlockTemperature": 40.0 }, + "startedAt": "2024-06-12T17:21:56.915517+00:00", + "completedAt": "2024-06-12T17:21:56.915543+00:00", + "notes": [] + }, + { + "id": "21ccc284-a287-4cdd-9045-cbf74b752723", + "createdAt": "2024-06-12T17:21:56.915625+00:00", + "commandType": "thermocycler/waitForBlockTemperature", + "key": "9f90e933-131f-44eb-ab12-efb152c9cb83", + "status": "succeeded", + "params": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.915651+00:00", + "completedAt": "2024-06-12T17:21:56.915674+00:00", + "notes": [] + }, + { + "id": "fbfe223a-9ca6-43ae-aff5-d784e84877a1", + "createdAt": "2024-06-12T17:21:56.915765+00:00", + "commandType": "waitForDuration", + "key": "f580c50f-08bb-42c4-b4a2-2764ed2fc090", + "status": "succeeded", + "params": { "seconds": 60.0, "message": "" }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.915794+00:00", + "completedAt": "2024-06-12T17:21:56.915816+00:00", + "notes": [] + }, + { + "id": "4b76af6d-3e05-4b56-85de-a49bab41e3c7", + "createdAt": "2024-06-12T17:21:56.915900+00:00", + "commandType": "thermocycler/openLid", + "key": "f739bfc8-f438-4fa2-8d57-dc839ac29f24", + "status": "succeeded", + "params": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.915925+00:00", + "completedAt": "2024-06-12T17:21:56.915947+00:00", + "notes": [] + }, + { + "id": "d678170a-be83-466e-b898-2aa00d963086", + "createdAt": "2024-06-12T17:21:56.916011+00:00", + "commandType": "thermocycler/deactivateBlock", + "key": "4561d98c-b565-48db-a7af-6bcd31520340", + "status": "succeeded", + "params": { + "moduleId": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.916037+00:00", + "completedAt": "2024-06-12T17:21:56.916059+00:00", + "notes": [] + }, + { + "id": "81f0bd93-bae3-449b-904d-027fbe7d4864", + "createdAt": "2024-06-12T17:21:56.916138+00:00", + "commandType": "heaterShaker/deactivateHeater", + "key": "79dd17bf-f86a-4fe9-990a-e4e567798c87", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.916171+00:00", + "completedAt": "2024-06-12T17:21:56.916195+00:00", + "notes": [] + }, + { + "id": "5d194127-7eb6-4ef4-a26e-bbf25ef5af7f", + "createdAt": "2024-06-12T17:21:56.916279+00:00", + "commandType": "heaterShaker/openLabwareLatch", + "key": "995a2630-7a9c-4b70-aef8-ddccb7ce26ce", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": { "pipetteRetracted": true }, + "startedAt": "2024-06-12T17:21:56.916307+00:00", + "completedAt": "2024-06-12T17:21:56.916335+00:00", + "notes": [] + }, + { + "id": "e0a7f92e-affe-481c-8749-93909ddbfb3b", + "createdAt": "2024-06-12T17:21:56.916454+00:00", + "commandType": "moveLabware", + "key": "9d1035a4-617f-4fcc-a7a3-1b7a8c52b4c6", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "newLocation": { + "labwareId": "7c4d59fa-0e50-442f-adce-9e4b0c7f0b88:opentrons/opentrons_96_pcr_adapter/1" + }, + "strategy": "usingGripper" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.916480+00:00", + "completedAt": "2024-06-12T17:21:56.916597+00:00", + "notes": [] + }, + { + "id": "57399cf0-794d-4dfa-a38b-e5d678454f0f", + "createdAt": "2024-06-12T17:21:56.916667+00:00", + "commandType": "heaterShaker/closeLabwareLatch", + "key": "a244eacc-4cbc-48af-b54a-6c08cd534a51", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.916694+00:00", + "completedAt": "2024-06-12T17:21:56.916715+00:00", + "notes": [] + }, + { + "id": "487eee54-9fdc-4dcd-b948-6ff860855887", + "createdAt": "2024-06-12T17:21:56.916808+00:00", + "commandType": "heaterShaker/deactivateHeater", + "key": "a6970f26-4800-4949-8592-d977df547d8b", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.916832+00:00", + "completedAt": "2024-06-12T17:21:56.916851+00:00", + "notes": [] + }, + { + "id": "917f4e1b-d622-4602-9c13-75177b2119b2", + "createdAt": "2024-06-12T17:21:56.916913+00:00", + "commandType": "heaterShaker/setAndWaitForShakeSpeed", + "key": "ef808dac-1e14-47a1-843d-ce4ce63bdfce", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType", + "rpm": 200.0 + }, + "result": { "pipetteRetracted": true }, + "startedAt": "2024-06-12T17:21:56.916939+00:00", + "completedAt": "2024-06-12T17:21:56.916969+00:00", + "notes": [] + }, + { + "id": "0807dfa8-3c0c-4125-964d-985c642afc34", + "createdAt": "2024-06-12T17:21:56.917053+00:00", + "commandType": "waitForDuration", + "key": "5b47f11e-0755-47d2-b844-f1363e28a54e", + "status": "succeeded", + "params": { "seconds": 60.0 }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.917077+00:00", + "completedAt": "2024-06-12T17:21:56.917095+00:00", + "notes": [] + }, + { + "id": "0a40eee2-8902-4914-86f1-af3d961e7dcd", + "createdAt": "2024-06-12T17:21:56.917156+00:00", + "commandType": "heaterShaker/deactivateShaker", + "key": "614ec8d0-8abf-4aa4-b771-23ff2bde2881", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.917187+00:00", + "completedAt": "2024-06-12T17:21:56.917208+00:00", + "notes": [] + }, + { + "id": "ddffeaa1-9f97-4bd3-99ee-5e0d8aee28bc", + "createdAt": "2024-06-12T17:21:56.917290+00:00", + "commandType": "heaterShaker/deactivateHeater", + "key": "dbbe307e-d361-4cb9-afe7-afeab944bfce", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.917314+00:00", + "completedAt": "2024-06-12T17:21:56.917333+00:00", + "notes": [] + }, + { + "id": "176fe5ea-4a4f-4a78-a2b9-160fdd1a7be1", + "createdAt": "2024-06-12T17:21:56.917404+00:00", + "commandType": "heaterShaker/deactivateHeater", + "key": "62f98610-cbff-4acb-ba36-a3fbb9527ba9", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.917427+00:00", + "completedAt": "2024-06-12T17:21:56.917445+00:00", + "notes": [] + }, + { + "id": "26022284-0a3c-48ad-b864-91f7c48ea19c", + "createdAt": "2024-06-12T17:21:56.917509+00:00", + "commandType": "heaterShaker/openLabwareLatch", + "key": "81cfeab1-175f-4501-8732-1ea1bc9b528b", + "status": "succeeded", + "params": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "result": { "pipetteRetracted": true }, + "startedAt": "2024-06-12T17:21:56.917532+00:00", + "completedAt": "2024-06-12T17:21:56.917554+00:00", + "notes": [] + }, + { + "id": "5e8711de-7231-47d1-ab55-541dd21cef48", + "createdAt": "2024-06-12T17:21:56.917627+00:00", + "commandType": "moveLabware", + "key": "279df4d0-2c87-4f01-b016-5c42d5edce96", + "status": "succeeded", + "params": { + "labwareId": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "newLocation": { "addressableAreaName": "B4" }, + "strategy": "usingGripper" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.917651+00:00", + "completedAt": "2024-06-12T17:21:56.917738+00:00", + "notes": [] + }, + { + "id": "abf5283b-dc04-457e-b1ea-4dc848620954", + "createdAt": "2024-06-12T17:21:56.917841+00:00", + "commandType": "moveLabware", + "key": "f88f41dc-ddf9-4242-9ba4-21bd728ca25f", + "status": "succeeded", + "params": { + "labwareId": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "newLocation": { "addressableAreaName": "gripperWasteChute" }, + "strategy": "usingGripper" + }, + "result": {}, + "startedAt": "2024-06-12T17:21:56.917866+00:00", + "completedAt": "2024-06-12T17:21:56.917941+00:00", + "notes": [] + } + ], + "labware": [ + { + "id": "7c4d59fa-0e50-442f-adce-9e4b0c7f0b88:opentrons/opentrons_96_pcr_adapter/1", + "loadName": "opentrons_96_pcr_adapter", + "definitionUri": "opentrons/opentrons_96_pcr_adapter/1", + "location": { + "moduleId": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType" + }, + "displayName": "Opentrons 96 PCR Heater-Shaker Adapter" + }, + { + "id": "f2d371ea-5146-4c89-8200-9c056a7f321a:opentrons/opentrons_flex_96_tiprack_1000ul/1", + "loadName": "opentrons_flex_96_tiprack_1000ul", + "definitionUri": "opentrons/opentrons_flex_96_tiprack_1000ul/1", + "location": "offDeck", + "displayName": "Opentrons Flex 96 Tip Rack 1000 \u00b5L" + }, + { + "id": "54370838-4fca-4a14-b88a-7840e4903649:opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "loadName": "opentrons_96_wellplate_200ul_pcr_full_skirt", + "definitionUri": "opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2", + "location": { "addressableAreaName": "B4" }, + "displayName": "Opentrons Tough 96 Well Plate 200 \u00b5L PCR Full Skirt" + }, + { + "id": "8bacda22-9e05-45e8-bef4-cc04414a204f:opentrons/axygen_1_reservoir_90ml/1", + "loadName": "axygen_1_reservoir_90ml", + "definitionUri": "opentrons/axygen_1_reservoir_90ml/1", + "location": { "slotName": "C1" }, + "displayName": "Axygen 1 Well Reservoir 90 mL" + } + ], + "pipettes": [ + { + "id": "9fcd50d9-92b2-45ac-acf1-e2cf773feffc", + "pipetteName": "p1000_single_flex", + "mount": "left" + } + ], + "modules": [ + { + "id": "23347241-80bb-4a7e-9c91-5d9727a9e483:heaterShakerModuleType", + "model": "heaterShakerModuleV1", + "location": { "slotName": "D1" }, + "serialNumber": "fake-serial-number-0d06c2c1-3ee4-467d-b1cb-31ce8a260ff5" + }, + { + "id": "fd6da9f1-d63b-414b-929e-c646b64790e9:thermocyclerModuleType", + "model": "thermocyclerModuleV2", + "location": { "slotName": "B1" }, + "serialNumber": "fake-serial-number-abe43d5b-1dd7-4fa9-bf8b-b089236d5adb" + } + ], + "liquids": [ + { + "id": "0", + "displayName": "h20", + "description": "", + "displayColor": "#b925ff" + }, + { + "id": "1", + "displayName": "sample", + "description": "", + "displayColor": "#ffd600" + } + ], + "errors": [] +} diff --git a/app/src/molecules/Command/__fixtures__/index.ts b/app/src/molecules/Command/__fixtures__/index.ts index 447a935d3dc..ba988a5197a 100644 --- a/app/src/molecules/Command/__fixtures__/index.ts +++ b/app/src/molecules/Command/__fixtures__/index.ts @@ -1,5 +1,5 @@ import robotSideAnalysis from './mockRobotSideAnalysis.json' -import doItAllAnalysis from './doItAllV8.json' +import doItAllAnalysis from './doItAllV10.json' import qiaseqAnalysis from './analysis_QIAseqFX24xv4_8.json' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' import type { CommandTextData } from '../types' diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index 7425c2e1853..a6614c6b330 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -821,11 +821,9 @@ describe('CommandText', () => { i18nInstance: i18n, } ) - screen.getByText( - 'Thermocycler starting 2 repetitions of cycle composed of the following steps:' - ) - screen.getByText('temperature: 20°C, seconds: 10') - screen.getByText('temperature: 40°C, seconds: 30') + screen.getByText('Running thermocycler profile with 2 steps:') + screen.getByText('Temperature: 20°C, hold time: 0h 00m 10s') + screen.getByText('Temperature: 40°C, hold time: 0h 00m 30s') }) it('renders correct text for thermocycler/runProfile on ODD', () => { const mockProfileSteps = [ @@ -853,12 +851,128 @@ describe('CommandText', () => { i18nInstance: i18n, } ) + screen.getByText('Running thermocycler profile with 2 steps:') + screen.getByText('Temperature: 20°C, hold time: 0h 00m 10s') + expect( + screen.queryByText('Temperature: 40°C, hold time: 0h 00m 30s') + ).not.toBeInTheDocument() + }) + it('renders correct text for thermocycler/runExtendedProfile on Desktop', () => { + const mockProfileSteps = [ + { holdSeconds: 10, celsius: 20 }, + { + repetitions: 10, + steps: [ + { holdSeconds: 15, celsius: 10 }, + { holdSeconds: 12, celsius: 11 }, + ], + }, + { holdSeconds: 30, celsius: 40 }, + { + repetitions: 9, + steps: [ + { holdSeconds: 13000, celsius: 12 }, + { holdSeconds: 14, celsius: 13 }, + ], + }, + ] + renderWithProviders( + , + { + i18nInstance: i18n, + } + ) screen.getByText( - 'Thermocycler starting 2 repetitions of cycle composed of the following steps:' + 'Running thermocycler profile with 4 total steps and cycles:' + ) + screen.getByText('Temperature: 20°C, hold time: 0h 00m 10s') + screen.getByText('10 repetitions of the following steps:') + screen.getByText('Temperature: 10°C, hold time: 0h 00m 15s') + screen.getByText('Temperature: 11°C, hold time: 0h 00m 12s') + screen.getByText('Temperature: 40°C, hold time: 0h 00m 30s') + screen.getByText('9 repetitions of the following steps:') + screen.getByText('Temperature: 12°C, hold time: 3h 36m 40s') + screen.getByText('Temperature: 13°C, hold time: 0h 00m 14s') + }) + it('renders correct text for thermocycler/runExtendedProfile on ODD', () => { + const mockProfileSteps = [ + { holdSeconds: 10, celsius: 20 }, + { + repetitions: 10, + steps: [ + { holdSeconds: 15, celsius: 10 }, + { holdSeconds: 12, celsius: 11 }, + ], + }, + { holdSeconds: 30, celsius: 40 }, + { + repetitions: 9, + steps: [ + { holdSeconds: 13, celsius: 12 }, + { holdSeconds: 14, celsius: 13 }, + ], + }, + ] + renderWithProviders( + , + { + i18nInstance: i18n, + } + ) + screen.getByText( + 'Running thermocycler profile with 4 total steps and cycles:' ) - screen.getByText('temperature: 20°C, seconds: 10') + screen.getByText('Temperature: 20°C, hold time: 0h 00m 10s') + + expect( + screen.queryByText('10 repetitions of the following steps:') + ).not.toBeInTheDocument() + expect( + screen.queryByText('Temperature: 10°C, hold time: 0h 00m 15s') + ).not.toBeInTheDocument() + expect( + screen.queryByText('Temperature: 11°C, hold time: 0h 00m 12s') + ).not.toBeInTheDocument() + expect( + screen.queryByText('Temperature: 40°C, hold time: 0h 00m 30s') + ).not.toBeInTheDocument() + expect( + screen.queryByText('9 repetitions of the following steps:') + ).not.toBeInTheDocument() + expect( + screen.queryByText('Temperature: 12°C, hold time: 0h 00m 13s') + ).not.toBeInTheDocument() expect( - screen.queryByText('temperature: 40°C, seconds: 30') + screen.queryByText('Temperature: 13°C, hold time: 0h 00m 14s') ).not.toBeInTheDocument() }) it('renders correct text for heaterShaker/setAndWaitForShakeSpeed', () => { diff --git a/app/src/molecules/Command/hooks/index.ts b/app/src/molecules/Command/hooks/index.ts index 6b6545c7689..6f0457c174f 100644 --- a/app/src/molecules/Command/hooks/index.ts +++ b/app/src/molecules/Command/hooks/index.ts @@ -4,4 +4,6 @@ export type { UseCommandTextStringParams, GetCommandText, GetCommandTextResult, + GetTCRunExtendedProfileCommandTextResult, + GetTCRunProfileCommandTextResult, } from './useCommandTextString' diff --git a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx index d10a9aa3211..1cf39cc0d1f 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx +++ b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx @@ -5,6 +5,10 @@ import type { TFunction } from 'i18next' import type { RunTimeCommand, RobotType } from '@opentrons/shared-data' import type { CommandTextData } from '../../types' import type { GetDirectTranslationCommandText } from './utils/getDirectTranslationCommandText' +import type { + TCProfileStepText, + TCProfileCycleText, +} from './utils/getTCRunExtendedProfileCommandText' export interface UseCommandTextStringParams { command: RunTimeCommand | null @@ -12,13 +16,32 @@ export interface UseCommandTextStringParams { robotType: RobotType } +export type CommandTextKind = + | 'generic' + | 'thermocycler/runProfile' + | 'thermocycler/runExtendedProfile' + export type GetCommandText = UseCommandTextStringParams & { t: TFunction } -export interface GetCommandTextResult { +export interface GetGenericCommandTextResult { + kind: 'generic' /* The actual command text. Ex "Homing all gantry, pipette, and plunger axes" */ commandText: string +} +export interface GetTCRunProfileCommandTextResult { + kind: 'thermocycler/runProfile' + commandText: string /* The TC run profile steps. */ - stepTexts?: string[] + stepTexts: string[] } +export interface GetTCRunExtendedProfileCommandTextResult { + kind: 'thermocycler/runExtendedProfile' + commandText: string + profileElementTexts: Array +} +export type GetCommandTextResult = + | GetGenericCommandTextResult + | GetTCRunProfileCommandTextResult + | GetTCRunExtendedProfileCommandTextResult // TODO(jh, 07-18-24): Move the testing that covers this from CommandText to a new file, and verify that all commands are // properly tested. @@ -52,6 +75,7 @@ export function useCommandTextString( case 'heaterShaker/deactivateShaker': case 'heaterShaker/waitForTemperature': return { + kind: 'generic', commandText: utils.getDirectTranslationCommandText( fullParams as GetDirectTranslationCommandText ), @@ -67,6 +91,7 @@ export function useCommandTextString( case 'dropTipInPlace': case 'pickUpTip': return { + kind: 'generic', commandText: utils.getPipettingCommandText(fullParams), } @@ -76,12 +101,14 @@ export function useCommandTextString( case 'loadModule': case 'loadLiquid': return { + kind: 'generic', commandText: utils.getLoadCommandText(fullParams), } case 'liquidProbe': case 'tryLiquidProbe': return { + kind: 'generic', commandText: utils.getLiquidProbeCommandText({ ...fullParams, command, @@ -94,6 +121,7 @@ export function useCommandTextString( case 'thermocycler/setTargetLidTemperature': case 'heaterShaker/setTargetTemperature': return { + kind: 'generic', commandText: utils.getTemperatureCommandText({ ...fullParams, command, @@ -103,8 +131,15 @@ export function useCommandTextString( case 'thermocycler/runProfile': return utils.getTCRunProfileCommandText({ ...fullParams, command }) + case 'thermocycler/runExtendedProfile': + return utils.getTCRunExtendedProfileCommandText({ + ...fullParams, + command, + }) + case 'heaterShaker/setAndWaitForShakeSpeed': return { + kind: 'generic', commandText: utils.getHSShakeSpeedCommandText({ ...fullParams, command, @@ -113,11 +148,13 @@ export function useCommandTextString( case 'moveToSlot': return { + kind: 'generic', commandText: utils.getMoveToSlotCommandText({ ...fullParams, command }), } case 'moveRelative': return { + kind: 'generic', commandText: utils.getMoveRelativeCommandText({ ...fullParams, command, @@ -126,6 +163,7 @@ export function useCommandTextString( case 'moveToCoordinates': return { + kind: 'generic', commandText: utils.getMoveToCoordinatesCommandText({ ...fullParams, command, @@ -134,11 +172,13 @@ export function useCommandTextString( case 'moveToWell': return { + kind: 'generic', commandText: utils.getMoveToWellCommandText({ ...fullParams, command }), } case 'moveLabware': return { + kind: 'generic', commandText: utils.getMoveLabwareCommandText({ ...fullParams, command, @@ -147,6 +187,7 @@ export function useCommandTextString( case 'configureForVolume': return { + kind: 'generic', commandText: utils.getConfigureForVolumeCommandText({ ...fullParams, command, @@ -155,6 +196,7 @@ export function useCommandTextString( case 'configureNozzleLayout': return { + kind: 'generic', commandText: utils.getConfigureNozzleLayoutCommandText({ ...fullParams, command, @@ -163,6 +205,7 @@ export function useCommandTextString( case 'prepareToAspirate': return { + kind: 'generic', commandText: utils.getPrepareToAspirateCommandText({ ...fullParams, command, @@ -171,6 +214,7 @@ export function useCommandTextString( case 'moveToAddressableArea': return { + kind: 'generic', commandText: utils.getMoveToAddressableAreaCommandText({ ...fullParams, command, @@ -179,6 +223,7 @@ export function useCommandTextString( case 'moveToAddressableAreaForDropTip': return { + kind: 'generic', commandText: utils.getMoveToAddressableAreaForDropTipCommandText({ ...fullParams, command, @@ -187,6 +232,7 @@ export function useCommandTextString( case 'waitForDuration': return { + kind: 'generic', commandText: utils.getWaitForDurationCommandText({ ...fullParams, command, @@ -196,6 +242,7 @@ export function useCommandTextString( case 'pause': // legacy pause command case 'waitForResume': return { + kind: 'generic', commandText: utils.getWaitForResumeCommandText({ ...fullParams, command, @@ -204,27 +251,31 @@ export function useCommandTextString( case 'delay': return { + kind: 'generic', commandText: utils.getDelayCommandText({ ...fullParams, command }), } case 'comment': return { + kind: 'generic', commandText: utils.getCommentCommandText({ ...fullParams, command }), } case 'custom': return { + kind: 'generic', commandText: utils.getCustomCommandText({ ...fullParams, command }), } case 'setRailLights': return { + kind: 'generic', commandText: utils.getRailLightsCommandText({ ...fullParams, command }), } case undefined: case null: - return { commandText: '' } + return { kind: 'generic', commandText: '' } default: console.warn( @@ -232,6 +283,7 @@ export function useCommandTextString( command ) return { + kind: 'generic', commandText: utils.getUnknownCommandText({ ...fullParams, command }), } } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts new file mode 100644 index 00000000000..4c4acde0b6f --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunExtendedProfileCommandText.ts @@ -0,0 +1,67 @@ +import { formatDurationLabeled } from '/app/transformations/commands' +import type { + TCRunExtendedProfileRunTimeCommand, + TCProfileCycle, + AtomicProfileStep, +} from '@opentrons/shared-data/command' +import type { GetTCRunExtendedProfileCommandTextResult } from '..' +import type { HandlesCommands } from './types' + +export interface TCProfileStepText { + kind: 'step' + stepText: string +} + +export interface TCProfileCycleText { + kind: 'cycle' + cycleText: string + stepTexts: TCProfileStepText[] +} + +export function getTCRunExtendedProfileCommandText({ + command, + t, +}: HandlesCommands): GetTCRunExtendedProfileCommandTextResult { + const { profileElements } = command.params + + const stepText = ({ + celsius, + holdSeconds, + }: AtomicProfileStep): TCProfileStepText => ({ + kind: 'step', + stepText: t('tc_run_profile_steps', { + celsius, + duration: formatDurationLabeled({ seconds: holdSeconds }), + }).trim(), + }) + + const stepTexts = (cycle: AtomicProfileStep[]): TCProfileStepText[] => + cycle.map(stepText) + + const startingCycleText = (cycle: TCProfileCycle): string => + t('tc_starting_extended_profile_cycle', { + repetitions: cycle.repetitions, + }) + + const cycleText = (cycle: TCProfileCycle): TCProfileCycleText => ({ + kind: 'cycle', + cycleText: startingCycleText(cycle), + stepTexts: stepTexts(cycle.steps), + }) + const profileElementTexts = ( + profile: Array + ): Array => + profile.map(element => + Object.hasOwn(element, 'repetitions') + ? cycleText(element as TCProfileCycle) + : stepText(element as AtomicProfileStep) + ) + + return { + kind: 'thermocycler/runExtendedProfile', + commandText: t('tc_starting_extended_profile', { + elementCount: profileElements.length, + }), + profileElementTexts: profileElementTexts(profileElements), + } +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts index 2d279fca850..cbc56b02635 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getTCRunProfileCommandText.ts @@ -1,24 +1,29 @@ +import { formatDurationLabeled } from '/app/transformations/commands' import type { TCRunProfileRunTimeCommand } from '@opentrons/shared-data/command' -import type { GetCommandTextResult } from '..' +import type { GetTCRunProfileCommandTextResult } from '..' import type { HandlesCommands } from './types' export function getTCRunProfileCommandText({ command, t, -}: HandlesCommands): GetCommandTextResult { +}: HandlesCommands): GetTCRunProfileCommandTextResult { const { profile } = command.params const stepTexts = profile.map( ({ holdSeconds, celsius }: { holdSeconds: number; celsius: number }) => t('tc_run_profile_steps', { celsius, - seconds: holdSeconds, + duration: formatDurationLabeled({ seconds: holdSeconds }), }).trim() ) const startingProfileText = t('tc_starting_profile', { - repetitions: Object.keys(stepTexts).length, + stepCount: Object.keys(stepTexts).length, }) - return { commandText: startingProfileText, stepTexts } + return { + kind: 'thermocycler/runProfile', + commandText: startingProfileText, + stepTexts, + } } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts index ff3ad43fc8c..590824e558d 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts @@ -1,6 +1,7 @@ export { getLoadCommandText } from './getLoadCommandText' export { getTemperatureCommandText } from './getTemperatureCommandText' export { getTCRunProfileCommandText } from './getTCRunProfileCommandText' +export { getTCRunExtendedProfileCommandText } from './getTCRunExtendedProfileCommandText' export { getHSShakeSpeedCommandText } from './getHSShakeSpeedCommandText' export { getMoveToSlotCommandText } from './getMoveToSlotCommandText' export { getMoveRelativeCommandText } from './getMoveRelativeCommandText' diff --git a/app/src/molecules/InterventionModal/CategorizedStepContent.stories.tsx b/app/src/molecules/InterventionModal/CategorizedStepContent.stories.tsx index 04c04c0f3c6..79d83fd57ef 100644 --- a/app/src/molecules/InterventionModal/CategorizedStepContent.stories.tsx +++ b/app/src/molecules/InterventionModal/CategorizedStepContent.stories.tsx @@ -14,7 +14,7 @@ import type { Meta, StoryObj } from '@storybook/react' type CommandType = RunTimeCommand['commandType'] const availableCommandTypes = uniq( - Fixtures.mockQIASeqTextData.commands.map(command => command.commandType) + Fixtures.mockDoItAllTextData.commands.map(command => command.commandType) ) const commandsByType: Partial> = {} @@ -22,7 +22,7 @@ function commandsOfType(type: CommandType): RunTimeCommand[] { if (type in commandsByType) { return commandsByType[type] } - commandsByType[type] = Fixtures.mockQIASeqTextData.commands.filter( + commandsByType[type] = Fixtures.mockDoItAllTextData.commands.filter( command => command.commandType === type ) return commandsByType[type] @@ -62,22 +62,22 @@ function Wrapper(props: WrapperProps): JSX.Element { const topCommandIndex = topCommand == null ? undefined - : Fixtures.mockQIASeqTextData.commands.indexOf(topCommand) + : Fixtures.mockDoItAllTextData.commands.indexOf(topCommand) const bottomCommand1Index = bottomCommand1 == null ? undefined - : Fixtures.mockQIASeqTextData.commands.indexOf(bottomCommand1) + : Fixtures.mockDoItAllTextData.commands.indexOf(bottomCommand1) const bottomCommand2Index = bottomCommand2 == null ? undefined - : Fixtures.mockQIASeqTextData.commands.indexOf(bottomCommand2) + : Fixtures.mockDoItAllTextData.commands.indexOf(bottomCommand2) return ( { mockMakeToast = vi.fn() vi.mocked(useToaster).mockReturnValue({ makeToast: mockMakeToast } as any) vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'generic', commandText: TEST_COMMAND, }) }) @@ -69,6 +71,7 @@ describe('useRecoveryToasts', () => { it('should make toast with correct parameters for desktop', () => { vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'generic', commandText: TEST_COMMAND, }) @@ -80,8 +83,8 @@ describe('useRecoveryToasts', () => { ) vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'generic', commandText: TEST_COMMAND, - stepTexts: undefined, }) result.current.makeSuccessToast() @@ -120,8 +123,8 @@ describe('useRecoveryToasts', () => { it('should use recoveryToastText when desktopFullCommandText is null', () => { vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'generic', commandText: '', - stepTexts: undefined, }) const { result } = renderHook(() => @@ -196,8 +199,8 @@ describe('getStepNumber', () => { describe('useRecoveryFullCommandText', () => { it('should return the correct command text', () => { vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'generic', commandText: TEST_COMMAND, - stepTexts: undefined, }) const { result } = renderHook(() => @@ -213,8 +216,8 @@ describe('useRecoveryFullCommandText', () => { it('should return null when relevantCmd is null', () => { vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'generic', commandText: '', - stepTexts: undefined, }) const { result } = renderHook(() => @@ -242,6 +245,7 @@ describe('useRecoveryFullCommandText', () => { it('should truncate TC command', () => { vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'thermocycler/runProfile', commandText: TC_COMMAND, stepTexts: ['step'], }) @@ -255,7 +259,25 @@ describe('useRecoveryFullCommandText', () => { } as any, }) ) + expect(result.current).toBe('tc starting profile of 1231231 element steps') + }) - expect(result.current).toBe('tc command cycle') + it('should truncate new TC command', () => { + vi.mocked(useCommandTextString).mockReturnValue({ + kind: 'thermocycler/runExtendedProfile', + commandText: TC_COMMAND, + profileElementTexts: [{ kind: 'step', stepText: 'blah blah blah' }], + }) + + const { result } = renderHook(() => + useRecoveryFullCommandText({ + robotType: FLEX_ROBOT_TYPE, + stepNumber: 0, + commandTextData: { + commands: [TC_COMMAND], + } as any, + }) + ) + expect(result.current).toBe('tc starting profile of 1231231 element steps') }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts index ff530c5fdb0..ed5aaaeaae5 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts @@ -105,7 +105,7 @@ export function useRecoveryFullCommandText( const relevantCmdIdx = typeof stepNumber === 'number' ? stepNumber : -1 const relevantCmd = commandTextData?.commands[relevantCmdIdx] ?? null - const { commandText, stepTexts } = useCommandTextString({ + const { commandText, kind } = useCommandTextString({ ...props, command: relevantCmd, }) @@ -117,7 +117,12 @@ export function useRecoveryFullCommandText( else if (relevantCmd === null) { return null } else { - return truncateIfTCCommand(commandText, stepTexts != null) + return truncateIfTCCommand( + commandText, + ['thermocycler/runProfile', 'thermocycler/runExtendedProfile'].includes( + kind + ) + ) } } @@ -164,17 +169,20 @@ function handleRecoveryOptionAction( } // Special case the TC text, so it make sense in a success toast. -function truncateIfTCCommand(commandText: string, isTCText: boolean): string { - if (isTCText) { - const indexOfCycle = commandText.indexOf('cycle') - - if (indexOfCycle === -1) { +function truncateIfTCCommand( + commandText: string, + isTCCommand: boolean +): string { + if (isTCCommand) { + const indexOfProfile = commandText.indexOf('steps') + + if (indexOfProfile === -1) { console.warn( 'TC cycle text has changed. Update Error Recovery TC text utility.' ) } - return commandText.slice(0, indexOfCycle + 5) // +5 to include "cycle" + return commandText.slice(0, indexOfProfile + 5) // +5 to include "steps" } else { return commandText } diff --git a/app/src/transformations/commands/transformations/__tests__/formatDuration.test.tsx b/app/src/transformations/commands/transformations/__tests__/formatDuration.test.tsx index 21ffb0f565b..fa6c3a954d2 100644 --- a/app/src/transformations/commands/transformations/__tests__/formatDuration.test.tsx +++ b/app/src/transformations/commands/transformations/__tests__/formatDuration.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { formatDuration } from '../formatDuration' +import { formatDuration, formatDurationLabeled } from '../formatDuration' describe('formatDuration', () => { it('should format a duration', () => { @@ -36,4 +36,57 @@ describe('formatDuration', () => { expect(formatDuration(duration)).toEqual(expected) }) + + it('should format a non-normalized duration', () => { + const duration = { + seconds: 360002, + } + const expected = '100:00:02' + expect(formatDuration(duration)).toEqual(expected) + }) +}) + +describe('formatDurationLabeled', () => { + it('should format a duration', () => { + const duration = { + hours: 2, + minutes: 40, + seconds: 2, + } + + const expected = '2h 40m 02s' + + expect(formatDurationLabeled(duration)).toEqual(expected) + }) + + it('should format a short duration with plenty of zeroes', () => { + const duration = { + seconds: 2, + } + + const expected = '0h 00m 02s' + + expect(formatDurationLabeled(duration)).toEqual(expected) + }) + + it('should format a longer duration', () => { + const duration = { + days: 3, + hours: 2, + minutes: 40, + seconds: 2, + } + + const expected = '74h 40m 02s' + + expect(formatDurationLabeled(duration)).toEqual(expected) + }) + + it('should format a non-normalized duration', () => { + const duration = { + seconds: 360002, + } + const expected = '100h 00m 02s' + expect(formatDurationLabeled(duration)).toEqual(expected) + }) }) diff --git a/app/src/transformations/commands/transformations/formatDuration.ts b/app/src/transformations/commands/transformations/formatDuration.ts index f744d9dba5d..1bef1591874 100644 --- a/app/src/transformations/commands/transformations/formatDuration.ts +++ b/app/src/transformations/commands/transformations/formatDuration.ts @@ -6,14 +6,39 @@ import type { Duration } from 'date-fns' * @returns string in format hh:mm:ss, e.g. 03:15:45 */ export function formatDuration(duration: Duration): string { - const { days, hours, minutes, seconds } = duration + const { hours, minutes, seconds } = timestampDetails(duration) - // edge case: protocol runs (or is paused) for over 24 hours - const hoursWithDays = days != null ? days * 24 + (hours ?? 0) : hours + return `${hours}:${minutes}:${seconds}` +} + +export function formatDurationLabeled(duration: Duration): string { + const { hours, minutes, seconds } = timestampDetails(duration, 1) + + return `${hours}h ${minutes}m ${seconds}s` +} + +function timestampDetails( + duration: Duration, + padHoursTo?: number +): { hours: string; minutes: string; seconds: string } { + const paddingWithDefault = padHoursTo ?? 2 + const days = duration?.days ?? 0 + const hours = duration?.hours ?? 0 + const minutes = duration?.minutes ?? 0 + const seconds = duration?.seconds ?? 0 + + const totalSeconds = seconds + minutes * 60 + hours * 3600 + days * 24 * 3600 - const paddedHours = padStart(hoursWithDays?.toString(), 2, '0') - const paddedMinutes = padStart(minutes?.toString(), 2, '0') - const paddedSeconds = padStart(seconds?.toString(), 2, '0') + const normalizedHours = Math.floor(totalSeconds / 3600) + const normalizedMinutes = Math.floor((totalSeconds % 3600) / 60) + const normalizedSeconds = totalSeconds % 60 - return `${paddedHours}:${paddedMinutes}:${paddedSeconds}` + const paddedHours = padStart( + normalizedHours.toString(), + paddingWithDefault, + '0' + ) + const paddedMinutes = padStart(normalizedMinutes.toString(), 2, '0') + const paddedSeconds = padStart(normalizedSeconds.toString(), 2, '0') + return { hours: paddedHours, minutes: paddedMinutes, seconds: paddedSeconds } } diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index 07afff241a1..6eb524b9a45 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -63,6 +63,7 @@ "thermocycler/openLid": "#/definitions/opentrons__protocol_engine__commands__thermocycler__open_lid__OpenLidCreate", "thermocycler/closeLid": "#/definitions/opentrons__protocol_engine__commands__thermocycler__close_lid__CloseLidCreate", "thermocycler/runProfile": "#/definitions/RunProfileCreate", + "thermocycler/runExtendedProfile": "#/definitions/RunExtendedProfileCreate", "absorbanceReader/closeLid": "#/definitions/opentrons__protocol_engine__commands__absorbance_reader__close_lid__CloseLidCreate", "absorbanceReader/openLid": "#/definitions/opentrons__protocol_engine__commands__absorbance_reader__open_lid__OpenLidCreate", "absorbanceReader/initialize": "#/definitions/InitializeCreate", @@ -253,6 +254,9 @@ { "$ref": "#/definitions/RunProfileCreate" }, + { + "$ref": "#/definitions/RunExtendedProfileCreate" + }, { "$ref": "#/definitions/opentrons__protocol_engine__commands__absorbance_reader__close_lid__CloseLidCreate" }, @@ -3911,6 +3915,108 @@ }, "required": ["params"] }, + "ProfileStep": { + "title": "ProfileStep", + "description": "An individual step in a Thermocycler extended profile.", + "type": "object", + "properties": { + "celsius": { + "title": "Celsius", + "description": "Target temperature in \u00b0C.", + "type": "number" + }, + "holdSeconds": { + "title": "Holdseconds", + "description": "Time to hold target temperature in seconds.", + "type": "number" + } + }, + "required": ["celsius", "holdSeconds"] + }, + "ProfileCycle": { + "title": "ProfileCycle", + "description": "An individual cycle in a Thermocycler extended profile.", + "type": "object", + "properties": { + "steps": { + "title": "Steps", + "description": "Steps to repeat.", + "type": "array", + "items": { + "$ref": "#/definitions/ProfileStep" + } + }, + "repetitions": { + "title": "Repetitions", + "description": "Number of times to repeat the steps.", + "type": "integer" + } + }, + "required": ["steps", "repetitions"] + }, + "RunExtendedProfileParams": { + "title": "RunExtendedProfileParams", + "description": "Input parameters for an individual Thermocycler profile step.", + "type": "object", + "properties": { + "moduleId": { + "title": "Moduleid", + "description": "Unique ID of the Thermocycler.", + "type": "string" + }, + "profileElements": { + "title": "Profileelements", + "description": "Elements of the profile. Each can be either a step or a cycle.", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/ProfileStep" + }, + { + "$ref": "#/definitions/ProfileCycle" + } + ] + } + }, + "blockMaxVolumeUl": { + "title": "Blockmaxvolumeul", + "description": "Amount of liquid in uL of the most-full well in labware loaded onto the thermocycler.", + "type": "number" + } + }, + "required": ["moduleId", "profileElements"] + }, + "RunExtendedProfileCreate": { + "title": "RunExtendedProfileCreate", + "description": "A request to execute a Thermocycler profile run.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "thermocycler/runExtendedProfile", + "enum": ["thermocycler/runExtendedProfile"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/RunExtendedProfileParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] + }, "opentrons__protocol_engine__commands__absorbance_reader__close_lid__CloseLidParams": { "title": "CloseLidParams", "description": "Input parameters to close the lid on an absorbance reading.", diff --git a/shared-data/command/types/module.ts b/shared-data/command/types/module.ts index ff2787bfa25..b0df1ae6b6b 100644 --- a/shared-data/command/types/module.ts +++ b/shared-data/command/types/module.ts @@ -15,6 +15,7 @@ export type ModuleRunTimeCommand = | TCDeactivateBlockRunTimeCommand | TCDeactivateLidRunTimeCommand | TCRunProfileRunTimeCommand + | TCRunExtendedProfileRunTimeCommand | TCAwaitProfileCompleteRunTimeCommand | HeaterShakerSetTargetTemperatureRunTimeCommand | HeaterShakerWaitForTemperatureRunTimeCommand @@ -39,6 +40,7 @@ export type ModuleCreateCommand = | TCDeactivateBlockCreateCommand | TCDeactivateLidCreateCommand | TCRunProfileCreateCommand + | TCRunExtendedProfileCreateCommand | TCAwaitProfileCompleteCreateCommand | HeaterShakerWaitForTemperatureCreateCommand | HeaterShakerSetAndWaitForShakeSpeedCreateCommand @@ -189,6 +191,16 @@ export interface TCRunProfileRunTimeCommand TCRunProfileCreateCommand { result?: any } +export interface TCRunExtendedProfileCreateCommand + extends CommonCommandCreateInfo { + commandType: 'thermocycler/runExtendedProfile' + params: TCExtendedProfileParams +} +export interface TCRunExtendedProfileRunTimeCommand + extends CommonCommandRunTimeInfo, + TCRunExtendedProfileCreateCommand { + result?: any +} export interface TCAwaitProfileCompleteCreateCommand extends CommonCommandCreateInfo { commandType: 'thermocycler/awaitProfileComplete' @@ -305,3 +317,14 @@ export interface ThermocyclerSetTargetBlockTemperatureParams { volume?: number holdTimeSeconds?: number } + +export interface TCProfileCycle { + steps: AtomicProfileStep[] + repetitions: number +} + +export interface TCExtendedProfileParams { + moduleId: string + profileElements: Array + blockMaxVolumeUl?: number +} From f8608fd3688bfd2906876e07418093e89a92abc9 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Tue, 8 Oct 2024 09:54:57 -0400 Subject: [PATCH 014/101] feat(api-client): Add bindings for `/errorRecovery/settings` endpoints (#16427) --- api-client/src/errorRecovery/index.ts | 1 + .../settings/getErrorRecoverySettings.ts | 16 +++++++++++++++ .../src/errorRecovery/settings/index.ts | 3 +++ .../src/errorRecovery/settings/types.ts | 9 +++++++++ .../settings/updateErrorRecoverySettings.ts | 20 +++++++++++++++++++ api-client/src/index.ts | 1 + 6 files changed, 50 insertions(+) create mode 100644 api-client/src/errorRecovery/index.ts create mode 100644 api-client/src/errorRecovery/settings/getErrorRecoverySettings.ts create mode 100644 api-client/src/errorRecovery/settings/index.ts create mode 100644 api-client/src/errorRecovery/settings/types.ts create mode 100644 api-client/src/errorRecovery/settings/updateErrorRecoverySettings.ts diff --git a/api-client/src/errorRecovery/index.ts b/api-client/src/errorRecovery/index.ts new file mode 100644 index 00000000000..eca32dd0aef --- /dev/null +++ b/api-client/src/errorRecovery/index.ts @@ -0,0 +1 @@ +export * from './settings' diff --git a/api-client/src/errorRecovery/settings/getErrorRecoverySettings.ts b/api-client/src/errorRecovery/settings/getErrorRecoverySettings.ts new file mode 100644 index 00000000000..30a3b9f6aa1 --- /dev/null +++ b/api-client/src/errorRecovery/settings/getErrorRecoverySettings.ts @@ -0,0 +1,16 @@ +import { GET, request } from '../../request' + +import type { ResponsePromise } from '../../request' +import type { HostConfig } from '../../types' +import type { ErrorRecoverySettingsResponse } from './types' + +export function getErrorRecoverySettings( + config: HostConfig +): ResponsePromise { + return request( + GET, + '/errorRecovery/settings', + null, + config + ) +} diff --git a/api-client/src/errorRecovery/settings/index.ts b/api-client/src/errorRecovery/settings/index.ts new file mode 100644 index 00000000000..7f33c4c0069 --- /dev/null +++ b/api-client/src/errorRecovery/settings/index.ts @@ -0,0 +1,3 @@ +export { getErrorRecoverySettings } from './getErrorRecoverySettings' +export { updateErrorRecoverySettings } from './updateErrorRecoverySettings' +export * from './types' diff --git a/api-client/src/errorRecovery/settings/types.ts b/api-client/src/errorRecovery/settings/types.ts new file mode 100644 index 00000000000..d427ab88714 --- /dev/null +++ b/api-client/src/errorRecovery/settings/types.ts @@ -0,0 +1,9 @@ +export interface ErrorRecoverySettingsResponse { + data: { + enabled: boolean + } +} + +export interface ErrorRecoverySettingsRequest { + data: Partial +} diff --git a/api-client/src/errorRecovery/settings/updateErrorRecoverySettings.ts b/api-client/src/errorRecovery/settings/updateErrorRecoverySettings.ts new file mode 100644 index 00000000000..e5d1acf29aa --- /dev/null +++ b/api-client/src/errorRecovery/settings/updateErrorRecoverySettings.ts @@ -0,0 +1,20 @@ +import { PATCH, request } from '../../request' + +import type { ResponsePromise } from '../../request' +import type { HostConfig } from '../../types' +import type { + ErrorRecoverySettingsRequest, + ErrorRecoverySettingsResponse, +} from './types' + +export function updateErrorRecoverySettings( + config: HostConfig, + settings: ErrorRecoverySettingsRequest +): ResponsePromise { + return request( + PATCH, + '/errorRecovery/settings', + settings, + config + ) +} diff --git a/api-client/src/index.ts b/api-client/src/index.ts index 858772034ab..ade46aeee7f 100644 --- a/api-client/src/index.ts +++ b/api-client/src/index.ts @@ -3,6 +3,7 @@ export * from './calibration' export * from './client_data' export * from './dataFiles' export * from './deck_configuration' +export * from './errorRecovery' export * from './health' export * from './instruments' export * from './maintenance_runs' From 44aa75786c0b3e7e44e75c3f8c12925c234dd902 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Tue, 8 Oct 2024 09:23:39 -0500 Subject: [PATCH 015/101] chore(abt): upgrade python to 3.13 and upgrade dependencies on analyses battery (#16430) # Overview Upgrade python and dependencies on the analysis battery test. ## Checks - [x] Works locally - [x] CI lint works - [x] CI test works --- .github/workflows/analyses-snapshot-lint.yaml | 2 +- .github/workflows/analyses-snapshot-test.yaml | 12 +- analyses-snapshot-testing/Pipfile | 24 +- analyses-snapshot-testing/Pipfile.lock | 657 +++++++++--------- analyses-snapshot-testing/pyproject.toml | 4 +- 5 files changed, 330 insertions(+), 369 deletions(-) diff --git a/.github/workflows/analyses-snapshot-lint.yaml b/.github/workflows/analyses-snapshot-lint.yaml index 90724f3c7a2..17e13e30868 100644 --- a/.github/workflows/analyses-snapshot-lint.yaml +++ b/.github/workflows/analyses-snapshot-lint.yaml @@ -27,7 +27,7 @@ jobs: - name: Setup Python uses: 'actions/setup-python@v5' with: - python-version: '3.12' + python-version: '3.13.0-rc.3' cache: 'pipenv' cache-dependency-path: analyses-snapshot-testing/Pipfile.lock - name: Setup diff --git a/.github/workflows/analyses-snapshot-test.yaml b/.github/workflows/analyses-snapshot-test.yaml index 26576899188..7770db0d286 100644 --- a/.github/workflows/analyses-snapshot-test.yaml +++ b/.github/workflows/analyses-snapshot-test.yaml @@ -48,8 +48,8 @@ jobs: ANALYSIS_REF: ${{ github.event.inputs.ANALYSIS_REF || github.head_ref || 'edge' }} SNAPSHOT_REF: ${{ github.event.inputs.SNAPSHOT_REF || github.head_ref || 'edge' }} # If we're running because of workflow_dispatch, use the user input to decide - # whether to open a PR on failure. Otherwise, there is no user input, so always - # open a PR on failure. + # whether to open a PR on failure. Otherwise, there is no user input, + # so we only open a PR if the PR has the label 'gen-analyses-snapshot-pr' OPEN_PR_ON_FAILURE: ${{ (github.event_name == 'workflow_dispatch' && github.events.inputs.OPEN_PR_ON_FAILURE) || ((github.event_name != 'workflow_dispatch') && (contains(github.event.pull_request.labels.*.name, 'gen-analyses-snapshot-pr'))) }} PR_TARGET_BRANCH: ${{ github.event.pull_request.base.ref || 'not a pr'}} steps: @@ -75,10 +75,10 @@ jobs: working-directory: analyses-snapshot-testing run: make build-opentrons-analysis - - name: Set up Python 3.12 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13.0-rc.3' cache: 'pipenv' cache-dependency-path: analyses-snapshot-testing/Pipfile.lock @@ -107,7 +107,7 @@ jobs: - name: Create Snapshot update Request id: create_pull_request if: always() && steps.handle_failure.outcome == 'success' && env.OPEN_PR_ON_FAILURE == 'true' && github.event_name == 'pull_request' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: commit-message: 'fix(analyses-snapshot-testing): heal analyses snapshots' title: 'fix(analyses-snapshot-testing): heal ${{ env.ANALYSIS_REF }} snapshots' @@ -130,7 +130,7 @@ jobs: - name: Create Snapshot update Request on edge overnight failure if: always() && steps.handle_failure.outcome == 'success' && github.event_name == 'schedule' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: # scheduled run uses the default values for ANALYSIS_REF and SNAPSHOT_REF which are edge commit-message: 'fix(analyses-snapshot-testing): heal ${{ env.ANALYSIS_REF }} snapshots' title: 'fix(analyses-snapshot-testing): heal ${{ env.ANALYSIS_REF }} snapshots' diff --git a/analyses-snapshot-testing/Pipfile b/analyses-snapshot-testing/Pipfile index 43bb4dd2475..85a1d29c337 100644 --- a/analyses-snapshot-testing/Pipfile +++ b/analyses-snapshot-testing/Pipfile @@ -4,20 +4,18 @@ url = "https://pypi.org/simple" verify_ssl = true [packages] -pytest = "==8.1.1" -black = "==24.3.0" -selenium = "==4.19.0" -importlib-metadata = "==7.1.0" -requests = "==2.31.0" +pytest = "==8.3.3" +black = "==24.10.0" +importlib-metadata = "==8.5.0" +httpx = "==0.27.2" python-dotenv = "==1.0.1" -mypy = "==1.9.0" -types-requests = "==2.31.0.20240311" -rich = "==13.7.1" -pydantic = "==2.6.4" -ruff = "==0.3.4" -docker = "==7.0.0" -syrupy = "==4.6.1" +mypy = "==1.11.2" +rich = "==13.9.2" +pydantic = "==2.9.2" +ruff = "==0.6.9" +docker = "==7.1.0" +syrupy = "==4.7.2" pytest-html = "==4.1.1" [requires] -python_version = "3.12" +python_version = "3.13" diff --git a/analyses-snapshot-testing/Pipfile.lock b/analyses-snapshot-testing/Pipfile.lock index 0672556f9cd..648d065049c 100644 --- a/analyses-snapshot-testing/Pipfile.lock +++ b/analyses-snapshot-testing/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "b7ac4510c6e3aa343c669e1bd838183e905abb6f1701c6efbfb1c22f20cfae44" + "sha256": "6657134003c472f3e25e7c91a6b1f46eb8231b4518c18c90c6353bcf2666923e" }, "pipfile-spec": 6, "requires": { - "python_version": "3.12" + "python_version": "3.13" }, "sources": [ { @@ -18,56 +18,56 @@ "default": { "annotated-types": { "hashes": [ - "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43", - "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d" + "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", + "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" ], "markers": "python_version >= '3.8'", - "version": "==0.6.0" + "version": "==0.7.0" }, - "attrs": { + "anyio": { "hashes": [ - "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", - "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", + "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a" ], - "markers": "python_version >= '3.7'", - "version": "==23.2.0" + "markers": "python_version >= '3.9'", + "version": "==4.6.0" }, "black": { "hashes": [ - "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f", - "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93", - "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11", - "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0", - "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9", - "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5", - "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213", - "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d", - "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7", - "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837", - "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f", - "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395", - "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995", - "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", - "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597", - "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959", - "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5", - "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb", - "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4", - "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7", - "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd", - "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7" + "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", + "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd", + "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", + "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", + "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", + "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7", + "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", + "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", + "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", + "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", + "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", + "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f", + "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", + "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", + "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", + "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", + "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800", + "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", + "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", + "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", + "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", + "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==24.3.0" + "markers": "python_version >= '3.9'", + "version": "==24.10.0" }, "certifi": { "hashes": [ - "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f", - "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1" + "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" ], "markers": "python_version >= '3.6'", - "version": "==2024.2.2" + "version": "==2024.8.30" }, "charset-normalizer": { "hashes": [ @@ -175,12 +175,12 @@ }, "docker": { "hashes": [ - "sha256:12ba681f2777a0ad28ffbcc846a69c31b4dfd9752b47eb425a274ee269c5e14b", - "sha256:323736fb92cd9418fc5e7133bc953e11a9da04f4483f828b527db553f1e7e5a3" + "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", + "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==7.0.0" + "version": "==7.1.0" }, "h11": { "hashes": [ @@ -190,22 +190,39 @@ "markers": "python_version >= '3.7'", "version": "==0.14.0" }, + "httpcore": { + "hashes": [ + "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", + "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f" + ], + "markers": "python_version >= '3.8'", + "version": "==1.0.6" + }, + "httpx": { + "hashes": [ + "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", + "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==0.27.2" + }, "idna": { "hashes": [ - "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", - "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], - "markers": "python_version >= '3.5'", - "version": "==3.6" + "markers": "python_version >= '3.6'", + "version": "==3.10" }, "importlib-metadata": { "hashes": [ - "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", - "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2" + "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", + "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==7.1.0" + "version": "==8.5.0" }, "iniconfig": { "hashes": [ @@ -217,11 +234,11 @@ }, "jinja2": { "hashes": [ - "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa", - "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90" + "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", + "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" ], "markers": "python_version >= '3.7'", - "version": "==3.1.3" + "version": "==3.1.4" }, "markdown-it-py": { "hashes": [ @@ -233,69 +250,70 @@ }, "markupsafe": { "hashes": [ - "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", - "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", - "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", - "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", - "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", - "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", - "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", - "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", - "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", - "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", - "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", - "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", - "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", - "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", - "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", - "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", - "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", - "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", - "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", - "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", - "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", - "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", - "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", - "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", - "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", - "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", - "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", - "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", - "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", - "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", - "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", - "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", - "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", - "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", - "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", - "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", - "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", - "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", - "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", - "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", - "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", - "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", - "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", - "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", - "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", - "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", - "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", - "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", - "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", - "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", - "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", - "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", - "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", - "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", - "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", - "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", - "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", - "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", - "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", - "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" - ], - "markers": "python_version >= '3.7'", - "version": "==2.1.5" + "sha256:03ff62dea2fef3eadf2f1853bc6332bcb0458d9608b11dfb1cd5aeda1c178ea6", + "sha256:105ada43a61af22acb8774514c51900dc820c481cc5ba53f17c09d294d9c07ca", + "sha256:12ddac720b8965332d36196f6f83477c6351ba6a25d4aff91e30708c729350d7", + "sha256:1d151b9cf3307e259b749125a5a08c030ba15a8f1d567ca5bfb0e92f35e761f5", + "sha256:1ee9790be6f62121c4c58bbced387b0965ab7bffeecb4e17cc42ef290784e363", + "sha256:1fd02f47596e00a372f5b4af2b4c45f528bade65c66dfcbc6e1ea1bfda758e98", + "sha256:23efb2be7221105c8eb0e905433414d2439cb0a8c5d5ca081c1c72acef0f5613", + "sha256:25396abd52b16900932e05b7104bcdc640a4d96c914f39c3b984e5a17b01fba0", + "sha256:27d6a73682b99568916c54a4bfced40e7d871ba685b580ea04bbd2e405dfd4c5", + "sha256:380faf314c3c84c1682ca672e6280c6c59e92d0bc13dc71758ffa2de3cd4e252", + "sha256:3b231255770723f1e125d63c14269bcd8b8136ecfb620b9a18c0297e046d0736", + "sha256:3cd0bba31d484fe9b9d77698ddb67c978704603dc10cdc905512af308cfcca6b", + "sha256:3efde9a8c56c3b6e5f3fa4baea828f8184970c7c78480fedb620d804b1c31e5c", + "sha256:409535e0521c4630d5b5a1bf284e9d3c76d2fc2f153ebb12cf3827797798cc99", + "sha256:494a64efc535e147fcc713dba58eecfce3a79f1e93ebe81995b387f5cd9bc2e1", + "sha256:4ca04c60006867610a06575b46941ae616b19da0adc85b9f8f3d9cbd7a3da385", + "sha256:4deea1d9169578917d1f35cdb581bc7bab56a7e8c5be2633bd1b9549c3c22a01", + "sha256:509c424069dd037d078925b6815fc56b7271f3aaec471e55e6fa513b0a80d2aa", + "sha256:5509a8373fed30b978557890a226c3d30569746c565b9daba69df80c160365a5", + "sha256:59420b5a9a5d3fee483a32adb56d7369ae0d630798da056001be1e9f674f3aa6", + "sha256:5d207ff5cceef77796f8aacd44263266248cf1fbc601441524d7835613f8abec", + "sha256:5ddf5cb8e9c00d9bf8b0c75949fb3ff9ea2096ba531693e2e87336d197fdb908", + "sha256:63dae84964a9a3d2610808cee038f435d9a111620c37ccf872c2fcaeca6865b3", + "sha256:64a7c7856c3a409011139b17d137c2924df4318dab91ee0530800819617c4381", + "sha256:64f7d04410be600aa5ec0626d73d43e68a51c86500ce12917e10fd013e258df5", + "sha256:658fdf6022740896c403d45148bf0c36978c6b48c9ef8b1f8d0c7a11b6cdea86", + "sha256:678fbceb202382aae42c1f0cd9f56b776bc20a58ae5b553ee1fe6b802983a1d6", + "sha256:7835de4c56066e096407a1852e5561f6033786dd987fa90dc384e45b9bd21295", + "sha256:7c524203207f5b569df06c96dafdc337228921ee8c3cc5f6e891d024c6595352", + "sha256:7ed789d0f7f11fcf118cf0acb378743dfdd4215d7f7d18837c88171405c9a452", + "sha256:81be2c0084d8c69e97e3c5d73ce9e2a6e523556f2a19c4e195c09d499be2f808", + "sha256:81ee9c967956b9ea39b3a5270b7cb1740928d205b0dc72629164ce621b4debf9", + "sha256:8219e2207f6c188d15614ea043636c2b36d2d79bf853639c124a179412325a13", + "sha256:96e3ed550600185d34429477f1176cedea8293fa40e47fe37a05751bcb64c997", + "sha256:98fb3a2bf525ad66db96745707b93ba0f78928b7a1cb2f1cb4b143bc7e2ba3b3", + "sha256:9b36473a2d3e882d1873ea906ce54408b9588dc2c65989664e6e7f5a2de353d7", + "sha256:9f91c90f8f3bf436f81c12eeb4d79f9ddd263c71125e6ad71341906832a34386", + "sha256:a5fd5500d4e4f7cc88d8c0f2e45126c4307ed31e08f8ec521474f2fd99d35ac3", + "sha256:a7171d2b869e9be238ea318c196baf58fbf272704e9c1cd4be8c380eea963342", + "sha256:a80c6740e1bfbe50cea7cbf74f48823bb57bd59d914ee22ff8a81963b08e62d2", + "sha256:b2a7afd24d408b907672015555bc10be2382e6c5f62a488e2d452da670bbd389", + "sha256:b43ac1eb9f91e0c14aac1d2ef0f76bc7b9ceea51de47536f61268191adf52ad7", + "sha256:b6cc46a27d904c9be5732029769acf4b0af69345172ed1ef6d4db0c023ff603b", + "sha256:b94bec9eda10111ec7102ef909eca4f3c2df979643924bfe58375f560713a7d1", + "sha256:bd9b8e458e2bab52f9ad3ab5dc8b689a3c84b12b2a2f64cd9a0dfe209fb6b42f", + "sha256:c182d45600556917f811aa019d834a89fe4b6f6255da2fd0bdcf80e970f95918", + "sha256:c409691696bec2b5e5c9efd9593c99025bf2f317380bf0d993ee0213516d908a", + "sha256:c5243044a927e8a6bb28517838662a019cd7f73d7f106bbb37ab5e7fa8451a92", + "sha256:c8ab7efeff1884c5da8e18f743b667215300e09043820d11723718de0b7db934", + "sha256:cb244adf2499aa37d5dc43431990c7f0b632d841af66a51d22bd89c437b60264", + "sha256:d261ec38b8a99a39b62e0119ed47fe3b62f7691c500bc1e815265adc016438c1", + "sha256:d2c099be5274847d606574234e494f23a359e829ba337ea9037c3a72b0851942", + "sha256:d7e63d1977d3806ce0a1a3e0099b089f61abdede5238ca6a3f3bf8877b46d095", + "sha256:dba0f83119b9514bc37272ad012f0cc03f0805cc6a2bea7244e19250ac8ff29f", + "sha256:dcbee57fedc9b2182c54ffc1c5eed316c3da8bbfeda8009e1b5d7220199d15da", + "sha256:e042ccf8fe5bf8b6a4b38b3f7d618eb10ea20402b0c9f4add9293408de447974", + "sha256:e363440c8534bf2f2ef1b8fdc02037eb5fff8fce2a558519b22d6a3a38b3ec5e", + "sha256:e64b390a306f9e849ee809f92af6a52cda41741c914358e0e9f8499d03741526", + "sha256:f0411641d31aa6f7f0cc13f0f18b63b8dc08da5f3a7505972a42ab059f479ba3", + "sha256:f1c13c6c908811f867a8e9e66efb2d6c03d1cdd83e92788fe97f693c457dc44f", + "sha256:f846fd7c241e5bd4161e2a483663eb66e4d8e12130fcdc052f310f388f1d61c6" + ], + "markers": "python_version >= '3.9'", + "version": "==3.0.0" }, "mdurl": { "hashes": [ @@ -307,37 +325,37 @@ }, "mypy": { "hashes": [ - "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6", - "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913", - "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129", - "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc", - "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974", - "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374", - "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150", - "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03", - "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9", - "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02", - "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89", - "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2", - "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d", - "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3", - "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612", - "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e", - "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3", - "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e", - "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd", - "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04", - "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed", - "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185", - "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf", - "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b", - "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4", - "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f", - "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6" + "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", + "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce", + "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", + "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b", + "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", + "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", + "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", + "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", + "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86", + "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", + "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", + "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", + "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", + "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", + "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", + "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", + "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", + "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", + "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", + "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", + "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", + "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", + "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", + "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", + "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1", + "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b", + "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.9.0" + "version": "==1.11.2" }, "mypy-extensions": { "hashes": [ @@ -347,21 +365,13 @@ "markers": "python_version >= '3.5'", "version": "==1.0.0" }, - "outcome": { - "hashes": [ - "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", - "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.0.post0" - }, "packaging": { "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" ], - "markers": "python_version >= '3.7'", - "version": "==24.0" + "markers": "python_version >= '3.8'", + "version": "==24.1" }, "pathspec": { "hashes": [ @@ -373,138 +383,140 @@ }, "platformdirs": { "hashes": [ - "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", - "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" ], "markers": "python_version >= '3.8'", - "version": "==4.2.0" + "version": "==4.3.6" }, "pluggy": { "hashes": [ - "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", - "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" + "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", + "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" ], "markers": "python_version >= '3.8'", - "version": "==1.4.0" + "version": "==1.5.0" }, "pydantic": { "hashes": [ - "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6", - "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5" + "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", + "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.6.4" + "version": "==2.9.2" }, "pydantic-core": { "hashes": [ - "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a", - "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed", - "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979", - "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff", - "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5", - "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45", - "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340", - "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad", - "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23", - "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6", - "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7", - "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241", - "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda", - "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187", - "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba", - "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c", - "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2", - "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c", - "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132", - "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf", - "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972", - "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db", - "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade", - "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4", - "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8", - "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f", - "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9", - "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48", - "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec", - "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d", - "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9", - "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb", - "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4", - "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89", - "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c", - "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9", - "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da", - "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac", - "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b", - "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf", - "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e", - "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137", - "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1", - "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b", - "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8", - "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e", - "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053", - "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01", - "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe", - "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd", - "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805", - "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183", - "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8", - "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99", - "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820", - "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074", - "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256", - "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8", - "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975", - "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad", - "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e", - "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca", - "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df", - "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b", - "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a", - "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a", - "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721", - "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a", - "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f", - "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2", - "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97", - "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6", - "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed", - "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc", - "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1", - "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe", - "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120", - "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f", - "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a" + "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", + "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", + "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", + "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", + "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", + "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", + "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", + "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", + "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", + "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", + "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", + "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", + "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", + "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", + "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", + "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", + "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368", + "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", + "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", + "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2", + "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", + "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", + "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", + "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", + "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", + "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", + "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271", + "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", + "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb", + "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13", + "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", + "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556", + "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665", + "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", + "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", + "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", + "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", + "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", + "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", + "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", + "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", + "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", + "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", + "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", + "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", + "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", + "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658", + "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", + "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", + "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", + "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", + "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", + "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", + "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", + "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", + "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", + "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", + "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad", + "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", + "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", + "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", + "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", + "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", + "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", + "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", + "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", + "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", + "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", + "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555", + "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", + "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6", + "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", + "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", + "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", + "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", + "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", + "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", + "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", + "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", + "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12", + "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", + "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", + "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", + "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", + "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", + "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", + "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", + "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", + "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607" ], "markers": "python_version >= '3.8'", - "version": "==2.16.3" + "version": "==2.23.4" }, "pygments": { "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" + "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", + "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" - }, - "pysocks": { - "hashes": [ - "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", - "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", - "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" - ], - "version": "==1.7.1" + "markers": "python_version >= '3.8'", + "version": "==2.18.0" }, "pytest": { "hashes": [ - "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7", - "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044" + "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", + "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==8.1.1" + "version": "==8.3.3" }, "pytest-html": { "hashes": [ @@ -534,54 +546,45 @@ }, "requests": { "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", + "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.31.0" + "markers": "python_version >= '3.8'", + "version": "==2.32.3" }, "rich": { "hashes": [ - "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", - "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432" + "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", + "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1" ], "index": "pypi", - "markers": "python_full_version >= '3.7.0'", - "version": "==13.7.1" + "markers": "python_full_version >= '3.8.0'", + "version": "==13.9.2" }, "ruff": { "hashes": [ - "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365", - "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488", - "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4", - "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9", - "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232", - "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91", - "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369", - "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed", - "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102", - "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e", - "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c", - "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7", - "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378", - "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854", - "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6", - "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50", - "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1" + "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", + "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", + "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", + "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", + "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", + "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", + "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", + "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", + "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", + "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", + "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", + "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", + "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", + "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", + "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", + "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", + "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", + "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.3.4" - }, - "selenium": { - "hashes": [ - "sha256:5b4f49240d61e687a73f7968ae2517d403882aae3550eae2a229c745e619f1d9", - "sha256:d9dfd6d0b021d71d0a48b865fe7746490ba82b81e9c87b212360006629eb1853" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.19.0" + "version": "==0.6.9" }, "sniffio": { "hashes": [ @@ -591,81 +594,41 @@ "markers": "python_version >= '3.7'", "version": "==1.3.1" }, - "sortedcontainers": { - "hashes": [ - "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", - "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" - ], - "version": "==2.4.0" - }, "syrupy": { "hashes": [ - "sha256:203e52f9cb9fa749cf683f29bd68f02c16c3bc7e7e5fe8f2fc59bdfe488ce133", - "sha256:37a835c9ce7857eeef86d62145885e10b3cb9615bc6abeb4ce404b3f18e1bb36" + "sha256:ea45e099f242de1bb53018c238f408a5bb6c82007bc687aefcbeaa0e1c2e935a", + "sha256:eae7ba6be5aed190237caa93be288e97ca1eec5ca58760e4818972a10c4acc64" ], "index": "pypi", - "markers": "python_version < '4' and python_full_version >= '3.8.1'", - "version": "==4.6.1" - }, - "trio": { - "hashes": [ - "sha256:9b41f5993ad2c0e5f62d0acca320ec657fdb6b2a2c22b8c7aed6caf154475c4e", - "sha256:e6458efe29cc543e557a91e614e2b51710eba2961669329ce9c862d50c6e8e81" - ], - "markers": "python_version >= '3.8'", - "version": "==0.25.0" - }, - "trio-websocket": { - "hashes": [ - "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f", - "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638" - ], - "markers": "python_version >= '3.7'", - "version": "==0.11.1" - }, - "types-requests": { - "hashes": [ - "sha256:47872893d65a38e282ee9f277a4ee50d1b28bd592040df7d1fdaffdf3779937d", - "sha256:b1c1b66abfb7fa79aae09097a811c4aa97130eb8831c60e47aee4ca344731ca5" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2.31.0.20240311" + "markers": "python_full_version >= '3.8.1'", + "version": "==4.7.2" }, "typing-extensions": { "hashes": [ - "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", - "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], - "markers": "python_version >= '3.8'", - "version": "==4.10.0" + "markers": "python_version >= '3.13'", + "version": "==4.12.2" }, "urllib3": { "extras": [ "socks" ], "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" - }, - "wsproto": { - "hashes": [ - "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", - "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==1.2.0" + "version": "==2.2.3" }, "zipp": { "hashes": [ - "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b", - "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715" + "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", + "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29" ], "markers": "python_version >= '3.8'", - "version": "==3.18.1" + "version": "==3.20.2" } }, "develop": {} diff --git a/analyses-snapshot-testing/pyproject.toml b/analyses-snapshot-testing/pyproject.toml index 4d4c9cf166c..9ea01a884cb 100644 --- a/analyses-snapshot-testing/pyproject.toml +++ b/analyses-snapshot-testing/pyproject.toml @@ -1,13 +1,13 @@ [tool.black] line-length = 140 -target-version = ['py312'] +target-version = ['py313'] [tool.ruff] # Like Black line-length = 140 # Like Black indent-width = 4 -target-version = "py312" +target-version = "py313" exclude = ["files"] src = ["*.py", "automation", "tests", "citools"] From 7e39d06092e166fad8dd1d3270a1d4f4b968bd9c Mon Sep 17 00:00:00 2001 From: koji Date: Tue, 8 Oct 2024 11:29:59 -0400 Subject: [PATCH 016/101] fix(protocol-designer): fix pipette info card in protocol overview (#16429) * fix(protocol-designer): fix pipette info card in protocol overview --- .../ProtocolOverview/InstrumentsInfo.tsx | 130 ++++++++++++++++++ .../__tests__/InstrumentsInfo.test.tsx | 106 ++++++++++++++ .../__tests__/ProtocolOverview.test.tsx | 16 ++- .../src/pages/ProtocolOverview/index.tsx | 83 ++--------- 4 files changed, 254 insertions(+), 81 deletions(-) create mode 100644 protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx create mode 100644 protocol-designer/src/pages/ProtocolOverview/__tests__/InstrumentsInfo.test.tsx diff --git a/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx b/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx new file mode 100644 index 00000000000..e87b6550904 --- /dev/null +++ b/protocol-designer/src/pages/ProtocolOverview/InstrumentsInfo.tsx @@ -0,0 +1,130 @@ +import { useTranslation } from 'react-i18next' + +import { + Flex, + StyledText, + Btn, + DIRECTION_COLUMN, + SPACING, + JUSTIFY_SPACE_BETWEEN, + TYPOGRAPHY, + ListItem, + ListItemDescriptor, +} from '@opentrons/components' +import { getPipetteSpecsV2, FLEX_ROBOT_TYPE } from '@opentrons/shared-data' + +import { BUTTON_LINK_STYLE } from '../../atoms' + +import type { PipetteName, RobotType } from '@opentrons/shared-data' +import type { AdditionalEquipmentEntities } from '@opentrons/step-generation' +import type { PipetteOnDeck } from '../../step-forms' + +interface InstrumentsInfoProps { + robotType: RobotType + pipettesOnDeck: PipetteOnDeck[] + additionalEquipment: AdditionalEquipmentEntities + setShowEditInstrumentsModal: (showEditInstrumentsModal: boolean) => void +} + +export function InstrumentsInfo({ + robotType, + pipettesOnDeck, + additionalEquipment, + setShowEditInstrumentsModal, +}: InstrumentsInfoProps): JSX.Element { + const { t } = useTranslation(['protocol_overview', 'shared']) + const leftPipette = pipettesOnDeck.find(pipette => pipette.mount === 'left') + const rightPipette = pipettesOnDeck.find(pipette => pipette.mount === 'right') + const isGripperAttached = Object.values(additionalEquipment).some( + equipment => equipment?.name === 'gripper' + ) + + const pipetteInfo = (pipette?: PipetteOnDeck): JSX.Element | string => { + const pipetteName = + pipette != null + ? getPipetteSpecsV2(pipette.name as PipetteName)?.displayName + : t('na') + const tipsInfo = pipette?.tiprackLabwareDef + ? pipette.tiprackLabwareDef.map(labware => labware.metadata.displayName) + : t('na') + + if (pipetteName === t('na') || tipsInfo === t('na')) { + return t('na') + } + + return ( + + {pipetteName} + {pipette != null && pipette.tiprackLabwareDef.length > 0 + ? pipette?.tiprackLabwareDef.map(labware => ( + + {labware.metadata.displayName} + + )) + : null} + + ) + } + + return ( + + + + {t('instruments')} + + + { + setShowEditInstrumentsModal(true) + }} + css={BUTTON_LINK_STYLE} + > + + {t('edit')} + + + + + + + + + + + + + + + {robotType === FLEX_ROBOT_TYPE ? ( + + + + ) : null} + + + ) +} diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/InstrumentsInfo.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/InstrumentsInfo.test.tsx new file mode 100644 index 00000000000..d982027d9fc --- /dev/null +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/InstrumentsInfo.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' + +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../assets/localization' +import { InstrumentsInfo } from '../InstrumentsInfo' + +import type { ComponentProps } from 'react' +import type { AdditionalEquipmentEntities } from '@opentrons/step-generation' +import type { PipetteOnDeck } from '../../../step-forms' + +const mockSetShowEditInstrumentsModal = vi.fn() +const mockPipettes = [ + { + mount: 'left', + id: 'mock-left', + name: 'p50_single_flex', + tiprackDefURI: ['opentrons/opentrons_flex_96_tiprack_50ul/1'], + tiprackLabwareDef: [ + { + metadata: { + displayName: 'Opentrons Flex 96 Tip Rack 50 µL', + displayCategory: 'tipRack', + displayVolumeUnits: 'µL', + tags: [], + }, + } as any, + ], + } as PipetteOnDeck, + { + mount: 'right', + id: 'mock-right', + name: 'p50_multi_flex', + tiprackDefURI: ['opentrons/opentrons_flex_96_filtertiprack_50ul/1'], + tiprackLabwareDef: [ + { + metadata: { + displayName: 'Opentrons Flex 96 Filter Tip Rack 50 µL', + displayCategory: 'tipRack', + displayVolumeUnits: 'µL', + tags: [], + }, + } as any, + ], + } as PipetteOnDeck, +] + +const mockAdditionalEquipment = { + 'mock:gripper': { + name: 'gripper', + id: 'mock:gripper', + }, +} as AdditionalEquipmentEntities + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('InstrumentsInfo', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + robotType: FLEX_ROBOT_TYPE, + pipettesOnDeck: [], + additionalEquipment: {}, + setShowEditInstrumentsModal: mockSetShowEditInstrumentsModal, + } + }) + + it('should render text', () => { + render(props) + screen.getByText('Instruments') + screen.getByText('Robot type') + screen.getAllByText('Opentrons Flex') + screen.getByText('Left pipette') + screen.getByText('Right pipette') + screen.getByText('Extension mount') + expect(screen.getAllByText('N/A').length).toBe(3) + }) + + it('should render instruments info', () => { + props = { + ...props, + pipettesOnDeck: mockPipettes, + additionalEquipment: mockAdditionalEquipment, + } + render(props) + + screen.getByText('Flex 1-Channel 50 μL') + screen.getByText('Opentrons Flex 96 Tip Rack 50 µL') + screen.getByText('Flex 8-Channel 50 μL') + screen.getByText('Opentrons Flex 96 Filter Tip Rack 50 µL') + screen.getByText('Opentrons Flex Gripper') + }) + + it('should call mock function when clicking edit text button', () => { + render(props) + fireEvent.click(screen.getByText('Edit')) + expect(mockSetShowEditInstrumentsModal).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx index 471898802f6..1ee9718966b 100644 --- a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx @@ -16,6 +16,7 @@ import { selectors as labwareIngredSelectors } from '../../../labware-ingred/sel import { ProtocolOverview } from '../index' import { DeckThumbnail } from '../DeckThumbnail' import { OffDeckThumbnail } from '../OffdeckThumbnail' +import { InstrumentsInfo } from '../InstrumentsInfo' import { LiquidDefinitions } from '../LiquidDefinitions' import type { NavigateFunction } from 'react-router-dom' @@ -29,6 +30,7 @@ vi.mock('../../../labware-ingred/selectors') vi.mock('../../../organisms') vi.mock('../../../labware-ingred/selectors') vi.mock('../LiquidDefinitions') +vi.mock('../InstrumentsInfo') const mockNavigate = vi.fn() @@ -78,6 +80,7 @@ describe('ProtocolOverview', () => { vi.mocked(LiquidDefinitions).mockReturnValue(
    mock LiquidDefinitions
    ) + vi.mocked(InstrumentsInfo).mockReturnValue(
    mock InstrumentsInfo
    ) }) it('renders each section with text', () => { @@ -99,15 +102,13 @@ describe('ProtocolOverview', () => { screen.getByText('Last exported') screen.getByText('Required app version') screen.getByText('8.0.0 or higher') + // instruments - screen.getByText('Instruments') - screen.getByText('Robot type') - screen.getAllByText('Opentrons Flex') - screen.getByText('Left pipette') - screen.getByText('Right pipette') - screen.getByText('Extension mount') + screen.getByText('mock InstrumentsInfo') + // liquids screen.getByText('mock LiquidDefinitions') + // steps screen.getByText('Protocol steps') }) @@ -126,7 +127,8 @@ describe('ProtocolOverview', () => { description: undefined, }) render() - expect(screen.getAllByText('N/A').length).toBe(7) + // ToDo (kk: 2024/10/07) this part should be replaced + expect(screen.getAllByText('N/A').length).toBe(4) }) it('navigates to starting deck state', () => { diff --git a/protocol-designer/src/pages/ProtocolOverview/index.tsx b/protocol-designer/src/pages/ProtocolOverview/index.tsx index 0009f4cf624..9a8dd1b4f4b 100644 --- a/protocol-designer/src/pages/ProtocolOverview/index.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/index.tsx @@ -27,11 +27,7 @@ import { ToggleGroup, TYPOGRAPHY, } from '@opentrons/components' -import { - getPipetteSpecsV2, - FLEX_ROBOT_TYPE, - OT2_ROBOT_TYPE, -} from '@opentrons/shared-data' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { getAdditionalEquipmentEntities, @@ -57,9 +53,10 @@ import { import { DeckThumbnail } from './DeckThumbnail' import { OffDeckThumbnail } from './OffdeckThumbnail' import { getWarningContent } from './UnusedModalContent' +import { InstrumentsInfo } from './InstrumentsInfo' import { LiquidDefinitions } from './LiquidDefinitions' -import type { CreateCommand, PipetteName } from '@opentrons/shared-data' +import type { CreateCommand } from '@opentrons/shared-data' import type { DeckSlot } from '@opentrons/step-generation' import type { ThunkDispatch } from '../../types' @@ -181,8 +178,6 @@ export function ProtocolOverview(): JSX.Element { } const pipettesOnDeck = Object.values(pipettes) - const leftPip = pipettesOnDeck.find(pip => pip.mount === 'left') - const rightPip = pipettesOnDeck.find(pip => pip.mount === 'right') const { protocolName, description, @@ -384,72 +379,12 @@ export function ProtocolOverview(): JSX.Element { - - - - {t('instruments')} - - - { - setShowEditInstrumentsModal(true) - }} - css={BUTTON_LINK_STYLE} - > - - {t('edit')} - - - - - - - - - - - - - - - {robotType === FLEX_ROBOT_TYPE ? ( - - - - ) : null} - - + From 6869198be24f46608cb513b86204ea5cf213e870 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Tue, 8 Oct 2024 14:09:30 -0500 Subject: [PATCH 017/101] feat(abt): speed up analyses battery with async analysis (#16431) # Overview Remove cruft from `generate_analyses.py` and analyze against `n` containers in a ThreadPoolExecutor. - [x] Works locally - [x] Works in CI --- .../citools/generate_analyses.py | 312 ++++++------------ .../citools/write_failed_analysis.py | 0 2 files changed, 105 insertions(+), 207 deletions(-) delete mode 100644 analyses-snapshot-testing/citools/write_failed_analysis.py diff --git a/analyses-snapshot-testing/citools/generate_analyses.py b/analyses-snapshot-testing/citools/generate_analyses.py index f1335b102ae..f67d0394429 100644 --- a/analyses-snapshot-testing/citools/generate_analyses.py +++ b/analyses-snapshot-testing/citools/generate_analyses.py @@ -1,13 +1,13 @@ +import concurrent import json import os -import signal import time -from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum, auto from pathlib import Path -from typing import Any, Dict, Generator, List, Optional +from typing import Any, Dict, List, Optional import docker # type: ignore from automation.data.protocol import Protocol @@ -15,31 +15,22 @@ from rich.traceback import install install(show_locals=True) -IMAGE = "opentrons-analysis" -CONTAINER_LABWARE = "/var/lib/ot" -HOST_LABWARE = Path(Path(__file__).parent.parent, "files", "labware") -HOST_PROTOCOLS_ROOT = Path(Path(__file__).parent.parent, "files", "protocols") -CONTAINER_PROTOCOLS_ROOT = "/var/lib/ot/protocols" -CONTAINER_RESULTS = "/var/lib/ot/analysis_results" -HOST_RESULTS = Path(Path(__file__).parent.parent, "analysis_results") -ANALYSIS_SUFFIX = "analysis.json" +IMAGE: str = "opentrons-analysis" +CONTAINER_LABWARE: str = "/var/lib/ot" +HOST_LABWARE: Path = Path(Path(__file__).parent.parent, "files", "labware") +HOST_PROTOCOLS_ROOT: Path = Path(Path(__file__).parent.parent, "files", "protocols") +CONTAINER_PROTOCOLS_ROOT: str = "/var/lib/ot/protocols" +CONTAINER_RESULTS: str = "/var/lib/ot/analysis_results" +HOST_RESULTS: Path = Path(Path(__file__).parent.parent, "analysis_results") +ANALYSIS_SUFFIX: str = "analysis.json" +ANALYSIS_TIMEOUT_SECONDS: int = 30 +ANALYSIS_CONTAINER_INSTANCES: int = 5 console = Console() -@contextmanager -def timeout(seconds: int) -> Generator[None, None, None]: - # Signal handler function - def raise_timeout(signum, frame) -> None: # type: ignore[no-untyped-def] - raise TimeoutError - - # Set the signal handler for the alarm signal - signal.signal(signal.SIGALRM, raise_timeout) - signal.alarm(seconds) # Set the alarm - try: - yield - finally: - signal.alarm(0) # Disable the alarm +def is_running_in_github_actions() -> bool: + return os.getenv("GITHUB_ACTIONS") == "true" class ProtocolType(Enum): @@ -48,7 +39,7 @@ class ProtocolType(Enum): @dataclass -class AnalyzedProtocol: +class TargetProtocol: host_protocol_file: Path container_protocol_file: Path host_analysis_file: Path @@ -111,7 +102,7 @@ def set_analysis_execution_time(self, analysis_execution_time: float) -> None: self.analysis_execution_time = analysis_execution_time -def stop_and_restart_container(image_name: str, timeout: int = 60) -> docker.models.containers.Container: +def start_containers(image_name: str, num_containers: int, timeout: int = 60) -> List[docker.models.containers.Container]: client = docker.from_env() volumes = { str(HOST_LABWARE): {"bind": CONTAINER_LABWARE, "mode": "rw"}, @@ -119,64 +110,55 @@ def stop_and_restart_container(image_name: str, timeout: int = 60) -> docker.mod str(HOST_PROTOCOLS_ROOT): {"bind": CONTAINER_PROTOCOLS_ROOT, "mode": "rw"}, } - # Find the running container using the specified image - containers = client.containers.list(filters={"ancestor": image_name, "status": "running"}) - + # Stop and remove existing containers + containers: List[docker.models.containers.Container] = client.containers.list(filters={"ancestor": image_name}) if containers: - console.print("Stopping the running container(s)...") + console.print("Stopping and removing existing container(s)...") for container in containers: container.stop(timeout=10) + container.remove() - # Start a new container with the specified volume - console.print("Starting a new container.") - container = client.containers.run(image_name, detach=True, volumes=volumes) + # Start new containers + console.print(f"Starting {num_containers} new container(s).") + containers = [] + for _ in range(num_containers): + container = client.containers.run(image_name, detach=True, volumes=volumes) + containers.append(container) - # Wait for the container to be ready if a readiness command is provided + # Wait for containers to be ready start_time = time.time() while time.time() - start_time < timeout: - exit_code, output = container.exec_run(f"ls -al {CONTAINER_LABWARE}") - if exit_code == 0: - console.print("Container is ready.") + all_ready = True + for container in containers: + exit_code, _ = container.exec_run(f"ls -al {CONTAINER_LABWARE}") + if exit_code != 0: + all_ready = False + break + if all_ready: + console.print("All containers are ready.") break else: - console.print("Waiting for container to be ready...") - time.sleep(5) + console.print("Waiting for containers to be ready...") + time.sleep(5) else: - console.print("Timeout waiting for container to be ready. Proceeding anyway.") - return container + console.print("Timeout waiting for containers to be ready. Proceeding anyway.") + return containers def stop_and_remove_containers(image_name: str) -> None: client = docker.from_env() - - # Find all containers created from the specified image containers = client.containers.list(all=True, filters={"ancestor": image_name}) - for container in containers: try: - # Stop the container if it's running if container.status == "running": console.print(f"Stopping container {container.short_id}...") container.stop(timeout=10) - - # Remove the container console.print(f"Removing container {container.short_id}...") container.remove() - except docker.errors.ContainerError as e: + except Exception as e: console.print(f"Error stopping/removing container {container.short_id}: {e}") -def has_designer_application(json_file_path: Path) -> bool: - try: - with open(json_file_path, "r", encoding="utf-8") as file: - data = json.load(file) - return "designerApplication" in data - except json.JSONDecodeError: - # Handle the exception if the file is not a valid JSON - console.print(f"Invalid JSON file: {json_file_path}") - return False - - def host_analysis_path(protocol_file: Path, tag: str) -> Path: return Path(HOST_RESULTS, f"{protocol_file.stem}_{tag}_{ANALYSIS_SUFFIX}") @@ -185,79 +167,6 @@ def container_analysis_path(protocol_file: Path, tag: str) -> Path: return Path(CONTAINER_RESULTS, f"{protocol_file.stem}_{tag}_{ANALYSIS_SUFFIX}") -def generate_protocols(tag: str) -> List[AnalyzedProtocol]: - - # Since we do not have the specification for which labware to use - # we will use all labware in the host labware directory - all_custom_labware_paths = [str(host_path.relative_to(CONTAINER_LABWARE)) for host_path in list(Path(HOST_LABWARE).rglob("*.json"))] - - def find_pd_protocols() -> List[AnalyzedProtocol]: - # Check if the provided path is a valid directory - if not HOST_PROTOCOLS_ROOT.is_dir(): - raise NotADirectoryError(f"The path {HOST_PROTOCOLS_ROOT} is not a valid directory.") - - nonlocal all_custom_labware_paths - - # Recursively find all .json files - json_files = list(HOST_PROTOCOLS_ROOT.rglob("*.json")) - filtered_json_files = [file for file in json_files if has_designer_application(file)] - pd_protocols: List[AnalyzedProtocol] = [] - for path in filtered_json_files: - relative_path = path.relative_to(HOST_PROTOCOLS_ROOT) - updated_path = Path(CONTAINER_PROTOCOLS_ROOT, relative_path) - pd_protocols.append( - AnalyzedProtocol( - host_protocol_file=path, - container_protocol_file=updated_path, - host_analysis_file=host_analysis_path(path, tag), - container_analysis_file=container_analysis_path(path, tag), - tag=tag, - custom_labware_paths=all_custom_labware_paths, - ) - ) - return pd_protocols - - def find_python_protocols() -> List[AnalyzedProtocol]: - # Check if the provided path is a valid directory - if not HOST_PROTOCOLS_ROOT.is_dir(): - raise NotADirectoryError(f"The path {HOST_PROTOCOLS_ROOT} is not a valid directory.") - - nonlocal all_custom_labware_paths - - # Recursively find all .py files - python_files = list(HOST_PROTOCOLS_ROOT.rglob("*.py")) - py_protocols: List[AnalyzedProtocol] = [] - - for path in python_files: - relative_path = path.relative_to(HOST_PROTOCOLS_ROOT) - container_path = Path(CONTAINER_PROTOCOLS_ROOT, relative_path) - py_protocols.append( - AnalyzedProtocol( - host_protocol_file=path, - container_protocol_file=container_path, - host_analysis_file=host_analysis_path(path, tag), - container_analysis_file=container_analysis_path(path, tag), - tag=tag, - custom_labware_paths=all_custom_labware_paths, - ) - ) - return py_protocols - - return find_pd_protocols() + find_python_protocols() - - -def remove_all_files_in_directory(directory: Path) -> None: - for filename in os.listdir(directory): - file_path = os.path.join(directory, filename) - try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - pass # Currently, subdirectories are not removed - except Exception as e: - console.print(f"Failed to delete {file_path}. Reason: {e}") - - def protocol_custom_labware_paths_in_container(protocol: Protocol) -> List[str]: if not HOST_LABWARE.is_dir() or protocol.custom_labware is None: return [] @@ -269,98 +178,87 @@ def protocol_custom_labware_paths_in_container(protocol: Protocol) -> List[str]: ] -def analyze(protocol: AnalyzedProtocol, container: docker.models.containers.Container) -> bool: - # Run the analyze command - command = f"python -I -m opentrons.cli analyze --json-output {protocol.container_analysis_file} {protocol.container_protocol_file} {' '.join(protocol.custom_labware_paths)}" # noqa: E501 +def analyze(protocol: TargetProtocol, container: docker.models.containers.Container) -> bool: + command = ( + f"python -I -m opentrons.cli analyze --json-output {protocol.container_analysis_file} " + f"{protocol.container_protocol_file} {' '.join(protocol.custom_labware_paths)}" + ) start_time = time.time() - timeout_duration = 30 # seconds + result = None + exit_code = None try: - with timeout(timeout_duration): - command_result = container.exec_run(cmd=command) - exit_code = command_result.exit_code - result = command_result.output - protocol.command_output = result.decode("utf-8") - protocol.command_exit_code = exit_code - protocol.set_analysis() - protocol.set_analysis_execution_time(time.time() - start_time) - return True - except TimeoutError: - console.print(f"Command execution exceeded {timeout_duration} seconds and was aborted.") - logs = container.logs() - # Decode and print the logs - console.print(f"container logs{logs.decode('utf-8')}") - except KeyboardInterrupt: - console.print("Execution was interrupted by the user.") - raise + command_result = container.exec_run(cmd=command) + exit_code = command_result.exit_code + result = command_result.output + protocol.command_output = result.decode("utf-8") if result else "" + protocol.command_exit_code = exit_code + protocol.set_analysis() + return True except Exception as e: console.print(f"An unexpected error occurred: {e}") - protocol.command_output = result.decode("utf-8") - console.print(f"Command output: {protocol.command_output}") - protocol.command_exit_code = exit_code - console.print(f"Exit code: {protocol.command_exit_code}") + protocol.command_output = result.decode("utf-8") if result else str(e) + protocol.command_exit_code = exit_code if exit_code is not None else -1 protocol.set_analysis() return False - protocol.command_output = None - protocol.command_exit_code = None - protocol.analysis = None - protocol.set_analysis_execution_time(time.time() - start_time) - return False - + finally: + protocol.set_analysis_execution_time(time.time() - start_time) + console.print(f"Analysis of {protocol.host_protocol_file.name} completed in {protocol.analysis_execution_time:.2f} seconds.") + + +def analyze_many(protocol_files: List[TargetProtocol], containers: List[docker.models.containers.Container]) -> None: + num_containers = len(containers) + with ThreadPoolExecutor(max_workers=num_containers) as executor: + futures = [] + for i, protocol in enumerate(protocol_files): + container = containers[i % num_containers] + future = executor.submit(analyze, protocol, container) + futures.append((future, protocol)) + for future, protocol in futures: + try: + future.result(timeout=ANALYSIS_TIMEOUT_SECONDS) + except concurrent.futures.TimeoutError: + console.print(f"Analysis of {protocol.host_protocol_file} exceeded {ANALYSIS_TIMEOUT_SECONDS} seconds and was aborted.") + # Handle timeout (e.g., mark as failed) + except Exception as e: + console.print(f"An error occurred during analysis: {e}") -def analyze_many(protocol_files: List[AnalyzedProtocol], container: docker.models.containers.Container) -> None: - for file in protocol_files: - analyze(file, container) accumulated_time = sum(protocol.analysis_execution_time for protocol in protocol_files if protocol.analysis_execution_time is not None) console.print(f"{len(protocol_files)} protocols with total analysis time of {accumulated_time:.2f} seconds.\n") -def analyze_against_image(tag: str) -> List[AnalyzedProtocol]: +def analyze_against_image(tag: str, protocols: List[TargetProtocol], num_containers: int = 1) -> List[TargetProtocol]: image_name = f"{IMAGE}:{tag}" - protocols = generate_protocols(tag) - protocols_to_process = protocols - # protocols_to_process = protocols[:1] # For testing try: - console.print(f"Analyzing {len(protocols_to_process)} protocol(s) against {image_name}...") - container = stop_and_restart_container(image_name) - analyze_many(protocols_to_process, container) + console.print(f"\nAnalyzing {len(protocols)} protocol(s) against {image_name} using {num_containers} container(s)...") + containers = start_containers(image_name, num_containers) + analyze_many(protocols, containers) finally: - stop_and_remove_containers(image_name) - return protocols_to_process + if is_running_in_github_actions(): + pass # We don't need to stop and remove containers in CI + else: + stop_and_remove_containers(image_name) + return protocols def generate_analyses_from_test(tag: str, protocols: List[Protocol]) -> None: """Generate analyses from the tests.""" - try: - image_name = f"{IMAGE}:{tag}" - protocols_to_process: List[AnalyzedProtocol] = [] - # convert the protocols to AnalyzedProtocol - for test_protocol in protocols: - host_protocol_file = Path(test_protocol.file_path) - container_protocol_file = Path(CONTAINER_PROTOCOLS_ROOT, host_protocol_file.relative_to(HOST_PROTOCOLS_ROOT)) - host_analysis_file = host_analysis_path(host_protocol_file, tag) - container_analysis_file = container_analysis_path(host_protocol_file, tag) - protocols_to_process.append( - AnalyzedProtocol( - host_protocol_file, - container_protocol_file, - host_analysis_file, - container_analysis_file, - tag, - protocol_custom_labware_paths_in_container(test_protocol), - ) + start_time = time.time() + protocols_to_process: List[TargetProtocol] = [] + for test_protocol in protocols: + host_protocol_file = Path(test_protocol.file_path) + container_protocol_file = Path(CONTAINER_PROTOCOLS_ROOT, host_protocol_file.relative_to(HOST_PROTOCOLS_ROOT)) + host_analysis_file = host_analysis_path(host_protocol_file, tag) + container_analysis_file = container_analysis_path(host_protocol_file, tag) + protocols_to_process.append( + TargetProtocol( + host_protocol_file, + container_protocol_file, + host_analysis_file, + container_analysis_file, + tag, + protocol_custom_labware_paths_in_container(test_protocol), ) - console.print(f"Analyzing {len(protocols_to_process)} protocol(s) against {tag}...") - container = stop_and_restart_container(image_name) - # Analyze the protocols - for protocol_to_analyze in protocols_to_process: - console.print(f"Analyzing {protocol_to_analyze.host_protocol_file}...") - analyzed = analyze(protocol_to_analyze, container) - if not analyzed: # Fail fast - console.print("Analysis failed. Exiting.") - stop_and_remove_containers(image_name) - accumulated_time = sum( - protocol.analysis_execution_time for protocol in protocols_to_process if protocol.analysis_execution_time is not None ) - console.print(f"{len(protocols_to_process)} protocols with total analysis time of {accumulated_time:.2f} seconds.\n") - finally: - stop_and_remove_containers(image_name) + analyze_against_image(tag, protocols_to_process, ANALYSIS_CONTAINER_INSTANCES) + end_time = time.time() + console.print(f"Clock time to generate analyses: {end_time - start_time:.2f} seconds.") diff --git a/analyses-snapshot-testing/citools/write_failed_analysis.py b/analyses-snapshot-testing/citools/write_failed_analysis.py deleted file mode 100644 index e69de29bb2d..00000000000 From 9ea27a20f4b86ea6f46a6605509dd670875d570a Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Tue, 8 Oct 2024 14:40:18 -0500 Subject: [PATCH 018/101] feat(ai-server): improved logging (#16435) # Overview Redo logging for this API ## Test Plan and Hands on Testing - [x] local logging output in the container is JSON - [x] local logging output with local environment is text Once merged and auto-deployed to staging, we will look at the output in DataDog ## Risk assessment - medium - we are not getting what we need in DataDog to build filters and alerts and so we must evolve with this this - there is also a risk with the middleware stacking, so we will test a lot in staging --- opentrons-ai-server/Dockerfile | 9 +- opentrons-ai-server/Makefile | 2 +- opentrons-ai-server/Pipfile | 3 +- opentrons-ai-server/Pipfile.lock | 767 ++++++++++-------- .../api/domain/openai_predict.py | 11 +- opentrons-ai-server/api/domain/prompts.py | 8 +- .../api/handler/custom_logging.py | 133 +++ opentrons-ai-server/api/handler/fast.py | 82 +- opentrons-ai-server/api/handler/local_run.py | 13 +- .../api/handler/logging_config.py | 39 - opentrons-ai-server/api/integration/auth.py | 17 +- opentrons-ai-server/api/settings.py | 17 +- .../api/uvicorn_disable_logging.json | 36 + .../tests/helpers/huggingface_client.py | 1 + 14 files changed, 736 insertions(+), 402 deletions(-) create mode 100644 opentrons-ai-server/api/handler/custom_logging.py delete mode 100644 opentrons-ai-server/api/handler/logging_config.py create mode 100644 opentrons-ai-server/api/uvicorn_disable_logging.json diff --git a/opentrons-ai-server/Dockerfile b/opentrons-ai-server/Dockerfile index ddd19bb88c7..7a9a696145b 100644 --- a/opentrons-ai-server/Dockerfile +++ b/opentrons-ai-server/Dockerfile @@ -1,7 +1,8 @@ -FROM --platform=linux/amd64 python:3.12-slim +ARG PLATFORM=linux/amd64 +FROM --platform=$PLATFORM python:3.12-slim -ENV PYTHONUNBUFFERED True -ENV DOCKER_RUNNING True +ENV PYTHONUNBUFFERED=True +ENV DOCKER_RUNNING=True WORKDIR /code @@ -15,4 +16,4 @@ COPY ./api /code/api EXPOSE 8000 -CMD ["ddtrace-run", "uvicorn", "api.handler.fast:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "8000", "--timeout-keep-alive", "190", "--workers", "3"] +CMD ["ddtrace-run", "uvicorn", "api.handler.fast:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "8000", "--timeout-keep-alive", "190", "--log-config", "/code/api/uvicorn_disable_logging.json", "--workers", "3"] diff --git a/opentrons-ai-server/Makefile b/opentrons-ai-server/Makefile index e3f678606e1..60eba38a312 100644 --- a/opentrons-ai-server/Makefile +++ b/opentrons-ai-server/Makefile @@ -115,7 +115,7 @@ run: docker logs -f $(CONTAINER_NAME) .PHONY: clean -clean: +clean: gen-requirements docker stop $(CONTAINER_NAME) || true docker rm $(CONTAINER_NAME) || true diff --git a/opentrons-ai-server/Pipfile b/opentrons-ai-server/Pipfile index a6ee65a0160..34b0b8d32dd 100644 --- a/opentrons-ai-server/Pipfile +++ b/opentrons-ai-server/Pipfile @@ -13,9 +13,10 @@ fastapi = "==0.111.0" ddtrace = "==2.9.2" pydantic-settings = "==2.3.4" pyjwt = {extras = ["crypto"], version = "*"} -python-json-logger = "==2.0.7" beautifulsoup4 = "==4.12.3" markdownify = "==0.13.1" +structlog = "==24.4.0" +asgi-correlation-id = "==4.3.3" [dev-packages] docker = "==7.1.0" diff --git a/opentrons-ai-server/Pipfile.lock b/opentrons-ai-server/Pipfile.lock index 8861f152e8e..55811db04cf 100644 --- a/opentrons-ai-server/Pipfile.lock +++ b/opentrons-ai-server/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7526bc1898bd03e19a277baf44f56d6f1287870288af558c8d8b719118af3389" + "sha256": "20b9e324d809f68cb0465d5e3d98467ceb5860f583fddc347ade1e5ad6a3b6ab" }, "pipfile-spec": 6, "requires": { @@ -18,108 +18,108 @@ "default": { "aiohappyeyeballs": { "hashes": [ - "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2", - "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd" + "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586", + "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572" ], "markers": "python_version >= '3.8'", - "version": "==2.4.0" + "version": "==2.4.3" }, "aiohttp": { "hashes": [ - "sha256:02108326574ff60267b7b35b17ac5c0bbd0008ccb942ce4c48b657bb90f0b8aa", - "sha256:029a019627b37fa9eac5c75cc54a6bb722c4ebbf5a54d8c8c0fb4dd8facf2702", - "sha256:03fa40d1450ee5196e843315ddf74a51afc7e83d489dbfc380eecefea74158b1", - "sha256:0749c4d5a08a802dd66ecdf59b2df4d76b900004017468a7bb736c3b5a3dd902", - "sha256:0754690a3a26e819173a34093798c155bafb21c3c640bff13be1afa1e9d421f9", - "sha256:0a75d5c9fb4f06c41d029ae70ad943c3a844c40c0a769d12be4b99b04f473d3d", - "sha256:0b82c8ebed66ce182893e7c0b6b60ba2ace45b1df104feb52380edae266a4850", - "sha256:0be3115753baf8b4153e64f9aa7bf6c0c64af57979aa900c31f496301b374570", - "sha256:14477c4e52e2f17437b99893fd220ffe7d7ee41df5ebf931a92b8ca82e6fd094", - "sha256:164ecd32e65467d86843dbb121a6666c3deb23b460e3f8aefdcaacae79eb718a", - "sha256:1cb045ec5961f51af3e2c08cd6fe523f07cc6e345033adee711c49b7b91bb954", - "sha256:1e52e59ed5f4cc3a3acfe2a610f8891f216f486de54d95d6600a2c9ba1581f4d", - "sha256:217791c6a399cc4f2e6577bb44344cba1f5714a2aebf6a0bea04cfa956658284", - "sha256:25d92f794f1332f656e3765841fc2b7ad5c26c3f3d01e8949eeb3495691cf9f4", - "sha256:2708baccdc62f4b1251e59c2aac725936a900081f079b88843dabcab0feeeb27", - "sha256:27cf19a38506e2e9f12fc17e55f118f04897b0a78537055d93a9de4bf3022e3d", - "sha256:289fa8a20018d0d5aa9e4b35d899bd51bcb80f0d5f365d9a23e30dac3b79159b", - "sha256:2cd5290ab66cfca2f90045db2cc6434c1f4f9fbf97c9f1c316e785033782e7d2", - "sha256:2dd56e3c43660ed3bea67fd4c5025f1ac1f9ecf6f0b991a6e5efe2e678c490c5", - "sha256:3427031064b0d5c95647e6369c4aa3c556402f324a3e18107cb09517abe5f962", - "sha256:3468b39f977a11271517c6925b226720e148311039a380cc9117b1e2258a721f", - "sha256:370e2d47575c53c817ee42a18acc34aad8da4dbdaac0a6c836d58878955f1477", - "sha256:3d2665c5df629eb2f981dab244c01bfa6cdc185f4ffa026639286c4d56fafb54", - "sha256:3e15e33bfc73fa97c228f72e05e8795e163a693fd5323549f49367c76a6e5883", - "sha256:3fb4216e3ec0dbc01db5ba802f02ed78ad8f07121be54eb9e918448cc3f61b7c", - "sha256:40271a2a375812967401c9ca8077de9368e09a43a964f4dce0ff603301ec9358", - "sha256:438c5863feb761f7ca3270d48c292c334814459f61cc12bab5ba5b702d7c9e56", - "sha256:4407a80bca3e694f2d2a523058e20e1f9f98a416619e04f6dc09dc910352ac8b", - "sha256:444d1704e2af6b30766debed9be8a795958029e552fe77551355badb1944012c", - "sha256:4611db8c907f90fe86be112efdc2398cd7b4c8eeded5a4f0314b70fdea8feab0", - "sha256:473961b3252f3b949bb84873d6e268fb6d8aa0ccc6eb7404fa58c76a326bb8e1", - "sha256:4752df44df48fd42b80f51d6a97553b482cda1274d9dc5df214a3a1aa5d8f018", - "sha256:47647c8af04a70e07a2462931b0eba63146a13affa697afb4ecbab9d03a480ce", - "sha256:482f74057ea13d387a7549d7a7ecb60e45146d15f3e58a2d93a0ad2d5a8457cd", - "sha256:4bef1480ee50f75abcfcb4b11c12de1005968ca9d0172aec4a5057ba9f2b644f", - "sha256:4fabdcdc781a36b8fd7b2ca9dea8172f29a99e11d00ca0f83ffeb50958da84a1", - "sha256:5582de171f0898139cf51dd9fcdc79b848e28d9abd68e837f0803fc9f30807b1", - "sha256:58c5d7318a136a3874c78717dd6de57519bc64f6363c5827c2b1cb775bea71dd", - "sha256:5db26bbca8e7968c4c977a0c640e0b9ce7224e1f4dcafa57870dc6ee28e27de6", - "sha256:614fc21e86adc28e4165a6391f851a6da6e9cbd7bb232d0df7718b453a89ee98", - "sha256:6419728b08fb6380c66a470d2319cafcec554c81780e2114b7e150329b9a9a7f", - "sha256:669c0efe7e99f6d94d63274c06344bd0e9c8daf184ce5602a29bc39e00a18720", - "sha256:66bc81361131763660b969132a22edce2c4d184978ba39614e8f8f95db5c95f8", - "sha256:671745ea7db19693ce867359d503772177f0b20fa8f6ee1e74e00449f4c4151d", - "sha256:682836fc672972cc3101cc9e30d49c5f7e8f1d010478d46119fe725a4545acfd", - "sha256:6a504d7cdb431a777d05a124fd0b21efb94498efa743103ea01b1e3136d2e4fb", - "sha256:6a86610174de8a85a920e956e2d4f9945e7da89f29a00e95ac62a4a414c4ef4e", - "sha256:6b50b367308ca8c12e0b50cba5773bc9abe64c428d3fd2bbf5cd25aab37c77bf", - "sha256:7475da7a5e2ccf1a1c86c8fee241e277f4874c96564d06f726d8df8e77683ef7", - "sha256:7641920bdcc7cd2d3ddfb8bb9133a6c9536b09dbd49490b79e125180b2d25b93", - "sha256:79a9f42efcc2681790595ab3d03c0e52d01edc23a0973ea09f0dc8d295e12b8e", - "sha256:7ea35d849cdd4a9268f910bff4497baebbc1aa3f2f625fd8ccd9ac99c860c621", - "sha256:8198b7c002aae2b40b2d16bfe724b9a90bcbc9b78b2566fc96131ef4e382574d", - "sha256:81b292f37969f9cc54f4643f0be7dacabf3612b3b4a65413661cf6c350226787", - "sha256:844d48ff9173d0b941abed8b2ea6a412f82b56d9ab1edb918c74000c15839362", - "sha256:8617c96a20dd57e7e9d398ff9d04f3d11c4d28b1767273a5b1a018ada5a654d3", - "sha256:8a637d387db6fdad95e293fab5433b775fd104ae6348d2388beaaa60d08b38c4", - "sha256:92351aa5363fc3c1f872ca763f86730ced32b01607f0c9662b1fa711087968d0", - "sha256:9843d683b8756971797be171ead21511d2215a2d6e3c899c6e3107fbbe826791", - "sha256:995ab1a238fd0d19dc65f2d222e5eb064e409665c6426a3e51d5101c1979ee84", - "sha256:9bd6b2033993d5ae80883bb29b83fb2b432270bbe067c2f53cc73bb57c46065f", - "sha256:9d26da22a793dfd424be1050712a70c0afd96345245c29aced1e35dbace03413", - "sha256:a976ef488f26e224079deb3d424f29144c6d5ba4ded313198169a8af8f47fb82", - "sha256:a9f196c970db2dcde4f24317e06615363349dc357cf4d7a3b0716c20ac6d7bcd", - "sha256:b169f8e755e541b72e714b89a831b315bbe70db44e33fead28516c9e13d5f931", - "sha256:b504c08c45623bf5c7ca41be380156d925f00199b3970efd758aef4a77645feb", - "sha256:ba18573bb1de1063d222f41de64a0d3741223982dcea863b3f74646faf618ec7", - "sha256:ba3662d41abe2eab0eeec7ee56f33ef4e0b34858f38abf24377687f9e1fb00a5", - "sha256:bd294dcdc1afdc510bb51d35444003f14e327572877d016d576ac3b9a5888a27", - "sha256:bdbeff1b062751c2a2a55b171f7050fb7073633c699299d042e962aacdbe1a07", - "sha256:bf861da9a43d282d6dd9dcd64c23a0fccf2c5aa5cd7c32024513c8c79fb69de3", - "sha256:c82a94ddec996413a905f622f3da02c4359952aab8d817c01cf9915419525e95", - "sha256:c91781d969fbced1993537f45efe1213bd6fccb4b37bfae2a026e20d6fbed206", - "sha256:c9721cdd83a994225352ca84cd537760d41a9da3c0eacb3ff534747ab8fba6d0", - "sha256:cca776a440795db437d82c07455761c85bbcf3956221c3c23b8c93176c278ce7", - "sha256:cf8b8560aa965f87bf9c13bf9fed7025993a155ca0ce8422da74bf46d18c2f5f", - "sha256:d2578ef941be0c2ba58f6f421a703527d08427237ed45ecb091fed6f83305336", - "sha256:d2b3935a22c9e41a8000d90588bed96cf395ef572dbb409be44c6219c61d900d", - "sha256:d4dfa5ad4bce9ca30a76117fbaa1c1decf41ebb6c18a4e098df44298941566f9", - "sha256:d7f408c43f5e75ea1edc152fb375e8f46ef916f545fb66d4aebcbcfad05e2796", - "sha256:dc1a16f3fc1944c61290d33c88dc3f09ba62d159b284c38c5331868425aca426", - "sha256:e0009258e97502936d3bd5bf2ced15769629097d0abb81e6495fba1047824fe0", - "sha256:e05b39158f2af0e2438cc2075cfc271f4ace0c3cc4a81ec95b27a0432e161951", - "sha256:e1f80cd17d81a404b6e70ef22bfe1870bafc511728397634ad5f5efc8698df56", - "sha256:e2e7d5591ea868d5ec82b90bbeb366a198715672841d46281b623e23079593db", - "sha256:f3af26f86863fad12e25395805bb0babbd49d512806af91ec9708a272b696248", - "sha256:f52e54fd776ad0da1006708762213b079b154644db54bcfc62f06eaa5b896402", - "sha256:f8b8e49fe02f744d38352daca1dbef462c3874900bd8166516f6ea8e82b5aacf", - "sha256:fb138fbf9f53928e779650f5ed26d0ea1ed8b2cab67f0ea5d63afa09fdc07593", - "sha256:fe517113fe4d35d9072b826c3e147d63c5f808ca8167d450b4f96c520c8a1d8d", - "sha256:ff99ae06eef85c7a565854826114ced72765832ee16c7e3e766c5e4c5b98d20e" - ], - "markers": "python_version >= '3.8'", - "version": "==3.10.6" + "sha256:02d1d6610588bcd743fae827bd6f2e47e0d09b346f230824b4c6fb85c6065f9c", + "sha256:03690541e4cc866eef79626cfa1ef4dd729c5c1408600c8cb9e12e1137eed6ab", + "sha256:0bc059ecbce835630e635879f5f480a742e130d9821fbe3d2f76610a6698ee25", + "sha256:0c21c82df33b264216abffff9f8370f303dab65d8eee3767efbbd2734363f677", + "sha256:1298b854fd31d0567cbb916091be9d3278168064fca88e70b8468875ef9ff7e7", + "sha256:1321658f12b6caffafdc35cfba6c882cb014af86bef4e78c125e7e794dfb927b", + "sha256:143b0026a9dab07a05ad2dd9e46aa859bffdd6348ddc5967b42161168c24f857", + "sha256:16e6a51d8bc96b77f04a6764b4ad03eeef43baa32014fce71e882bd71302c7e4", + "sha256:172ad884bb61ad31ed7beed8be776eb17e7fb423f1c1be836d5cb357a096bf12", + "sha256:17c272cfe7b07a5bb0c6ad3f234e0c336fb53f3bf17840f66bd77b5815ab3d16", + "sha256:1a0ee6c0d590c917f1b9629371fce5f3d3f22c317aa96fbdcce3260754d7ea21", + "sha256:2746d8994ebca1bdc55a1e998feff4e94222da709623bb18f6e5cfec8ec01baf", + "sha256:2914caa46054f3b5ff910468d686742ff8cff54b8a67319d75f5d5945fd0a13d", + "sha256:2bbf94d4a0447705b7775417ca8bb8086cc5482023a6e17cdc8f96d0b1b5aba6", + "sha256:2bd9f3eac515c16c4360a6a00c38119333901b8590fe93c3257a9b536026594d", + "sha256:2c33fa6e10bb7ed262e3ff03cc69d52869514f16558db0626a7c5c61dde3c29f", + "sha256:2d37f4718002863b82c6f391c8efd4d3a817da37030a29e2682a94d2716209de", + "sha256:3668d0c2a4d23fb136a753eba42caa2c0abbd3d9c5c87ee150a716a16c6deec1", + "sha256:36d4fba838be5f083f5490ddd281813b44d69685db910907636bc5dca6322316", + "sha256:40ff5b7660f903dc587ed36ef08a88d46840182d9d4b5694e7607877ced698a1", + "sha256:42775de0ca04f90c10c5c46291535ec08e9bcc4756f1b48f02a0657febe89b10", + "sha256:482c85cf3d429844396d939b22bc2a03849cb9ad33344689ad1c85697bcba33a", + "sha256:4e6cb75f8ddd9c2132d00bc03c9716add57f4beff1263463724f6398b813e7eb", + "sha256:4edc3fd701e2b9a0d605a7b23d3de4ad23137d23fc0dbab726aa71d92f11aaaf", + "sha256:4fd16b30567c5b8e167923be6e027eeae0f20cf2b8a26b98a25115f28ad48ee0", + "sha256:5002a02c17fcfd796d20bac719981d2fca9c006aac0797eb8f430a58e9d12431", + "sha256:51d0a4901b27272ae54e42067bc4b9a90e619a690b4dc43ea5950eb3070afc32", + "sha256:558b3d223fd631ad134d89adea876e7fdb4c93c849ef195049c063ada82b7d08", + "sha256:5c070430fda1a550a1c3a4c2d7281d3b8cfc0c6715f616e40e3332201a253067", + "sha256:5f392ef50e22c31fa49b5a46af7f983fa3f118f3eccb8522063bee8bfa6755f8", + "sha256:60555211a006d26e1a389222e3fab8cd379f28e0fbf7472ee55b16c6c529e3a6", + "sha256:608cecd8d58d285bfd52dbca5b6251ca8d6ea567022c8a0eaae03c2589cd9af9", + "sha256:60ad5b8a7452c0f5645c73d4dad7490afd6119d453d302cd5b72b678a85d6044", + "sha256:63649309da83277f06a15bbdc2a54fbe75efb92caa2c25bb57ca37762789c746", + "sha256:6ebdc3b3714afe1b134b3bbeb5f745eed3ecbcff92ab25d80e4ef299e83a5465", + "sha256:6f3c6648aa123bcd73d6f26607d59967b607b0da8ffcc27d418a4b59f4c98c7c", + "sha256:7003f33f5f7da1eb02f0446b0f8d2ccf57d253ca6c2e7a5732d25889da82b517", + "sha256:776e9f3c9b377fcf097c4a04b241b15691e6662d850168642ff976780609303c", + "sha256:85711eec2d875cd88c7eb40e734c4ca6d9ae477d6f26bd2b5bb4f7f60e41b156", + "sha256:87d1e4185c5d7187684d41ebb50c9aeaaaa06ca1875f4c57593071b0409d2444", + "sha256:8a3f063b41cc06e8d0b3fcbbfc9c05b7420f41287e0cd4f75ce0a1f3d80729e6", + "sha256:8b3fb28a9ac8f2558760d8e637dbf27aef1e8b7f1d221e8669a1074d1a266bb2", + "sha256:8bd9125dd0cc8ebd84bff2be64b10fdba7dc6fd7be431b5eaf67723557de3a31", + "sha256:8be1a65487bdfc285bd5e9baf3208c2132ca92a9b4020e9f27df1b16fab998a9", + "sha256:8cc0d13b4e3b1362d424ce3f4e8c79e1f7247a00d792823ffd640878abf28e56", + "sha256:8d9d10d10ec27c0d46ddaecc3c5598c4db9ce4e6398ca872cdde0525765caa2f", + "sha256:8debb45545ad95b58cc16c3c1cc19ad82cffcb106db12b437885dbee265f0ab5", + "sha256:91aa966858593f64c8a65cdefa3d6dc8fe3c2768b159da84c1ddbbb2c01ab4ef", + "sha256:9331dd34145ff105177855017920dde140b447049cd62bb589de320fd6ddd582", + "sha256:99f9678bf0e2b1b695e8028fedac24ab6770937932eda695815d5a6618c37e04", + "sha256:9fdf5c839bf95fc67be5794c780419edb0dbef776edcfc6c2e5e2ffd5ee755fa", + "sha256:a14e4b672c257a6b94fe934ee62666bacbc8e45b7876f9dd9502d0f0fe69db16", + "sha256:a19caae0d670771ea7854ca30df76f676eb47e0fd9b2ee4392d44708f272122d", + "sha256:a35ed3d03910785f7d9d6f5381f0c24002b2b888b298e6f941b2fc94c5055fcd", + "sha256:a61df62966ce6507aafab24e124e0c3a1cfbe23c59732987fc0fd0d71daa0b88", + "sha256:a6e00c8a92e7663ed2be6fcc08a2997ff06ce73c8080cd0df10cc0321a3168d7", + "sha256:ac3196952c673822ebed8871cf8802e17254fff2a2ed4835d9c045d9b88c5ec7", + "sha256:ac74e794e3aee92ae8f571bfeaa103a141e409863a100ab63a253b1c53b707eb", + "sha256:ad3675c126f2a95bde637d162f8231cff6bc0bc9fbe31bd78075f9ff7921e322", + "sha256:aeebd3061f6f1747c011e1d0b0b5f04f9f54ad1a2ca183e687e7277bef2e0da2", + "sha256:ba1a599255ad6a41022e261e31bc2f6f9355a419575b391f9655c4d9e5df5ff5", + "sha256:bbdb8def5268f3f9cd753a265756f49228a20ed14a480d151df727808b4531dd", + "sha256:c2555e4949c8d8782f18ef20e9d39730d2656e218a6f1a21a4c4c0b56546a02e", + "sha256:c2695c61cf53a5d4345a43d689f37fc0f6d3a2dc520660aec27ec0f06288d1f9", + "sha256:c2b627d3c8982691b06d89d31093cee158c30629fdfebe705a91814d49b554f8", + "sha256:c46131c6112b534b178d4e002abe450a0a29840b61413ac25243f1291613806a", + "sha256:c54dc329cd44f7f7883a9f4baaefe686e8b9662e2c6c184ea15cceee587d8d69", + "sha256:c7d7cafc11d70fdd8801abfc2ff276744ae4cb39d8060b6b542c7e44e5f2cfc2", + "sha256:cb0b2d5d51f96b6cc19e6ab46a7b684be23240426ae951dcdac9639ab111b45e", + "sha256:d15a29424e96fad56dc2f3abed10a89c50c099f97d2416520c7a543e8fddf066", + "sha256:d1f5c9169e26db6a61276008582d945405b8316aae2bb198220466e68114a0f5", + "sha256:d271f770b52e32236d945911b2082f9318e90ff835d45224fa9e28374303f729", + "sha256:d646fdd74c25bbdd4a055414f0fe32896c400f38ffbdfc78c68e62812a9e0257", + "sha256:d6e395c3d1f773cf0651cd3559e25182eb0c03a2777b53b4575d8adc1149c6e9", + "sha256:d7c071235a47d407b0e93aa6262b49422dbe48d7d8566e1158fecc91043dd948", + "sha256:d97273a52d7f89a75b11ec386f786d3da7723d7efae3034b4dda79f6f093edc1", + "sha256:dcf354661f54e6a49193d0b5653a1b011ba856e0b7a76bda2c33e4c6892f34ea", + "sha256:e3e7fabedb3fe06933f47f1538df7b3a8d78e13d7167195f51ca47ee12690373", + "sha256:e525b69ee8a92c146ae5b4da9ecd15e518df4d40003b01b454ad694a27f498b5", + "sha256:e709d6ac598c5416f879bb1bae3fd751366120ac3fa235a01de763537385d036", + "sha256:e83dfefb4f7d285c2d6a07a22268344a97d61579b3e0dce482a5be0251d672ab", + "sha256:e86260b76786c28acf0b5fe31c8dca4c2add95098c709b11e8c35b424ebd4f5b", + "sha256:e883b61b75ca6efc2541fcd52a5c8ccfe288b24d97e20ac08fdf343b8ac672ea", + "sha256:f0a44bb40b6aaa4fb9a5c1ee07880570ecda2065433a96ccff409c9c20c1624a", + "sha256:f82ace0ec57c94aaf5b0e118d4366cff5889097412c75aa14b4fd5fc0c44ee3e", + "sha256:f9ca09414003c0e96a735daa1f071f7d7ed06962ef4fa29ceb6c80d06696d900", + "sha256:fa430b871220dc62572cef9c69b41e0d70fcb9d486a4a207a5de4c1f25d82593", + "sha256:fc262c3df78c8ff6020c782d9ce02e4bcffe4900ad71c0ecdad59943cba54442", + "sha256:fcd546782d03181b0b1d20b43d612429a90a68779659ba8045114b867971ab71", + "sha256:fd4ceeae2fb8cabdd1b71c82bfdd39662473d3433ec95b962200e9e752fb70d0", + "sha256:fec5fac7aea6c060f317f07494961236434928e6f4374e170ef50b3001e14581" + ], + "markers": "python_version >= '3.8'", + "version": "==3.10.9" }, "aiosignal": { "hashes": [ @@ -145,6 +145,15 @@ "markers": "python_version >= '3.9'", "version": "==4.6.0" }, + "asgi-correlation-id": { + "hashes": [ + "sha256:25d89b52f3d32c0f3b4915a9fc38d9cffc7395960d05910bdce5c13023dc237b", + "sha256:62ba38c359aa004c1c3e2b8e0cdf0e8ad4aa5a93864eaadc46e77d5c142a206a" + ], + "index": "pypi", + "markers": "python_version >= '3.8' and python_version < '4.0'", + "version": "==4.3.3" + }, "attrs": { "hashes": [ "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", @@ -503,11 +512,11 @@ }, "dnspython": { "hashes": [ - "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50", - "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc" + "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", + "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1" ], - "markers": "python_version >= '3.8'", - "version": "==2.6.1" + "markers": "python_version >= '3.9'", + "version": "==2.7.0" }, "email-validator": { "hashes": [ @@ -722,11 +731,11 @@ }, "httpcore": { "hashes": [ - "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", - "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5" + "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", + "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f" ], "markers": "python_version >= '3.8'", - "version": "==1.0.5" + "version": "==1.0.6" }, "httptools": { "hashes": [ @@ -949,69 +958,70 @@ }, "markupsafe": { "hashes": [ - "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", - "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", - "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", - "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", - "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", - "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", - "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", - "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", - "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", - "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", - "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", - "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", - "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", - "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", - "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", - "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", - "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", - "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", - "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", - "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", - "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", - "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", - "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", - "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", - "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", - "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", - "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", - "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", - "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", - "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", - "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", - "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", - "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", - "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", - "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", - "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", - "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", - "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", - "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", - "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", - "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", - "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", - "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", - "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", - "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", - "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", - "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", - "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", - "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", - "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", - "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", - "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", - "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", - "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", - "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", - "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", - "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", - "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", - "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", - "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396", + "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38", + "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a", + "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8", + "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b", + "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad", + "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a", + "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a", + "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da", + "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6", + "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8", + "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344", + "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a", + "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8", + "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5", + "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7", + "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170", + "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132", + "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9", + "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd", + "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9", + "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346", + "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc", + "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589", + "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5", + "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915", + "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295", + "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453", + "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea", + "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b", + "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d", + "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b", + "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4", + "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b", + "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7", + "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf", + "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f", + "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91", + "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd", + "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50", + "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b", + "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583", + "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a", + "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984", + "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c", + "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c", + "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25", + "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa", + "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4", + "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3", + "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97", + "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1", + "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd", + "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772", + "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a", + "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729", + "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca", + "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6", + "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635", + "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b", + "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f" ], - "markers": "python_version >= '3.7'", - "version": "==2.1.5" + "markers": "python_version >= '3.9'", + "version": "==3.0.1" }, "marshmallow": { "hashes": [ @@ -1423,6 +1433,110 @@ "markers": "python_version >= '3.8'", "version": "==10.4.0" }, + "propcache": { + "hashes": [ + "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9", + "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", + "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", + "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb", + "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b", + "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09", + "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957", + "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68", + "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f", + "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798", + "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418", + "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6", + "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162", + "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", + "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", + "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8", + "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2", + "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110", + "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23", + "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8", + "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638", + "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a", + "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", + "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2", + "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", + "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850", + "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", + "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", + "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887", + "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89", + "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", + "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348", + "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4", + "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861", + "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e", + "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c", + "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b", + "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", + "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1", + "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", + "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", + "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563", + "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5", + "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", + "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9", + "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12", + "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4", + "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5", + "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71", + "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9", + "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed", + "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336", + "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90", + "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063", + "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad", + "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6", + "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8", + "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", + "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2", + "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7", + "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", + "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d", + "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df", + "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b", + "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178", + "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2", + "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630", + "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48", + "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61", + "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89", + "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb", + "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3", + "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6", + "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562", + "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b", + "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58", + "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db", + "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99", + "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37", + "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", + "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", + "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d", + "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04", + "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", + "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", + "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394", + "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea", + "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", + "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1", + "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793", + "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577", + "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7", + "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57", + "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d", + "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", + "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d", + "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016", + "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504" + ], + "markers": "python_version >= '3.8'", + "version": "==0.2.0" + }, "protobuf": { "hashes": [ "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132", @@ -1595,22 +1709,13 @@ "markers": "python_version >= '3.8'", "version": "==1.0.1" }, - "python-json-logger": { - "hashes": [ - "sha256:23e7ec02d34237c5aa1e29a070193a4ea87583bb4e7f8fd06d3de8264c4b2e1c", - "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd" - ], - "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==2.0.7" - }, "python-multipart": { "hashes": [ - "sha256:2b06ad9e8d50c7a8db80e3b56dab590137b323410605af2be20d62a5f1ba1dc8", - "sha256:46eb3c6ce6fdda5fb1a03c7e11d490e407c6930a2703fe7aef4da71c374688fa" + "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb", + "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf" ], "markers": "python_version >= '3.8'", - "version": "==0.0.10" + "version": "==0.0.12" }, "pytz": { "hashes": [ @@ -1788,11 +1893,11 @@ }, "rich": { "hashes": [ - "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06", - "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a" + "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", + "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==13.8.1" + "markers": "python_full_version >= '3.8.0'", + "version": "==13.9.2" }, "setuptools": { "hashes": [ @@ -1907,6 +2012,15 @@ ], "version": "==0.0.26" }, + "structlog": { + "hashes": [ + "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610", + "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==24.4.0" + }, "tenacity": { "hashes": [ "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", @@ -1917,45 +2031,40 @@ }, "tiktoken": { "hashes": [ - "sha256:03c6c40ff1db0f48a7b4d2dafeae73a5607aacb472fa11f125e7baf9dce73704", - "sha256:084cec29713bc9d4189a937f8a35dbdfa785bd1235a34c1124fe2323821ee93f", - "sha256:09ed925bccaa8043e34c519fbb2f99110bd07c6fd67714793c21ac298e449410", - "sha256:0bc603c30b9e371e7c4c7935aba02af5994a909fc3c0fe66e7004070858d3f8f", - "sha256:1063c5748be36344c7e18c7913c53e2cca116764c2080177e57d62c7ad4576d1", - "sha256:1077266e949c24e0291f6c350433c6f0971365ece2b173a23bc3b9f9defef6b6", - "sha256:10c7674f81e6e350fcbed7c09a65bca9356eaab27fb2dac65a1e440f2bcfe30f", - "sha256:131b8aeb043a8f112aad9f46011dced25d62629091e51d9dc1adbf4a1cc6aa98", - "sha256:13c94efacdd3de9aff824a788353aa5749c0faee1fbe3816df365ea450b82311", - "sha256:20295d21419bfcca092644f7e2f2138ff947a6eb8cfc732c09cc7d76988d4a89", - "sha256:21a20c3bd1dd3e55b91c1331bf25f4af522c525e771691adbc9a69336fa7f702", - "sha256:2398fecd38c921bcd68418675a6d155fad5f5e14c2e92fcf5fe566fa5485a858", - "sha256:2bcb28ddf79ffa424f171dfeef9a4daff61a94c631ca6813f43967cb263b83b9", - "sha256:2ee92776fdbb3efa02a83f968c19d4997a55c8e9ce7be821ceee04a1d1ee149c", - "sha256:485f3cc6aba7c6b6ce388ba634fbba656d9ee27f766216f45146beb4ac18b25f", - "sha256:54031f95c6939f6b78122c0aa03a93273a96365103793a22e1793ee86da31685", - "sha256:5d4511c52caacf3c4981d1ae2df85908bd31853f33d30b345c8b6830763f769c", - "sha256:71c55d066388c55a9c00f61d2c456a6086673ab7dec22dd739c23f77195b1908", - "sha256:79383a6e2c654c6040e5f8506f3750db9ddd71b550c724e673203b4f6b4b4590", - "sha256:811229fde1652fedcca7c6dfe76724d0908775b353556d8a71ed74d866f73f7b", - "sha256:861f9ee616766d736be4147abac500732b505bf7013cfaf019b85892637f235e", - "sha256:86b6e7dc2e7ad1b3757e8a24597415bafcfb454cebf9a33a01f2e6ba2e663992", - "sha256:8a81bac94769cab437dd3ab0b8a4bc4e0f9cf6835bcaa88de71f39af1791727a", - "sha256:8c46d7af7b8c6987fac9b9f61041b452afe92eb087d29c9ce54951280f899a97", - "sha256:8d57f29171255f74c0aeacd0651e29aa47dff6f070cb9f35ebc14c82278f3b25", - "sha256:8e58c7eb29d2ab35a7a8929cbeea60216a4ccdf42efa8974d8e176d50c9a3df5", - "sha256:8f5f6afb52fb8a7ea1c811e435e4188f2bef81b5e0f7a8635cc79b0eef0193d6", - "sha256:959d993749b083acc57a317cbc643fb85c014d055b2119b739487288f4e5d1cb", - "sha256:c72baaeaefa03ff9ba9688624143c858d1f6b755bb85d456d59e529e17234769", - "sha256:cabc6dc77460df44ec5b879e68692c63551ae4fae7460dd4ff17181df75f1db7", - "sha256:d20b5c6af30e621b4aca094ee61777a44118f52d886dbe4f02b70dfe05c15350", - "sha256:d427614c3e074004efa2f2411e16c826f9df427d3c70a54725cae860f09e4bf4", - "sha256:d6d73ea93e91d5ca771256dfc9d1d29f5a554b83821a1dc0891987636e0ae226", - "sha256:e215292e99cb41fbc96988ef62ea63bb0ce1e15f2c147a61acc319f8b4cbe5bf", - "sha256:e54be9a2cd2f6d6ffa3517b064983fb695c9a9d8aa7d574d1ef3c3f931a99225", - "sha256:fffdcb319b614cf14f04d02a52e26b1d1ae14a570f90e9b55461a72672f7b13d" + "sha256:02be1666096aff7da6cbd7cdaa8e7917bfed3467cd64b38b1f112e96d3b06a24", + "sha256:1473cfe584252dc3fa62adceb5b1c763c1874e04511b197da4e6de51d6ce5a02", + "sha256:18228d624807d66c87acd8f25fc135665617cab220671eb65b50f5d70fa51f69", + "sha256:25e13f37bc4ef2d012731e93e0fef21dc3b7aea5bb9009618de9a4026844e560", + "sha256:294440d21a2a51e12d4238e68a5972095534fe9878be57d905c476017bff99fc", + "sha256:2efaf6199717b4485031b4d6edb94075e4d79177a172f38dd934d911b588d54a", + "sha256:326624128590def898775b722ccc327e90b073714227175ea8febbc920ac0a99", + "sha256:4177faa809bd55f699e88c96d9bb4635d22e3f59d635ba6fd9ffedf7150b9953", + "sha256:5376b6f8dc4753cd81ead935c5f518fa0fbe7e133d9e25f648d8c4dabdd4bad7", + "sha256:5637e425ce1fc49cf716d88df3092048359a4b3bbb7da762840426e937ada06d", + "sha256:56edfefe896c8f10aba372ab5706b9e3558e78db39dd497c940b47bf228bc419", + "sha256:6adc8323016d7758d6de7313527f755b0fc6c72985b7d9291be5d96d73ecd1e1", + "sha256:6b231f5e8982c245ee3065cd84a4712d64692348bc609d84467c57b4b72dcbc5", + "sha256:6b2ddbc79a22621ce8b1166afa9f9a888a664a579350dc7c09346a3b5de837d9", + "sha256:7e17807445f0cf1f25771c9d86496bd8b5c376f7419912519699f3cc4dc5c12e", + "sha256:845287b9798e476b4d762c3ebda5102be87ca26e5d2c9854002825d60cdb815d", + "sha256:881839cfeae051b3628d9823b2e56b5cc93a9e2efb435f4cf15f17dc45f21586", + "sha256:886f80bd339578bbdba6ed6d0567a0d5c6cfe198d9e587ba6c447654c65b8edc", + "sha256:9269348cb650726f44dd3bbb3f9110ac19a8dcc8f54949ad3ef652ca22a38e21", + "sha256:9a58deb7075d5b69237a3ff4bb51a726670419db6ea62bdcd8bd80c78497d7ab", + "sha256:9ccbb2740f24542534369c5635cfd9b2b3c2490754a78ac8831d99f89f94eeb2", + "sha256:9fb0e352d1dbe15aba082883058b3cce9e48d33101bdaac1eccf66424feb5b47", + "sha256:b07e33283463089c81ef1467180e3e00ab00d46c2c4bbcef0acab5f771d6695e", + "sha256:b591fb2b30d6a72121a80be24ec7a0e9eb51c5500ddc7e4c2496516dd5e3816b", + "sha256:c94ff53c5c74b535b2cbf431d907fc13c678bbd009ee633a2aca269a04389f9a", + "sha256:d2908c0d043a7d03ebd80347266b0e58440bdef5564f84f4d29fb235b5df3b04", + "sha256:d622d8011e6d6f239297efa42a2657043aaed06c4f68833550cac9e9bc723ef1", + "sha256:d8c2d0e5ba6453a290b86cd65fc51fedf247e1ba170191715b049dac1f628005", + "sha256:d8f3192733ac4d77977432947d563d7e1b310b96497acd3c196c9bddb36ed9db", + "sha256:f13d13c981511331eac0d01a59b5df7c0d4060a8be1e378672822213da51e0a2", + "sha256:fe9399bdc3f29d428f16a2f86c3c8ec20be3eac5f53693ce4980371c3245729b" ], - "markers": "python_version >= '3.8'", - "version": "==0.7.0" + "markers": "python_version >= '3.9'", + "version": "==0.8.0" }, "tqdm": { "hashes": [ @@ -2093,11 +2202,11 @@ "standard" ], "hashes": [ - "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788", - "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5" + "sha256:13bc21373d103859f68fe739608e2eb054a816dea79189bc3ca08ea89a275906", + "sha256:cac7be4dd4d891c363cd942160a7b02e69150dcbc7a36be04d5f4af4b17c8ced" ], "markers": "python_version >= '3.8'", - "version": "==0.30.6" + "version": "==0.31.0" }, "uvloop": { "hashes": [ @@ -2400,101 +2509,101 @@ }, "yarl": { "hashes": [ - "sha256:0103c52f8dfe5d573c856322149ddcd6d28f51b4d4a3ee5c4b3c1b0a05c3d034", - "sha256:01549468858b87d36f967c97d02e6e54106f444aeb947ed76f8f71f85ed07cec", - "sha256:0274b1b7a9c9c32b7bf250583e673ff99fb9fccb389215841e2652d9982de740", - "sha256:0ac33d22b2604b020569a82d5f8a03ba637ba42cc1adf31f616af70baf81710b", - "sha256:0d0a5e87bc48d76dfcfc16295201e9812d5f33d55b4a0b7cad1025b92bf8b91b", - "sha256:10b690cd78cbaca2f96a7462f303fdd2b596d3978b49892e4b05a7567c591572", - "sha256:126309c0f52a2219b3d1048aca00766429a1346596b186d51d9fa5d2070b7b13", - "sha256:15871130439ad10abb25a4631120d60391aa762b85fcab971411e556247210a0", - "sha256:17d4dc4ff47893a06737b8788ed2ba2f5ac4e8bb40281c8603920f7d011d5bdd", - "sha256:18c2a7757561f05439c243f517dbbb174cadfae3a72dee4ae7c693f5b336570f", - "sha256:1d4017e78fb22bc797c089b746230ad78ecd3cdb215bc0bd61cb72b5867da57e", - "sha256:1f50a37aeeb5179d293465e522fd686080928c4d89e0ff215e1f963405ec4def", - "sha256:20d817c0893191b2ab0ba30b45b77761e8dfec30a029b7c7063055ca71157f84", - "sha256:22839d1d1eab9e4b427828a88a22beb86f67c14d8ff81175505f1cc8493f3500", - "sha256:22dda2799c8d39041d731e02bf7690f0ef34f1691d9ac9dfcb98dd1e94c8b058", - "sha256:2376d8cf506dffd0e5f2391025ae8675b09711016656590cb03b55894161fcfa", - "sha256:24197ba3114cc85ddd4091e19b2ddc62650f2e4a899e51b074dfd52d56cf8c72", - "sha256:24416bb5e221e29ddf8aac5b97e94e635ca2c5be44a1617ad6fe32556df44294", - "sha256:2631c9d7386bd2d4ce24ecc6ebf9ae90b3efd713d588d90504eaa77fec4dba01", - "sha256:28389a68981676bf74e2e199fe42f35d1aa27a9c98e3a03e6f58d2d3d054afe1", - "sha256:2aee7594d2c2221c717a8e394bbed4740029df4c0211ceb0f04815686e99c795", - "sha256:2e430ac432f969ef21770645743611c1618362309e3ad7cab45acd1ad1a540ff", - "sha256:2e912b282466444023610e4498e3795c10e7cfd641744524876239fcf01d538d", - "sha256:30ffc046ebddccb3c4cac72c1a3e1bc343492336f3ca86d24672e90ccc5e788a", - "sha256:319c206e83e46ec2421b25b300c8482b6fe8a018baca246be308c736d9dab267", - "sha256:326b8a079a9afcac0575971e56dabdf7abb2ea89a893e6949b77adfeb058b50e", - "sha256:36ee0115b9edca904153a66bb74a9ff1ce38caff015de94eadfb9ba8e6ecd317", - "sha256:3e26e64f42bce5ddf9002092b2c37b13071c2e6413d5c05f9fa9de58ed2f7749", - "sha256:4ea99e64b2ad2635e0f0597b63f5ea6c374791ff2fa81cdd4bad8ed9f047f56f", - "sha256:501a1576716032cc6d48c7c47bcdc42d682273415a8f2908e7e72cb4625801f3", - "sha256:54c8cee662b5f8c30ad7eedfc26123f845f007798e4ff1001d9528fe959fd23c", - "sha256:595bbcdbfc4a9c6989d7489dca8510cba053ff46b16c84ffd95ac8e90711d419", - "sha256:5b860055199aec8d6fe4dcee3c5196ce506ca198a50aab0059ffd26e8e815828", - "sha256:5c667b383529520b8dd6bd496fc318678320cb2a6062fdfe6d3618da6b8790f6", - "sha256:5fb475a4cdde582c9528bb412b98f899680492daaba318231e96f1a0a1bb0d53", - "sha256:607d12f0901f6419a8adceb139847c42c83864b85371f58270e42753f9780fa6", - "sha256:64c5b0f2b937fe40d0967516eee5504b23cb247b8b7ffeba7213a467d9646fdc", - "sha256:664380c7ed524a280b6a2d5d9126389c3e96cd6e88986cdb42ca72baa27421d6", - "sha256:6af871f70cfd5b528bd322c65793b5fd5659858cdfaa35fbe563fb99b667ed1f", - "sha256:6c89894cc6f6ddd993813e79244b36b215c14f65f9e4f1660b1f2ba9e5594b95", - "sha256:6dee0496d5f1a8f57f0f28a16f81a2033fc057a2cf9cd710742d11828f8c80e2", - "sha256:6e9a9f50892153bad5046c2a6df153224aa6f0573a5a8ab44fc54a1e886f6e21", - "sha256:712ba8722c0699daf186de089ddc4677651eb9875ed7447b2ad50697522cbdd9", - "sha256:717f185086bb9d817d4537dd18d5df5d657598cd00e6fc22e4d54d84de266c1d", - "sha256:71978ba778948760cff528235c951ea0ef7a4f9c84ac5a49975f8540f76c3f73", - "sha256:71af3766bb46738d12cc288d9b8de7ef6f79c31fd62757e2b8a505fe3680b27f", - "sha256:73a183042ae0918c82ce2df38c3db2409b0eeae88e3afdfc80fb67471a95b33b", - "sha256:7564525a4673fde53dee7d4c307a961c0951918f0b8c7f09b2c9e02067cf6504", - "sha256:76a59d1b63de859398bc7764c860a769499511463c1232155061fe0147f13e01", - "sha256:7e9905fc2dc1319e4c39837b906a024cf71b1261cc66b0cd89678f779c0c61f5", - "sha256:8112f640a4f7e7bf59f7cabf0d47a29b8977528c521d73a64d5cc9e99e48a174", - "sha256:835010cc17d0020e7931d39e487d72c8e01c98e669b6896a8b8c9aa8ca69a949", - "sha256:838dde2cb570cfbb4cab8a876a0974e8b90973ea40b3ac27a79b8a74c8a2db15", - "sha256:8d31dd0245d88cf7239e96e8f2a99f815b06e458a5854150f8e6f0e61618d41b", - "sha256:96b34830bd6825ca0220bf005ea99ac83eb9ce51301ddb882dcf613ae6cd95fb", - "sha256:96c8ff1e1dd680e38af0887927cab407a4e51d84a5f02ae3d6eb87233036c763", - "sha256:9a7ee79183f0b17dcede8b6723e7da2ded529cf159a878214be9a5d3098f5b1e", - "sha256:a3e2aff8b822ab0e0bdbed9f50494b3a35629c4b9488ae391659973a37a9f53f", - "sha256:a4f3ab9eb8ab2d585ece959c48d234f7b39ac0ca1954a34d8b8e58a52064bdb3", - "sha256:a8b54949267bd5704324397efe9fbb6aa306466dee067550964e994d309db5f1", - "sha256:a96198d5d26f40557d986c1253bfe0e02d18c9d9b93cf389daf1a3c9f7c755fa", - "sha256:aebbd47df77190ada603157f0b3670d578c110c31746ecc5875c394fdcc59a99", - "sha256:af1107299cef049ad00a93df4809517be432283a0847bcae48343ebe5ea340dc", - "sha256:b63465b53baeaf2122a337d4ab57d6bbdd09fcadceb17a974cfa8a0300ad9c67", - "sha256:ba1c779b45a399cc25f511c681016626f69e51e45b9d350d7581998722825af9", - "sha256:bce00f3b1f7f644faae89677ca68645ed5365f1c7f874fdd5ebf730a69640d38", - "sha256:bfdf419bf5d3644f94cd7052954fc233522f5a1b371fc0b00219ebd9c14d5798", - "sha256:c1caa5763d1770216596e0a71b5567f27aac28c95992110212c108ec74589a48", - "sha256:c3e4e1f7b08d1ec6b685ccd3e2d762219c550164fbf524498532e39f9413436e", - "sha256:c85ab016e96a975afbdb9d49ca90f3bca9920ef27c64300843fe91c3d59d8d20", - "sha256:c924deab8105f86980983eced740433fb7554a7f66db73991affa4eda99d5402", - "sha256:d4f818f6371970d6a5d1e42878389bbfb69dcde631e4bbac5ec1cb11158565ca", - "sha256:d920401941cb898ef089422e889759dd403309eb370d0e54f1bdf6ca07fef603", - "sha256:da045bd1147d12bd43fb032296640a7cc17a7f2eaba67495988362e99db24fd2", - "sha256:dc3192a81ecd5ff954cecd690327badd5a84d00b877e1573f7c9097ce13e5bfb", - "sha256:ddae504cfb556fe220efae65e35be63cd11e3c314b202723fc2119ce19f0ca2e", - "sha256:de4544b1fb29cf14870c4e2b8a897c0242449f5dcebd3e0366aa0aa3cf58a23a", - "sha256:dea360778e0668a7ad25d7727d03364de8a45bfd5d808f81253516b9f2217765", - "sha256:e2254fe137c4a360b0a13173a56444f756252c9283ba4d267ca8e9081cd140ea", - "sha256:e64f0421892a207d3780903085c1b04efeb53b16803b23d947de5a7261b71355", - "sha256:e97a29b37830ba1262d8dfd48ddb5b28ad4d3ebecc5d93a9c7591d98641ec737", - "sha256:eacbcf30efaca7dc5cb264228ffecdb95fdb1e715b1ec937c0ce6b734161e0c8", - "sha256:eee5ff934b0c9f4537ff9596169d56cab1890918004791a7a06b879b3ba2a7ef", - "sha256:eff6bac402719c14e17efe845d6b98593c56c843aca6def72080fbede755fd1f", - "sha256:f10954b233d4df5cc3137ffa5ced97f8894152df817e5d149bf05a0ef2ab8134", - "sha256:f23bb1a7a6e8e8b612a164fdd08e683bcc16c76f928d6dbb7bdbee2374fbfee6", - "sha256:f494c01b28645c431239863cb17af8b8d15b93b0d697a0320d5dd34cd9d7c2fa", - "sha256:f6a071d2c3d39b4104f94fc08ab349e9b19b951ad4b8e3b6d7ea92d6ef7ccaf8", - "sha256:f736f54565f8dd7e3ab664fef2bc461d7593a389a7f28d4904af8d55a91bd55f", - "sha256:f8981a94a27ac520a398302afb74ae2c0be1c3d2d215c75c582186a006c9e7b0", - "sha256:fd24996e12e1ba7c397c44be75ca299da14cde34d74bc5508cce233676cc68d0", - "sha256:ff54340fc1129e8e181827e2234af3ff659b4f17d9bbe77f43bc19e6577fadec" - ], - "markers": "python_version >= '3.8'", - "version": "==1.12.1" + "sha256:047b258e00b99091b6f90355521f026238c63bd76dcf996d93527bb13320eefd", + "sha256:06ff23462398333c78b6f4f8d3d70410d657a471c2c5bbe6086133be43fc8f1a", + "sha256:07f9eaf57719d6721ab15805d85f4b01a5b509a0868d7320134371bcb652152d", + "sha256:0aa92e3e30a04f9462a25077db689c4ac5ea9ab6cc68a2e563881b987d42f16d", + "sha256:0cf21f46a15d445417de8fc89f2568852cf57fe8ca1ab3d19ddb24d45c0383ae", + "sha256:0fd7b941dd1b00b5f0acb97455fea2c4b7aac2dd31ea43fb9d155e9bc7b78664", + "sha256:147e36331f6f63e08a14640acf12369e041e0751bb70d9362df68c2d9dcf0c87", + "sha256:16a682a127930f3fc4e42583becca6049e1d7214bcad23520c590edd741d2114", + "sha256:176110bff341b6730f64a1eb3a7070e12b373cf1c910a9337e7c3240497db76f", + "sha256:19268b4fec1d7760134f2de46ef2608c2920134fb1fa61e451f679e41356dc55", + "sha256:1b16f6c75cffc2dc0616ea295abb0e1967601bd1fb1e0af6a1de1c6c887f3439", + "sha256:1bfc25aa6a7c99cf86564210f79a0b7d4484159c67e01232b116e445b3036547", + "sha256:1ca3894e9e9f72da93544f64988d9c052254a338a9f855165f37f51edb6591de", + "sha256:1dda53508df0de87b6e6b0a52d6718ff6c62a5aca8f5552748404963df639269", + "sha256:217a782020b875538eebf3948fac3a7f9bbbd0fd9bf8538f7c2ad7489e80f4e8", + "sha256:2192f718db4a8509f63dd6d950f143279211fa7e6a2c612edc17d85bf043d36e", + "sha256:29a84a46ec3ebae7a1c024c055612b11e9363a8a23238b3e905552d77a2bc51b", + "sha256:3007a5b75cb50140708420fe688c393e71139324df599434633019314ceb8b59", + "sha256:30600ba5db60f7c0820ef38a2568bb7379e1418ecc947a0f76fd8b2ff4257a97", + "sha256:337912bcdcf193ade64b9aae5a4017a0a1950caf8ca140362e361543c6773f21", + "sha256:37001e5d4621cef710c8dc1429ca04e189e572f128ab12312eab4e04cf007132", + "sha256:3d569f877ed9a708e4c71a2d13d2940cb0791da309f70bd970ac1a5c088a0a92", + "sha256:4009def9be3a7e5175db20aa2d7307ecd00bbf50f7f0f989300710eee1d0b0b9", + "sha256:46a9772a1efa93f9cd170ad33101c1817c77e0e9914d4fe33e2da299d7cf0f9b", + "sha256:47eede5d11d669ab3759b63afb70d28d5328c14744b8edba3323e27dc52d298d", + "sha256:498b3c55087b9d762636bca9b45f60d37e51d24341786dc01b81253f9552a607", + "sha256:4e0d45ebf975634468682c8bec021618b3ad52c37619e5c938f8f831fa1ac5c0", + "sha256:4f24f08b6c9b9818fd80612c97857d28f9779f0d1211653ece9844fc7b414df2", + "sha256:55c144d363ad4626ca744556c049c94e2b95096041ac87098bb363dcc8635e8d", + "sha256:582cedde49603f139be572252a318b30dc41039bc0b8165f070f279e5d12187f", + "sha256:587c3cc59bc148a9b1c07a019346eda2549bc9f468acd2f9824d185749acf0a6", + "sha256:5cd5dad8366e0168e0fd23d10705a603790484a6dbb9eb272b33673b8f2cce72", + "sha256:5d02d700705d67e09e1f57681f758f0b9d4412eeb70b2eb8d96ca6200b486db3", + "sha256:625f207b1799e95e7c823f42f473c1e9dbfb6192bd56bba8695656d92be4535f", + "sha256:659603d26d40dd4463200df9bfbc339fbfaed3fe32e5c432fe1dc2b5d4aa94b4", + "sha256:689a99a42ee4583fcb0d3a67a0204664aa1539684aed72bdafcbd505197a91c4", + "sha256:68ac1a09392ed6e3fd14be880d39b951d7b981fd135416db7d18a6208c536561", + "sha256:6a615cad11ec3428020fb3c5a88d85ce1b5c69fd66e9fcb91a7daa5e855325dd", + "sha256:73bedd2be05f48af19f0f2e9e1353921ce0c83f4a1c9e8556ecdcf1f1eae4892", + "sha256:742aef0a99844faaac200564ea6f5e08facb285d37ea18bd1a5acf2771f3255a", + "sha256:75ff4c819757f9bdb35de049a509814d6ce851fe26f06eb95a392a5640052482", + "sha256:781e2495e408a81e4eaeedeb41ba32b63b1980dddf8b60dbbeff6036bcd35049", + "sha256:7a9f917966d27f7ce30039fe8d900f913c5304134096554fd9bea0774bcda6d1", + "sha256:7e2637d75e92763d1322cb5041573279ec43a80c0f7fbbd2d64f5aee98447b17", + "sha256:8089d4634d8fa2b1806ce44fefa4979b1ab2c12c0bc7ef3dfa45c8a374811348", + "sha256:816d24f584edefcc5ca63428f0b38fee00b39fe64e3c5e558f895a18983efe96", + "sha256:8385ab36bf812e9d37cf7613999a87715f27ef67a53f0687d28c44b819df7cb0", + "sha256:85cb3e40eaa98489f1e2e8b29f5ad02ee1ee40d6ce6b88d50cf0f205de1d9d2c", + "sha256:8648180b34faaea4aa5b5ca7e871d9eb1277033fa439693855cf0ea9195f85f1", + "sha256:8892fa575ac9b1b25fae7b221bc4792a273877b9b56a99ee2d8d03eeb3dbb1d2", + "sha256:88c7d9d58aab0724b979ab5617330acb1c7030b79379c8138c1c8c94e121d1b3", + "sha256:8a2f8fb7f944bcdfecd4e8d855f84c703804a594da5123dd206f75036e536d4d", + "sha256:8f4e475f29a9122f908d0f1f706e1f2fc3656536ffd21014ff8a6f2e1b14d1d8", + "sha256:8f50eb3837012a937a2b649ec872b66ba9541ad9d6f103ddcafb8231cfcafd22", + "sha256:91d875f75fabf76b3018c5f196bf3d308ed2b49ddcb46c1576d6b075754a1393", + "sha256:94b2bb9bcfd5be9d27004ea4398fb640373dd0c1a9e219084f42c08f77a720ab", + "sha256:9557c9322aaa33174d285b0c1961fb32499d65ad1866155b7845edc876c3c835", + "sha256:95e16e9eaa2d7f5d87421b8fe694dd71606aa61d74b824c8d17fc85cc51983d1", + "sha256:96952f642ac69075e44c7d0284528938fdff39422a1d90d3e45ce40b72e5e2d9", + "sha256:985623575e5c4ea763056ffe0e2d63836f771a8c294b3de06d09480538316b13", + "sha256:99ff3744f5fe48288be6bc402533b38e89749623a43208e1d57091fc96b783b9", + "sha256:9abe80ae2c9d37c17599557b712e6515f4100a80efb2cda15f5f070306477cd2", + "sha256:a152751af7ef7b5d5fa6d215756e508dd05eb07d0cf2ba51f3e740076aa74373", + "sha256:a2e4725a08cb2b4794db09e350c86dee18202bb8286527210e13a1514dc9a59a", + "sha256:a56fbe3d7f3bce1d060ea18d2413a2ca9ca814eea7cedc4d247b5f338d54844e", + "sha256:ab3abc0b78a5dfaa4795a6afbe7b282b6aa88d81cf8c1bb5e394993d7cae3457", + "sha256:b03384eed107dbeb5f625a99dc3a7de8be04fc8480c9ad42fccbc73434170b20", + "sha256:b0547ab1e9345dc468cac8368d88ea4c5bd473ebc1d8d755347d7401982b5dd8", + "sha256:b4c1ecba93e7826dc71ddba75fb7740cdb52e7bd0be9f03136b83f54e6a1f511", + "sha256:b693c63e7e64b524f54aa4888403c680342d1ad0d97be1707c531584d6aeeb4f", + "sha256:b6d0147574ce2e7b812c989e50fa72bbc5338045411a836bd066ce5fc8ac0bce", + "sha256:b9cfef3f14f75bf6aba73a76caf61f9d00865912a04a4393c468a7ce0981b519", + "sha256:b9f805e37ed16cc212fdc538a608422d7517e7faf539bedea4fe69425bc55d76", + "sha256:bab03192091681d54e8225c53f270b0517637915d9297028409a2a5114ff4634", + "sha256:bc24f968b82455f336b79bf37dbb243b7d76cd40897489888d663d4e028f5069", + "sha256:c14b504a74e58e2deb0378b3eca10f3d076635c100f45b113c18c770b4a47a50", + "sha256:c2089a9afef887664115f7fa6d3c0edd6454adaca5488dba836ca91f60401075", + "sha256:c8ed4034f0765f8861620c1f2f2364d2e58520ea288497084dae880424fc0d9f", + "sha256:cd2660c01367eb3ef081b8fa0a5da7fe767f9427aa82023a961a5f28f0d4af6c", + "sha256:d8361c7d04e6a264481f0b802e395f647cd3f8bbe27acfa7c12049efea675bd1", + "sha256:d9baec588f015d0ee564057aa7574313c53a530662ffad930b7886becc85abdf", + "sha256:dbd9ff43a04f8ffe8a959a944c2dca10d22f5f99fc6a459f49c3ebfb409309d9", + "sha256:e3f8bfc1db82589ef965ed234b87de30d140db8b6dc50ada9e33951ccd8ec07a", + "sha256:e6a2c5c5bb2556dfbfffffc2bcfb9c235fd2b566d5006dfb2a37afc7e3278a07", + "sha256:e749af6c912a7bb441d105c50c1a3da720474e8acb91c89350080dd600228f0e", + "sha256:e85d86527baebb41a214cc3b45c17177177d900a2ad5783dbe6f291642d4906f", + "sha256:ee2c68e4f2dd1b1c15b849ba1c96fac105fca6ffdb7c1e8be51da6fabbdeafb9", + "sha256:f3ab950f8814f3b7b5e3eebc117986f817ec933676f68f0a6c5b2137dd7c9c69", + "sha256:f4f4547944d4f5cfcdc03f3f097d6f05bbbc915eaaf80a2ee120d0e756de377d", + "sha256:f72a0d746d38cb299b79ce3d4d60ba0892c84bbc905d0d49c13df5bace1b65f8", + "sha256:fc2c80bc87fba076e6cbb926216c27fba274dae7100a7b9a0983b53132dd99f2", + "sha256:fe4d2536c827f508348d7b40c08767e8c7071614250927233bf0c92170451c0a" + ], + "markers": "python_version >= '3.8'", + "version": "==1.14.0" }, "zipp": { "hashes": [ @@ -2563,11 +2672,11 @@ }, "botocore-stubs": { "hashes": [ - "sha256:1184dcb19d833041cfadc3c533c8cb7ae246a9ab9b974b33b42fe209f54f551b", - "sha256:acb1c77d422c9bf51161c98a2912fd0b4abb4efe9578d7f4c851c77d30fc754a" + "sha256:b1aebecdfa4f4fc02b0a68a5e438877034b195168809a7202ee32b42245d3ece", + "sha256:d79a408dfc503a1a0389d10cd29ad22a01450d0d53902ea216815e2ba98913ba" ], "markers": "python_version >= '3.8'", - "version": "==1.35.26" + "version": "==1.35.35" }, "certifi": { "hashes": [ @@ -2952,11 +3061,11 @@ }, "rich": { "hashes": [ - "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06", - "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a" + "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", + "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==13.8.1" + "markers": "python_full_version >= '3.8.0'", + "version": "==13.9.2" }, "ruff": { "hashes": [ @@ -3000,11 +3109,11 @@ }, "types-awscrt": { "hashes": [ - "sha256:117ff2b1bb657f09d01b7e0ce3fe3fa6e039be12d30b826896182725c9ce85b1", - "sha256:9f7f47de68799cb2bcb9e486f48d77b9f58962b92fba43cb8860da70b3c57d1b" + "sha256:67a660c90bad360c339f6a79310cc17094d12472042c7ca5a41450aaf5fc9a54", + "sha256:b2c196bbd3226bab42d80fae13c34548de9ddc195f5a366d79c15d18e5897aa9" ], "markers": "python_version >= '3.8'", - "version": "==0.21.5" + "version": "==0.22.0" }, "types-beautifulsoup4": { "hashes": [ diff --git a/opentrons-ai-server/api/domain/openai_predict.py b/opentrons-ai-server/api/domain/openai_predict.py index c9733614458..71b34cff12b 100644 --- a/opentrons-ai-server/api/domain/openai_predict.py +++ b/opentrons-ai-server/api/domain/openai_predict.py @@ -1,7 +1,8 @@ -import logging from pathlib import Path from typing import List, Tuple +import structlog +from ddtrace import tracer from llama_index.core import Settings as li_settings from llama_index.core import StorageContext, load_index_from_storage from llama_index.embeddings.openai import OpenAIEmbedding @@ -25,8 +26,8 @@ from api.domain.utils import refine_characters from api.settings import Settings -logger = logging.getLogger(__name__) - +settings: Settings = Settings() +logger = structlog.stdlib.get_logger(settings.logger_name) ROOT_PATH: Path = Path(Path(__file__)).parent.parent.parent @@ -38,6 +39,7 @@ def __init__(self, settings: Settings) -> None: model_name="text-embedding-3-large", api_key=self.settings.openai_api_key.get_secret_value() ) + @tracer.wrap() def get_docs_all(self, query: str) -> Tuple[str, str, str]: commands = self.extract_atomic_description(query) logger.info("Commands", extra={"commands": commands}) @@ -85,6 +87,7 @@ def get_docs_all(self, query: str) -> Tuple[str, str, str]: return example_commands, docs + docs_ref, standard_api_names + @tracer.wrap() def extract_atomic_description(self, protocol_description: str) -> List[str]: class atomic_descr(BaseModel): """ @@ -106,6 +109,7 @@ class atomic_descr(BaseModel): descriptions.append(x) return descriptions + @tracer.wrap() def refine_response(self, assistant_message: str) -> str: if assistant_message is None: return "" @@ -129,6 +133,7 @@ def refine_response(self, assistant_message: str) -> str: return response.choices[0].message.content if response.choices[0].message.content is not None else "" + @tracer.wrap() def predict(self, prompt: str, chat_completion_message_params: List[ChatCompletionMessageParam] | None = None) -> None | str: prompt = refine_characters(prompt) diff --git a/opentrons-ai-server/api/domain/prompts.py b/opentrons-ai-server/api/domain/prompts.py index 582bd1565ea..8d335b65227 100644 --- a/opentrons-ai-server/api/domain/prompts.py +++ b/opentrons-ai-server/api/domain/prompts.py @@ -1,15 +1,16 @@ import json -import logging import uuid from typing import Any, Dict, Iterable import requests +import structlog +from ddtrace import tracer from openai.types.chat import ChatCompletionToolParam from api.settings import Settings settings: Settings = Settings() -logger = logging.getLogger(__name__) +logger = structlog.stdlib.get_logger(settings.logger_name) def generate_unique_name() -> str: @@ -17,13 +18,14 @@ def generate_unique_name() -> str: return unique_name +@tracer.wrap() def send_post_request(payload: str) -> str: url = "https://Opentrons-simulator.hf.space/protocol" protocol_name: str = generate_unique_name() data = {"name": protocol_name, "content": payload} hf_token: str = settings.huggingface_api_key.get_secret_value() headers = {"Content-Type": "application/json", "Authorization": "Bearer {}".format(hf_token)} - + logger.info("Sending POST request to the simulate API", extra={"url": url, "protocolName": data["name"]}) response = requests.post(url, json=data, headers=headers) if response.status_code != 200: diff --git a/opentrons-ai-server/api/handler/custom_logging.py b/opentrons-ai-server/api/handler/custom_logging.py new file mode 100644 index 00000000000..a062528f803 --- /dev/null +++ b/opentrons-ai-server/api/handler/custom_logging.py @@ -0,0 +1,133 @@ +# Taken directly from https://gist.github.com/nymous/f138c7f06062b7c43c060bf03759c29e +import logging +import sys + +import ddtrace +import structlog +from ddtrace import tracer +from structlog.types import EventDict, Processor + + +# https://github.com/hynek/structlog/issues/35#issuecomment-591321744 +def rename_event_key(_, __, event_dict: EventDict) -> EventDict: # type: ignore[no-untyped-def] + """ + Log entries keep the text message in the `event` field, but Datadog + uses the `message` field. This processor moves the value from one field to + the other. + See https://github.com/hynek/structlog/issues/35#issuecomment-591321744 + """ + event_dict["message"] = event_dict.pop("event") + return event_dict + + +def drop_color_message_key(_, __, event_dict: EventDict) -> EventDict: # type: ignore[no-untyped-def] + """ + Uvicorn logs the message a second time in the extra `color_message`, but we don't + need it. This processor drops the key from the event dict if it exists. + """ + event_dict.pop("color_message", None) + return event_dict + + +def tracer_injection(_, __, event_dict: EventDict) -> EventDict: # type: ignore[no-untyped-def] + # get correlation ids from current tracer context + span = tracer.current_span() + trace_id, span_id = (str((1 << 64) - 1 & span.trace_id), span.span_id) if span else (None, None) + + # add ids to structlog event dictionary + event_dict["dd.trace_id"] = str(trace_id or 0) + event_dict["dd.span_id"] = str(span_id or 0) + + # add the env, service, and version configured for the tracer + event_dict["dd.env"] = ddtrace.config.env or "" + event_dict["dd.service"] = ddtrace.config.service or "" + event_dict["dd.version"] = ddtrace.config.version or "" + + return event_dict + + +def setup_logging(json_logs: bool = False, log_level: str = "INFO") -> None: + timestamper = structlog.processors.TimeStamper(fmt="iso") + + shared_processors: list[Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.stdlib.ExtraAdder(), + drop_color_message_key, + tracer_injection, + timestamper, + structlog.processors.StackInfoRenderer(), + ] + + if json_logs: + # We rename the `event` key to `message` only in JSON logs, as Datadog looks for the + # `message` key but the pretty ConsoleRenderer looks for `event` + shared_processors.append(rename_event_key) + # Format the exception only for JSON logs, as we want to pretty-print them when + # using the ConsoleRenderer + shared_processors.append(structlog.processors.format_exc_info) + + structlog.configure( + processors=shared_processors + + [ + # Prepare event dict for `ProcessorFormatter`. + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + + log_renderer: structlog.types.Processor + if json_logs: + log_renderer = structlog.processors.JSONRenderer() + else: + log_renderer = structlog.dev.ConsoleRenderer() + + formatter = structlog.stdlib.ProcessorFormatter( + # These run ONLY on `logging` entries that do NOT originate within + # structlog. + foreign_pre_chain=shared_processors, + # These run on ALL entries after the pre_chain is done. + processors=[ + # Remove _record & _from_structlog. + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + log_renderer, + ], + ) + + handler = logging.StreamHandler() + # Use OUR `ProcessorFormatter` to format all `logging` entries. + handler.setFormatter(formatter) + root_logger = logging.getLogger() + root_logger.addHandler(handler) + root_logger.setLevel(log_level.upper()) + + for _log in ["uvicorn", "uvicorn.error"]: + # Clear the log handlers for uvicorn loggers, and enable propagation + # so the messages are caught by our root logger and formatted correctly + # by structlog + logging.getLogger(_log).handlers.clear() + logging.getLogger(_log).propagate = True + + # Since we re-create the access logs ourselves, to add all information + # in the structured log (see the `logging_middleware` in main.py), we clear + # the handlers and prevent the logs to propagate to a logger higher up in the + # hierarchy (effectively rendering them silent). + logging.getLogger("uvicorn.access").handlers.clear() + logging.getLogger("uvicorn.access").propagate = False + + def handle_exception(exc_type, exc_value, exc_traceback): # type: ignore[no-untyped-def] + """ + Log any uncaught exception instead of letting it be printed by Python + (but leave KeyboardInterrupt untouched to allow users to Ctrl+C to stop) + See https://stackoverflow.com/a/16993115/3641865 + """ + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + + root_logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) + + sys.excepthook = handle_exception diff --git a/opentrons-ai-server/api/handler/fast.py b/opentrons-ai-server/api/handler/fast.py index 3c88e08a1a2..8e5572d0c15 100644 --- a/opentrons-ai-server/api/handler/fast.py +++ b/opentrons-ai-server/api/handler/fast.py @@ -1,9 +1,13 @@ import asyncio import os +import time from typing import Any, Awaitable, Callable, List, Literal, Union -import ddtrace +import structlog +from asgi_correlation_id import CorrelationIdMiddleware +from asgi_correlation_id.context import correlation_id from ddtrace import tracer +from ddtrace.contrib.asgi.middleware import TraceMiddleware from fastapi import FastAPI, HTTPException, Query, Request, Response, Security, status from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware @@ -11,9 +15,10 @@ from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, Field, conint from starlette.middleware.base import BaseHTTPMiddleware +from uvicorn.protocols.utils import get_path_with_query_string from api.domain.openai_predict import OpenAIPredict -from api.handler.logging_config import get_logger, setup_logging +from api.handler.custom_logging import setup_logging from api.integration.auth import VerifyToken from api.models.chat_request import ChatRequest from api.models.chat_response import ChatResponse @@ -21,10 +26,12 @@ from api.models.internal_server_error import InternalServerError from api.settings import Settings -setup_logging() -logger = get_logger(__name__) -ddtrace.patch(logging=True) settings: Settings = Settings() +setup_logging(json_logs=settings.json_logging, log_level=settings.log_level.upper()) + +access_logger = structlog.stdlib.get_logger("api.access") +logger = structlog.stdlib.get_logger(settings.logger_name) + auth: VerifyToken = VerifyToken() openai: OpenAIPredict = OpenAIPredict(settings) @@ -75,6 +82,61 @@ async def dispatch(self, request: Request, call_next: Any) -> JSONResponse | Any app.add_middleware(TimeoutMiddleware, timeout_s=178) +@app.middleware("http") +async def logging_middleware(request: Request, call_next) -> Response: # type: ignore[no-untyped-def] + structlog.contextvars.clear_contextvars() + # These context vars will be added to all log entries emitted during the request + request_id = correlation_id.get() + structlog.contextvars.bind_contextvars(request_id=request_id) + + start_time = time.perf_counter_ns() + # If the call_next raises an error, we still want to return our own 500 response, + # so we can add headers to it (process time, request ID...) + response = Response(status_code=500) + try: + response = await call_next(request) + except Exception: + structlog.stdlib.get_logger("api.error").exception("Uncaught exception") + raise + finally: + process_time = time.perf_counter_ns() - start_time + status_code = response.status_code + url = get_path_with_query_string(request.scope) # type: ignore[arg-type] + client_host = request.client.host if request.client and request.client.host else "unknown" + client_port = request.client.port if request.client and request.client.port else "unknown" + http_method = request.method if request.method else "unknown" + http_version = request.scope["http_version"] + # Recreate the Uvicorn access log format, but add all parameters as structured information + access_logger.info( + f"""{client_host}:{client_port} - "{http_method} {url} HTTP/{http_version}" {status_code}""", + http={ + "url": str(request.url), + "status_code": status_code, + "method": http_method, + "request_id": request_id, + "version": http_version, + }, + network={"client": {"ip": client_host, "port": client_port}}, + duration=process_time, + ) + response.headers["X-Process-Time"] = str(process_time / 10**9) + return response + + +# This middleware must be placed after the logging, to populate the context with the request ID +# NOTE: Why last?? +# Answer: middlewares are applied in the reverse order of when they are added (you can verify this +# by debugging `app.middleware_stack` and recursively drilling down the `app` property). +app.add_middleware(CorrelationIdMiddleware) + +tracing_middleware = next((m for m in app.user_middleware if m.cls == TraceMiddleware), None) +if tracing_middleware is not None: + app.user_middleware = [m for m in app.user_middleware if m.cls != TraceMiddleware] + structlog.stdlib.get_logger("api.datadog_patch").info("Patching Datadog tracing middleware to be the outermost middleware...") + app.user_middleware.insert(0, tracing_middleware) + app.middleware_stack = app.build_middleware_stack() + + # Models class Status(BaseModel): status: Literal["ok", "error"] @@ -134,7 +196,7 @@ async def create_chat_completion( return ChatResponse(reply=response, fake=body.fake) except Exception as e: - logger.exception(e) + logger.exception("Error processing chat completion") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=InternalServerError(exception_object=e).model_dump() ) from e @@ -143,7 +205,7 @@ async def create_chat_completion( @app.get( "/health", response_model=Status, - summary="LB Health Check", + summary="Load Balancer Health Check", description="Check the health and version of the API.", include_in_schema=False, ) @@ -154,10 +216,14 @@ async def get_health(request: Request) -> Status: - **returns**: A Status containing the version of the API. """ - logger.debug(f"{request.method} {request.url.path}") + if request.url.path == "/health": + pass # This is a health check from the load balancer + else: + logger.info(f"{request.method} {request.url.path}", extra={"requestMethod": request.method, "requestPath": request.url.path}) return Status(status="ok", version=settings.dd_version) +@tracer.wrap() @app.get("/api/timeout", response_model=TimeoutResponse) async def timeout_endpoint(request: Request, seconds: conint(ge=1, le=300) = Query(..., description="Number of seconds to wait")): # type: ignore # noqa: B008 """ diff --git a/opentrons-ai-server/api/handler/local_run.py b/opentrons-ai-server/api/handler/local_run.py index 0b82fae7e41..e9bbcc6f151 100644 --- a/opentrons-ai-server/api/handler/local_run.py +++ b/opentrons-ai-server/api/handler/local_run.py @@ -1,9 +1,12 @@ # run.py import uvicorn -from api.handler.logging_config import setup_logging - -setup_logging() - if __name__ == "__main__": - uvicorn.run("api.handler.fast:app", host="localhost", port=8000, timeout_keep_alive=190, reload=True) + uvicorn.run( + "api.handler.fast:app", + host="localhost", + port=8000, + timeout_keep_alive=190, + reload=True, + log_config=None, + ) diff --git a/opentrons-ai-server/api/handler/logging_config.py b/opentrons-ai-server/api/handler/logging_config.py deleted file mode 100644 index fc576b6ad80..00000000000 --- a/opentrons-ai-server/api/handler/logging_config.py +++ /dev/null @@ -1,39 +0,0 @@ -# logging_config.py -import logging - -from pythonjsonlogger import jsonlogger - -from api.settings import Settings - -FORMAT = ( - "%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] " - "[dd.service=%(dd.service)s dd.env=%(dd.env)s dd.version=%(dd.version)s dd.trace_id=%(dd.trace_id)s dd.span_id=%(dd.span_id)s] " - "- %(message)s" -) - -LOCAL_FORMAT = "%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] - %(message)s" - - -def setup_logging() -> None: - settings = Settings() - log_handler = logging.StreamHandler() - - if settings.environment == "local": - formatter = logging.Formatter(LOCAL_FORMAT) - else: - formatter = jsonlogger.JsonFormatter(FORMAT) # type: ignore - - log_handler.setFormatter(formatter) - - logging.basicConfig( - level=settings.log_level.upper(), - handlers=[log_handler], - ) - - -# Call this function to initialize logging -setup_logging() - - -def get_logger(name: str) -> logging.Logger: - return logging.getLogger(name) diff --git a/opentrons-ai-server/api/integration/auth.py b/opentrons-ai-server/api/integration/auth.py index c3cf4b8d163..12e8b2a4a9e 100644 --- a/opentrons-ai-server/api/integration/auth.py +++ b/opentrons-ai-server/api/integration/auth.py @@ -1,13 +1,14 @@ -import logging from typing import Any, Optional import jwt +import structlog from fastapi import HTTPException, Security, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes from api.settings import Settings -logger = logging.getLogger(__name__) +settings: Settings = Settings() +logger = structlog.stdlib.get_logger(settings.logger_name) class UnauthenticatedException(HTTPException): @@ -35,10 +36,10 @@ async def verify( try: signing_key = self.jwks_client.get_signing_key_from_jwt(credentials.credentials).key except jwt.PyJWKClientError as error: - logger.error(error, extra={"credentials": credentials}) + logger.error("Client Error", extra={"credentials": credentials}, exc_info=True) raise UnauthenticatedException() from error except jwt.exceptions.DecodeError as error: - logger.error(error, extra={"credentials": credentials}) + logger.error("Decode Error", extra={"credentials": credentials}, exc_info=True) raise UnauthenticatedException() from error try: @@ -51,10 +52,10 @@ async def verify( ) logger.info("Decoded token", extra={"token": payload}) return payload - except jwt.ExpiredSignatureError as error: - logger.error(error, extra={"credentials": credentials}) + except jwt.ExpiredSignatureError: + logger.error("Expired Signature", extra={"credentials": credentials}, exc_info=True) # Handle token expiration, e.g., refresh token, re-authenticate, etc. - except jwt.PyJWTError as error: - logger.error(error, extra={"credentials": credentials}) + except jwt.PyJWTError: + logger.error("General JWT Error", extra={"credentials": credentials}, exc_info=True) # Handle other JWT errors raise UnauthenticatedException() diff --git a/opentrons-ai-server/api/settings.py b/opentrons-ai-server/api/settings.py index c29f44aface..c59a25c33de 100644 --- a/opentrons-ai-server/api/settings.py +++ b/opentrons-ai-server/api/settings.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from pydantic import SecretStr @@ -6,6 +7,10 @@ ENV_PATH: Path = Path(Path(__file__).parent.parent, ".env") +def is_running_in_docker() -> bool: + return os.path.exists("/.dockerenv") + + class Settings(BaseSettings): """ If the env_file file exists: It will read the configurations from the env_file file (local execution) @@ -26,7 +31,7 @@ class Settings(BaseSettings): auth0_algorithms: str = "RS256" dd_version: str = "hardcoded_default_from_settings" allowed_origins: str = "*" - dd_logs_injection: str = "true" + dd_trace_enabled: str = "false" cpu: str = "1028" memory: str = "2048" @@ -35,6 +40,16 @@ class Settings(BaseSettings): openai_api_key: SecretStr = SecretStr("default_openai_api_key") huggingface_api_key: SecretStr = SecretStr("default_huggingface_api_key") + @property + def json_logging(self) -> bool: + if self.environment == "local" and not is_running_in_docker(): + return False + return True + + @property + def logger_name(self) -> str: + return "app.logger" + def get_settings_from_json(json_str: str) -> Settings: """ diff --git a/opentrons-ai-server/api/uvicorn_disable_logging.json b/opentrons-ai-server/api/uvicorn_disable_logging.json new file mode 100644 index 00000000000..e2f06cb503f --- /dev/null +++ b/opentrons-ai-server/api/uvicorn_disable_logging.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + } + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.NullHandler" + }, + "access": { + "formatter": "access", + "class": "logging.NullHandler" + } + }, + "loggers": { + "uvicorn.error": { + "level": "INFO", + "handlers": ["default"], + "propagate": false + }, + "uvicorn.access": { + "level": "INFO", + "handlers": ["access"], + "propagate": false + } + } +} diff --git a/opentrons-ai-server/tests/helpers/huggingface_client.py b/opentrons-ai-server/tests/helpers/huggingface_client.py index 7b66fd61674..a55792d2fb7 100644 --- a/opentrons-ai-server/tests/helpers/huggingface_client.py +++ b/opentrons-ai-server/tests/helpers/huggingface_client.py @@ -39,6 +39,7 @@ def get_auth_headers(self, token_override: str | None = None) -> dict[str, str]: return {"Authorization": f"Bearer {self.settings.HF_API_KEY}"} def post_simulate_protocol(self, protocol: Protocol) -> Response: + console.print(self.auth_headers) return self.httpx.post("https://opentrons-simulator.hf.space/protocol", headers=self.standard_headers, json=protocol.model_dump()) From 6c8c5835a3eedd98887177369c15527a65c26500 Mon Sep 17 00:00:00 2001 From: Anthony Ngumah <68346382+AnthonyNASC20@users.noreply.github.com> Date: Wed, 9 Oct 2024 09:35:11 -0400 Subject: [PATCH 019/101] feat(hardware-testing): Abr Asair Script update (#16440) # Overview ## Test Plan and Hands on Testing ## Changelog ## Review requests --- abr-testing/protocol_simulation/__init__.py | 1 + .../protocol_simulation/simulation_metrics.py | 353 ++++++++++++++++++ .../hardware_testing/drivers/__init__.py | 35 +- .../scripts/abr_asair_sensor.py | 2 +- 4 files changed, 370 insertions(+), 21 deletions(-) create mode 100644 abr-testing/protocol_simulation/__init__.py create mode 100644 abr-testing/protocol_simulation/simulation_metrics.py diff --git a/abr-testing/protocol_simulation/__init__.py b/abr-testing/protocol_simulation/__init__.py new file mode 100644 index 00000000000..157c21fd93e --- /dev/null +++ b/abr-testing/protocol_simulation/__init__.py @@ -0,0 +1 @@ +"""The package holding code for simulating protocols.""" \ No newline at end of file diff --git a/abr-testing/protocol_simulation/simulation_metrics.py b/abr-testing/protocol_simulation/simulation_metrics.py new file mode 100644 index 00000000000..544bc3fb4bc --- /dev/null +++ b/abr-testing/protocol_simulation/simulation_metrics.py @@ -0,0 +1,353 @@ +import re +import sys +import os +from pathlib import Path +from click import Context +from opentrons.cli import analyze +import json +import argparse +from datetime import datetime +from abr_testing.automation import google_sheets_tool +from abr_testing.data_collection import read_robot_logs +from typing import Set, Dict, Any, Tuple, List, Union +from abr_testing.tools import plate_reader + +def look_for_air_gaps(protocol_file_path: str) -> int: + instances = 0 + try: + with open(protocol_file_path, "r") as open_file: + protocol_lines = open_file.readlines() + for line in protocol_lines: + if "air_gap" in line: + print(line) + instances += 1 + print(f'Found {instances} instance(s) of the air gap function') + open_file.close() + except Exception as error: + print("Error reading protocol:", error.with_traceback()) + return instances + +def set_api_level(protocol_file_path) -> None: + with open(protocol_file_path, "r") as file: + file_contents = file.readlines() + # Look for current'apiLevel:' + for i, line in enumerate(file_contents): + print(line) + if 'apiLevel' in line: + print(f"The current API level of this protocol is: {line}") + change = input("Would you like to simulate with a different API level? (Y/N) ").strip().upper() + + if change == "Y": + api_level = input("Protocol API Level to Simulate with: ") + # Update new API level + file_contents[i] = f'apiLevel: {api_level}\n' + print(f"Updated line: {file_contents[i]}") + break + with open(protocol_file_path, "w") as file: + file.writelines(file_contents) + print("File updated successfully.") + +original_exit = sys.exit + +def mock_exit(code=None) -> None: + print(f"sys.exit() called with code: {code}") + raise SystemExit(code) + +def get_labware_name(id: str, object_dict: dict, json_data: dict) -> str: + slot = "" + for obj in object_dict: + if obj['id'] == id: + try: + # Try to get the slotName from the location + slot = obj['location']['slotName'] + return " SLOT: " + slot + except KeyError: + location = obj.get('location', {}) + + # Check if location contains 'moduleId' + if 'moduleId' in location: + return get_labware_name(location['moduleId'], json_data['modules'], json_data) + + # Check if location contains 'labwareId' + elif 'labwareId' in location: + return get_labware_name(location['labwareId'], json_data['labware'], json_data) + + return " Labware not found" + +def parse_results_volume(json_data_file: str) -> Tuple[ + List[str], List[str], List[str], List[str], + List[str], List[str], List[str], List[str], + List[str], List[str], List[str] + ]: + json_data = [] + with open(json_data_file, "r") as json_file: + json_data = json.load(json_file) + commands = json_data.get("commands", []) + start_time = datetime.fromisoformat(commands[0]["createdAt"]) + end_time = datetime.fromisoformat(commands[len(commands)-1]["completedAt"]) + header = ["", "Protocol Name", "Date", "Time"] + header_fill_row = ["", protocol_name, str(file_date.date()), str(file_date.time())] + labware_names_row =["Labware Name"] + volume_dispensed_row =["Total Volume Dispensed uL"] + volume_aspirated_row =["Total Volume Aspirated uL"] + change_in_volume_row = ["Total Change in Volume uL"] + start_time_row = ["Start Time"] + end_time_row = ["End Time"] + total_time_row = ["Total Time of Execution"] + metrics_row = [ + "Metric", + "Heatershaker # of Latch Open/Close", + "Heatershaker # of Homes", + "Heatershaker # of Rotations", + "Heatershaker Temp On Time (sec)", + "Temp Module # of Temp Changes", + "Temp Module Temp On Time (sec)", + "Temp Mod Time to 4C (sec)", + "Thermocycler # of Lid Open/Close", + "Thermocycler Block # of Temp Changes", + "Thermocycler Block Temp On Time (sec)", + "Thermocycler Block Time to 4C (sec)", + "Thermocycler Lid # of Temp Changes", + "Thermocycler Lid Temp On Time (sec)", + "Thermocycler Lid Time to 105C (sec)", + "Plate Reader # of Reads", + "Plate Reader Avg Read Time (sec)", + "Plate Reader # of Initializations", + "Plate Reader Avg Initialize Time (sec)", + "Plate Reader # of Lid Movements", + "Plate Reader Result", + "Left Pipette Total Tip Pick Up(s)", + "Left Pipette Total Aspirates", + "Left Pipette Total Dispenses", + "Right Pipette Total Tip Pick Up(s)", + "Right Pipette Total Aspirates", + "Right Pipette Total Dispenses", + "Gripper Pick Ups", + "Total Liquid Probes", + "Average Liquid Probe Time (sec)", + ] + values_row = ["Value"] + labware_well_dict = {} + hs_dict, temp_module_dict, thermo_cycler_dict, plate_reader_dict, instrument_dict = {}, {}, {}, {}, {} + try: + hs_dict = read_robot_logs.hs_commands(json_data) + temp_module_dict = read_robot_logs.temperature_module_commands(json_data) + thermo_cycler_dict = read_robot_logs.thermocycler_commands(json_data) + plate_reader_dict = read_robot_logs.plate_reader_commands(json_data, hellma_plate_standards) + instrument_dict = read_robot_logs.instrument_commands(json_data) + except: + pass + + metrics = [hs_dict, temp_module_dict, thermo_cycler_dict, plate_reader_dict, instrument_dict] + + # Iterate through all the commands executed in the protocol run log + for x, command in enumerate(commands): + if x != 0: + prev_command = commands[x-1] + if command["commandType"] == "aspirate": + if not (prev_command["commandType"] == "comment" and (prev_command['params']['message'] == "AIR GAP" or prev_command['params']['message'] == "MIXING")): + labware_id = command["params"]["labwareId"] + labware_name = "" + for labware in json_data.get("labware"): + if labware["id"] == labware_id: + labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) + well_name = command["params"]["wellName"] + + if labware_id not in labware_well_dict: + labware_well_dict[labware_id] = {} + + if well_name not in labware_well_dict[labware_id]: + labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") + + vol = int(command["params"]["volume"]) + + labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] + + subtracted_volumes += vol + log+=(f"aspirated {vol} ") + labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) + elif command["commandType"] == "dispense": + if not (prev_command["commandType"] == "comment" and (prev_command['params']['message'] == "MIXING")): + labware_id = command["params"]["labwareId"] + labware_name = "" + for labware in json_data.get("labware"): + if labware["id"] == labware_id: + labware_name = (labware["loadName"]) + get_labware_name(labware["id"], json_data["labware"], json_data) + well_name = command["params"]["wellName"] + + if labware_id not in labware_well_dict: + labware_well_dict[labware_id] = {} + + if well_name not in labware_well_dict[labware_id]: + labware_well_dict[labware_id][well_name] = (labware_name, 0, 0, "") + + vol = int(command["params"]["volume"]) + labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well_name] + added_volumes += vol + log+=(f"dispensed {vol} ") + labware_well_dict[labware_id][well_name] = (labware_name, added_volumes, subtracted_volumes, log) + # file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") + with open(f"{os.path.dirname(json_data_file)}\\{protocol_name}_well_volumes_{file_date_formatted}.json", "w") as output_file: + json.dump(labware_well_dict, output_file) + output_file.close() + + # populate row lists + for labware_id in labware_well_dict.keys(): + volume_added = 0 + volume_subtracted = 0 + labware_name ="" + for well in labware_well_dict[labware_id].keys(): + labware_name, added_volumes, subtracted_volumes, log = labware_well_dict[labware_id][well] + volume_added += added_volumes + volume_subtracted += subtracted_volumes + labware_names_row.append(labware_name) + volume_dispensed_row.append(str(volume_added)) + volume_aspirated_row.append(str(volume_subtracted)) + change_in_volume_row.append(str(volume_added - volume_subtracted)) + start_time_row.append(str(start_time.time())) + end_time_row.append(str(end_time.time())) + total_time_row.append(str(end_time - start_time)) + + for metric in metrics: + for cmd in metric.keys(): + values_row.append(str(metric[cmd])) + return( + header, + header_fill_row, + labware_names_row, + volume_dispensed_row, + volume_aspirated_row, + change_in_volume_row, + start_time_row, + end_time_row, + total_time_row, + metrics_row, + values_row) + +def main(storage_directory, google_sheet_name, protocol_file_path): + sys.exit = mock_exit + + # Read file path from arguments + protocol_file_path = Path(protocol_file_path) + global protocol_name + protocol_name = protocol_file_path.stem + print("Simulating", protocol_name) + global file_date + file_date = datetime.now() + global file_date_formatted + file_date_formatted = file_date.strftime("%Y-%m-%d_%H-%M-%S") + # Prepare output file + json_file_path = f"{storage_directory}\\{protocol_name}_{file_date_formatted}.json" + json_file_output = open(json_file_path, "wb+") + error_output = f"{storage_directory}\\error_log" + # Run protocol simulation + try: + with Context(analyze) as ctx: + ctx.invoke( + analyze, + files=[protocol_file_path], + json_output=json_file_output, + human_json_output=None, + log_output=error_output, + log_level="ERROR", + check=False + ) + except SystemExit as e: + print(f"SystemExit caught with code: {e}") + finally: + sys.exit = original_exit + json_file_output.close() + with open(error_output, "r") as open_file: + try: + errors = open_file.readlines() + if not errors: pass + else: + print(errors) + sys.exit(1) + except: + print("error simulating ...") + sys.exit() + + try: + credentials_path = os.path.join(storage_directory, "credentials.json") + print(credentials_path) + except FileNotFoundError: + print(f"Add credentials.json file to: {storage_directory}.") + sys.exit() + + global hellma_plate_standards + + try: + hellma_plate_standards = plate_reader.read_hellma_plate_files(storage_directory, 101934) + except: + print(f"Add helma plate standard files to {storage_directory}.") + sys.exit() + + google_sheet = google_sheets_tool.google_sheet( + credentials_path, google_sheet_name, 0 + ) + + google_sheet.write_to_row([]) + + for row in parse_results_volume(json_file_path): + print("Writing results to", google_sheet_name) + print(str(row)) + google_sheet.write_to_row(row) + +if __name__ == "__main__": + CLEAN_PROTOCOL = True + parser = argparse.ArgumentParser(description="Read run logs on google drive.") + parser.add_argument( + "storage_directory", + metavar="STORAGE_DIRECTORY", + type=str, + nargs=1, + help="Path to long term storage directory for run logs.", + ) + parser.add_argument( + "sheet_name", + metavar="SHEETNAME", + type=str, + nargs=1, + help="Name of sheet to upload results to", + ) + parser.add_argument( + "protocol_file_path", + metavar="PROTOCOL_FILE_PATH", + type=str, + nargs=1, + help="Path to protocol file" + + ) + args = parser.parse_args() + storage_directory = args.storage_directory[0] + sheet_name = args.sheet_name[0] + protocol_file_path = args.protocol_file_path[0] + + SETUP = True + while(SETUP): + print("This current version cannot properly handle air gap calls.\nThese may cause simulation results to be inaccurate") + air_gaps = look_for_air_gaps(protocol_file_path) + if air_gaps > 0: + choice = "" + while not choice: + choice = input("This protocol contains air gaps, results may be innacurate, would you like to continue? (Y/N): ") + if choice.upper() == "Y": + SETUP = False + CLEAN_PROTOCOL = True + elif choice.upper() == "N": + CLEAN_PROTOCOL = False + SETUP = False + print("Please remove air gaps then re-run") + else: + choice = "" + print("Please enter a valid response.") + SETUP = False + + if CLEAN_PROTOCOL: + main( + storage_directory, + sheet_name, + protocol_file_path, + ) + else: sys.exit(0) \ No newline at end of file diff --git a/hardware-testing/hardware_testing/drivers/__init__.py b/hardware-testing/hardware_testing/drivers/__init__.py index fde7e228d9b..f1b4c991e2c 100644 --- a/hardware-testing/hardware_testing/drivers/__init__.py +++ b/hardware-testing/hardware_testing/drivers/__init__.py @@ -15,28 +15,23 @@ def list_ports_and_select(device_name: str = "", port_substr: str = None) -> str idx_str = "" for i, p in enumerate(ports): print(f"\t{i + 1}) {p.device}") - if port_substr: - for i, p in enumerate(ports): - if port_substr in p.device: - idx = i + 1 - break - else: - idx_str = input( - f"\nenter number next to {device_name} port (or ENTER to re-scan): " - ) - if not idx_str: - return list_ports_and_select(device_name) - if not device_name: - device_name = "desired" - - try: + if port_substr: + for i, p in enumerate(ports): + if port_substr in p.device: + return p.device + + while True: + idx_str = input( + f"\nEnter number next to {device_name} port (or ENTER to re-scan): " + ) + if not idx_str: + return list_ports_and_select(device_name, port_substr) + try: idx = int(idx_str.strip()) - except TypeError: - pass - return ports[idx - 1].device - except (ValueError, IndexError): - return list_ports_and_select() + return ports[idx - 1].device + except (ValueError, IndexError): + print("Invalid selection. Please try again.") def find_port(vid: int, pid: int) -> str: diff --git a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py index f2c00e015d3..1e8fca0358c 100644 --- a/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py +++ b/hardware-testing/hardware_testing/scripts/abr_asair_sensor.py @@ -26,7 +26,7 @@ def __init__(self, robot: str, duration: int, frequency: int) -> None: test_name = "ABR-Environment-Monitoring" run_id = data.create_run_id() file_name = data.create_file_name(test_name, run_id, robot) - sensor = asair_sensor.BuildAsairSensor(False, False, "USB") + sensor = asair_sensor.BuildAsairSensor(False, False, "USB0") print(sensor) env_data = sensor.get_reading() header = [ From e9dc78a40ce0bca5857c3e130915454923c88784 Mon Sep 17 00:00:00 2001 From: Anthony Ngumah <68346382+AnthonyNASC20@users.noreply.github.com> Date: Wed, 9 Oct 2024 09:35:42 -0400 Subject: [PATCH 020/101] feat(abr-testing): Protocol simulator, utilizes opentrons CLI to simulate and record information regarding a protocol. (#16433) # Overview Utilizes Opentrons CLI to simulate protocols and get information including; expected change in volume, deck labware, and module usage insights. ## Test Plan and Hands on Testing Tested tool using all abr protocols in the test-protocols folder as well as self made test-protocols to verify the accuracy of the results. ## Changelog Protocol will now comment if an aspiration belongs to air gap, to indicate that the volume should not change ## Risk assessment Low risk, this protocol simulation tool is still in it's beta, and does not change overall functionality of its dependencies. From 1f50f1d2fb5701dd346515283ed61de8ae380a60 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Wed, 9 Oct 2024 11:09:09 -0500 Subject: [PATCH 021/101] fix(abt): move to 3.13 stable (#16442) # Overview Moved to `3.13.0-rc.3` yesterday, today the stable is available in the runners. I speculate this will solve the issues with python not being mapped in https://github.com/Opentrons/opentrons/actions/runs/11243746409/job/31260344370?pr=16439 --- .github/workflows/analyses-snapshot-lint.yaml | 2 +- .github/workflows/analyses-snapshot-test.yaml | 2 +- analyses-snapshot-testing/mypy.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/analyses-snapshot-lint.yaml b/.github/workflows/analyses-snapshot-lint.yaml index 17e13e30868..7a51a5e976a 100644 --- a/.github/workflows/analyses-snapshot-lint.yaml +++ b/.github/workflows/analyses-snapshot-lint.yaml @@ -27,7 +27,7 @@ jobs: - name: Setup Python uses: 'actions/setup-python@v5' with: - python-version: '3.13.0-rc.3' + python-version: '3.13.0' cache: 'pipenv' cache-dependency-path: analyses-snapshot-testing/Pipfile.lock - name: Setup diff --git a/.github/workflows/analyses-snapshot-test.yaml b/.github/workflows/analyses-snapshot-test.yaml index 7770db0d286..09539d873e9 100644 --- a/.github/workflows/analyses-snapshot-test.yaml +++ b/.github/workflows/analyses-snapshot-test.yaml @@ -78,7 +78,7 @@ jobs: - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: '3.13.0-rc.3' + python-version: '3.13.0' cache: 'pipenv' cache-dependency-path: analyses-snapshot-testing/Pipfile.lock diff --git a/analyses-snapshot-testing/mypy.ini b/analyses-snapshot-testing/mypy.ini index cab126eb42d..d5e1e97f945 100644 --- a/analyses-snapshot-testing/mypy.ini +++ b/analyses-snapshot-testing/mypy.ini @@ -7,7 +7,7 @@ disallow_any_generics = true check_untyped_defs = true no_implicit_reexport = true exclude = "__init__.py" -python_version = 3.12 +python_version = 3.13 plugins = pydantic.mypy [pydantic-mypy] From 50d3208120b5b94b08805a81ab24aa4309e9c921 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 9 Oct 2024 13:52:11 -0400 Subject: [PATCH 022/101] docs(robot-server): Fix labware router response bodies (#16444) ## Overview The `GET /runs/{id}/loaded_labware_definitions` and `POST /runs/{id}/labware_definitions` endpoints were accidentally documented in OpenAPI as returning the *run,* not the labware definition. This fixes that. ## Review requests * Documented return types match actual return types? * OK with the `SimpleBody[list[...]]` thing? ## Risk assessment Low. --- .../robot_server/runs/router/labware_router.py | 10 ++++------ robot-server/robot_server/service/json_api/__init__.py | 2 -- robot-server/robot_server/service/json_api/response.py | 6 ------ robot-server/tests/runs/router/test_labware_router.py | 2 +- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/robot-server/robot_server/runs/router/labware_router.py b/robot-server/robot_server/runs/router/labware_router.py index 7eba96afa0e..16924fd4ae8 100644 --- a/robot-server/robot_server/runs/router/labware_router.py +++ b/robot-server/robot_server/runs/router/labware_router.py @@ -16,7 +16,6 @@ RequestModel, SimpleBody, PydanticResponse, - ResponseList, ) from ..run_models import Run, LabwareDefinitionSummary @@ -86,7 +85,7 @@ async def add_labware_offset( ), status_code=status.HTTP_201_CREATED, responses={ - status.HTTP_201_CREATED: {"model": SimpleBody[Run]}, + status.HTTP_201_CREATED: {"model": SimpleBody[LabwareDefinitionSummary]}, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]}, status.HTTP_409_CONFLICT: {"model": ErrorBody[Union[RunStopped, RunNotIdle]]}, }, @@ -134,14 +133,14 @@ async def add_labware_definition( " Repeated definitions will be deduplicated." ), responses={ - status.HTTP_200_OK: {"model": SimpleBody[Run]}, + status.HTTP_200_OK: {"model": SimpleBody[list[SD_LabwareDefinition]]}, status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, }, ) async def get_run_loaded_labware_definitions( runId: str, run_data_manager: Annotated[RunDataManager, Depends(get_run_data_manager)], -) -> PydanticResponse[SimpleBody[ResponseList[SD_LabwareDefinition]]]: +) -> PydanticResponse[SimpleBody[list[SD_LabwareDefinition]]]: """Get a run's loaded labware definition by the run ID. Args: @@ -155,8 +154,7 @@ async def get_run_loaded_labware_definitions( except RunNotCurrentError as e: raise RunStopped(detail=str(e)).as_error(status.HTTP_409_CONFLICT) from e - labware_definitions_result = ResponseList.construct(__root__=labware_definitions) return await PydanticResponse.create( - content=SimpleBody.construct(data=labware_definitions_result), + content=SimpleBody.construct(data=labware_definitions), status_code=status.HTTP_200_OK, ) diff --git a/robot-server/robot_server/service/json_api/__init__.py b/robot-server/robot_server/service/json_api/__init__.py index 2680c99049f..78a9deeaa4d 100644 --- a/robot-server/robot_server/service/json_api/__init__.py +++ b/robot-server/robot_server/service/json_api/__init__.py @@ -14,7 +14,6 @@ DeprecatedResponseDataModel, ResourceModel, PydanticResponse, - ResponseList, NotifyRefetchBody, NotifyUnsubscribeBody, ) @@ -44,7 +43,6 @@ "DeprecatedResponseDataModel", "DeprecatedResponseModel", "DeprecatedMultiResponseModel", - "ResponseList", # notify models "NotifyRefetchBody", "NotifyUnsubscribeBody", diff --git a/robot-server/robot_server/service/json_api/response.py b/robot-server/robot_server/service/json_api/response.py index e1e422f255c..8764d8edd53 100644 --- a/robot-server/robot_server/service/json_api/response.py +++ b/robot-server/robot_server/service/json_api/response.py @@ -278,12 +278,6 @@ class DeprecatedMultiResponseModel( ) -class ResponseList(BaseModel, Generic[ResponseDataT]): - """A response that returns a list resource.""" - - __root__: List[ResponseDataT] - - class NotifyRefetchBody(BaseResponseBody): """A notification response that returns a flag for refetching via HTTP.""" diff --git a/robot-server/tests/runs/router/test_labware_router.py b/robot-server/tests/runs/router/test_labware_router.py index 9a38ce6cd0f..1e3b929446d 100644 --- a/robot-server/tests/runs/router/test_labware_router.py +++ b/robot-server/tests/runs/router/test_labware_router.py @@ -169,7 +169,7 @@ async def test_get_run_labware_definition( runId="run-id", run_data_manager=mock_run_data_manager ) - assert result.content.data.__root__ == [ + assert result.content.data == [ SD_LabwareDefinition.construct(namespace="test_1"), # type: ignore[call-arg] SD_LabwareDefinition.construct(namespace="test_2"), # type: ignore[call-arg] ] From 7f6506ffc6f6464172aa331dbbe49725f2aeae8a Mon Sep 17 00:00:00 2001 From: Caila Marashaj <98041399+caila-marashaj@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:22:09 -0400 Subject: [PATCH 023/101] refactor(api): redefine well geometry structure (#16392) ## Overview After discovering some new shapes and generally interacting with the new well geometry data structures, I think it would be better to reshape the geometry data a little bit. Rather than having each section of a well be represented by its top cross-section and top height, let's just represent a section in its entirety, with bottom and top cross-sections and bottom and top heights being present in every shape that is not a `SphericalSegment`. ## Changelog - add `RoundedRectangle` class - add `TruncatedCircle` class - add `CircularFrustum` and `RectangularFrustum` classes - adjust `frustum_helpers` and tests to use the new data structure ## TODO - We should [write some more tests](https://opentrons.atlassian.net/browse/EXEC-743?atlOrigin=eyJpIjoiYzg5OThhMjQ2NTViNDRmNGI2OTkwMWEwYTExMmFjNjIiLCJwIjoiaiJ9) to make sure invalid wells don't get passed in without an error being raised. - Implement the math for [truncated circle](https://opentrons.atlassian.net/browse/EXEC-712) calculations - Implement the math for [rounded rectangle](https://opentrons.atlassian.net/browse/EXEC-744) calculations --------- Co-authored-by: Ryan howard --- .../protocol_engine/state/frustum_helpers.py | 376 ++++++++---------- .../state/test_geometry_view.py | 16 +- .../protocol_runner/test_json_translator.py | 39 +- .../geometry/test_frustum_helpers.py | 247 ++++++++---- .../js/__tests__/labwareDefSchemaV3.test.ts | 6 +- shared-data/js/types.ts | 52 ++- .../labware/fixtures/3/fixture_2_plate.json | 43 +- .../fixtures/3/fixture_corning_24_plate.json | 15 +- shared-data/labware/schemas/3.json | 161 ++++++-- .../labware/constants.py | 13 + .../labware/labware_definition.py | 234 ++++++++++- .../opentrons_shared_data/labware/types.py | 51 +-- 12 files changed, 790 insertions(+), 463 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 27e417aa8b4..4f132ac3b40 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -1,19 +1,20 @@ """Helper functions for liquid-level related calculations inside a given frustum.""" -from typing import List, Tuple, Iterator, Sequence, Any, Union, Optional +from typing import List, Tuple from numpy import pi, iscomplex, roots, real from math import isclose -from ..errors.exceptions import InvalidLiquidHeightFound, InvalidWellDefinitionError -from opentrons_shared_data.labware.types import ( - is_circular_frusta_list, - is_rectangular_frusta_list, - CircularBoundedSection, - RectangularBoundedSection, +from ..errors.exceptions import InvalidLiquidHeightFound + +from opentrons_shared_data.labware.labware_definition import ( + InnerWellGeometry, + WellSegment, + SphericalSegment, + ConicalFrustum, + CuboidalFrustum, ) -from opentrons_shared_data.labware.labware_definition import InnerWellGeometry -def reject_unacceptable_heights( +def _reject_unacceptable_heights( potential_heights: List[float], max_height: float ) -> float: """Reject any solutions to a polynomial equation that cannot be the height of a frustum.""" @@ -33,34 +34,18 @@ def reject_unacceptable_heights( return valid_heights[0] -def get_cross_section_area( - bounded_section: Union[CircularBoundedSection, RectangularBoundedSection] -) -> float: - """Find the shape of a cross-section and calculate the area appropriately.""" - if bounded_section["shape"] == "circular": - cross_section_area = cross_section_area_circular(bounded_section["diameter"]) - elif bounded_section["shape"] == "rectangular": - cross_section_area = cross_section_area_rectangular( - bounded_section["xDimension"], - bounded_section["yDimension"], - ) - else: - raise InvalidWellDefinitionError(message="Invalid well volume components.") - return cross_section_area - - -def cross_section_area_circular(diameter: float) -> float: +def _cross_section_area_circular(diameter: float) -> float: """Get the area of a circular cross-section.""" radius = diameter / 2 return pi * (radius**2) -def cross_section_area_rectangular(x_dimension: float, y_dimension: float) -> float: +def _cross_section_area_rectangular(x_dimension: float, y_dimension: float) -> float: """Get the area of a rectangular cross-section.""" return x_dimension * y_dimension -def rectangular_frustum_polynomial_roots( +def _rectangular_frustum_polynomial_roots( bottom_length: float, bottom_width: float, top_length: float, @@ -82,7 +67,7 @@ def rectangular_frustum_polynomial_roots( return a, b, c -def circular_frustum_polynomial_roots( +def _circular_frustum_polynomial_roots( bottom_radius: float, top_radius: float, total_frustum_height: float, @@ -95,14 +80,14 @@ def circular_frustum_polynomial_roots( return a, b, c -def volume_from_height_circular( +def _volume_from_height_circular( target_height: float, total_frustum_height: float, bottom_radius: float, top_radius: float, ) -> float: """Find the volume given a height within a circular frustum.""" - a, b, c = circular_frustum_polynomial_roots( + a, b, c = _circular_frustum_polynomial_roots( bottom_radius=bottom_radius, top_radius=top_radius, total_frustum_height=total_frustum_height, @@ -111,7 +96,7 @@ def volume_from_height_circular( return volume -def volume_from_height_rectangular( +def _volume_from_height_rectangular( target_height: float, total_frustum_height: float, bottom_length: float, @@ -120,7 +105,7 @@ def volume_from_height_rectangular( top_width: float, ) -> float: """Find the volume given a height within a rectangular frustum.""" - a, b, c = rectangular_frustum_polynomial_roots( + a, b, c = _rectangular_frustum_polynomial_roots( bottom_length=bottom_length, bottom_width=bottom_width, top_length=top_length, @@ -131,7 +116,7 @@ def volume_from_height_rectangular( return volume -def volume_from_height_spherical( +def _volume_from_height_spherical( target_height: float, radius_of_curvature: float, ) -> float: @@ -142,14 +127,14 @@ def volume_from_height_spherical( return volume -def height_from_volume_circular( +def _height_from_volume_circular( volume: float, total_frustum_height: float, bottom_radius: float, top_radius: float, ) -> float: """Find the height given a volume within a circular frustum.""" - a, b, c = circular_frustum_polynomial_roots( + a, b, c = _circular_frustum_polynomial_roots( bottom_radius=bottom_radius, top_radius=top_radius, total_frustum_height=total_frustum_height, @@ -158,14 +143,14 @@ def height_from_volume_circular( x_intercept_roots = (a, b, c, d) height_from_volume_roots = roots(x_intercept_roots) - height = reject_unacceptable_heights( + height = _reject_unacceptable_heights( potential_heights=list(height_from_volume_roots), max_height=total_frustum_height, ) return height -def height_from_volume_rectangular( +def _height_from_volume_rectangular( volume: float, total_frustum_height: float, bottom_length: float, @@ -174,7 +159,7 @@ def height_from_volume_rectangular( top_width: float, ) -> float: """Find the height given a volume within a rectangular frustum.""" - a, b, c = rectangular_frustum_polynomial_roots( + a, b, c = _rectangular_frustum_polynomial_roots( bottom_length=bottom_length, bottom_width=bottom_width, top_length=top_length, @@ -185,14 +170,14 @@ def height_from_volume_rectangular( x_intercept_roots = (a, b, c, d) height_from_volume_roots = roots(x_intercept_roots) - height = reject_unacceptable_heights( + height = _reject_unacceptable_heights( potential_heights=list(height_from_volume_roots), max_height=total_frustum_height, ) return height -def height_from_volume_spherical( +def _height_from_volume_spherical( volume: float, radius_of_curvature: float, total_frustum_height: float, @@ -205,20 +190,43 @@ def height_from_volume_spherical( x_intercept_roots = (a, b, c, d) height_from_volume_roots = roots(x_intercept_roots) - height = reject_unacceptable_heights( + height = _reject_unacceptable_heights( potential_heights=list(height_from_volume_roots), max_height=total_frustum_height, ) return height -def get_boundary_pairs(frusta: Sequence[Any]) -> Iterator[Tuple[Any, Any]]: - """Yield tuples representing two cross-section boundaries of a segment of a well.""" - iter_f = iter(frusta) - el = next(iter_f) - for next_el in iter_f: - yield el, next_el - el = next_el +def _get_segment_capacity(segment: WellSegment) -> float: + match segment: + case SphericalSegment(): + return _volume_from_height_spherical( + target_height=segment.topHeight, + radius_of_curvature=segment.radiusOfCurvature, + ) + case CuboidalFrustum(): + section_height = segment.topHeight - segment.bottomHeight + return _volume_from_height_rectangular( + target_height=section_height, + bottom_length=segment.bottomYDimension, + bottom_width=segment.bottomXDimension, + top_length=segment.topYDimension, + top_width=segment.topXDimension, + total_frustum_height=section_height, + ) + case ConicalFrustum(): + section_height = segment.topHeight - segment.bottomHeight + return _volume_from_height_circular( + target_height=section_height, + total_frustum_height=section_height, + bottom_radius=(segment.bottomDiameter / 2), + top_radius=(segment.topDiameter / 2), + ) + case _: + # TODO: implement volume calculations for truncated circular and rounded rectangular segments + raise NotImplementedError( + f"volume calculation for shape: {segment.shape} not yet implemented." + ) def get_well_volumetric_capacity( @@ -228,140 +236,105 @@ def get_well_volumetric_capacity( # dictionary map of heights to volumetric capacities within their respective segment # {top_height_0: volume_0, top_height_1: volume_1, top_height_2: volume_2} well_volume = [] - if well_geometry.bottomShape is not None: - if well_geometry.bottomShape.shape == "spherical": - bottom_spherical_section_depth = well_geometry.bottomShape.depth - bottom_sphere_volume = volume_from_height_spherical( - radius_of_curvature=well_geometry.bottomShape.radiusOfCurvature, - target_height=bottom_spherical_section_depth, - ) - well_volume.append((bottom_spherical_section_depth, bottom_sphere_volume)) - - # get the volume of remaining frusta sorted in ascending order - sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight) - - if is_rectangular_frusta_list(sorted_frusta): - for f, next_f in get_boundary_pairs(sorted_frusta): - top_cross_section_width = next_f["xDimension"] - top_cross_section_length = next_f["yDimension"] - bottom_cross_section_width = f["xDimension"] - bottom_cross_section_length = f["yDimension"] - frustum_height = next_f["topHeight"] - f["topHeight"] - frustum_volume = volume_from_height_rectangular( - target_height=frustum_height, - total_frustum_height=frustum_height, - bottom_length=bottom_cross_section_length, - bottom_width=bottom_cross_section_width, - top_length=top_cross_section_length, - top_width=top_cross_section_width, - ) - well_volume.append((next_f["topHeight"], frustum_volume)) - elif is_circular_frusta_list(sorted_frusta): - for f, next_f in get_boundary_pairs(sorted_frusta): - top_cross_section_radius = next_f["diameter"] / 2.0 - bottom_cross_section_radius = f["diameter"] / 2.0 - frustum_height = next_f["topHeight"] - f["topHeight"] - frustum_volume = volume_from_height_circular( - target_height=frustum_height, - total_frustum_height=frustum_height, - bottom_radius=bottom_cross_section_radius, - top_radius=top_cross_section_radius, - ) + # get the well segments sorted in ascending order + sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight) - well_volume.append((next_f["topHeight"], frustum_volume)) - else: - raise NotImplementedError( - "Well section with differing boundary shapes not yet implemented." - ) + for segment in sorted_well: + section_volume = _get_segment_capacity(segment) + well_volume.append((segment.topHeight, section_volume)) return well_volume def height_at_volume_within_section( - top_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], - bottom_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], + section: WellSegment, target_volume_relative: float, - frustum_height: float, + section_height: float, ) -> float: """Calculate a height within a bounded section according to geometry.""" - if top_cross_section["shape"] == bottom_cross_section["shape"] == "circular": - frustum_height = height_from_volume_circular( - volume=target_volume_relative, - top_radius=(top_cross_section["diameter"] / 2), - bottom_radius=(bottom_cross_section["diameter"] / 2), - total_frustum_height=frustum_height, - ) - elif top_cross_section["shape"] == bottom_cross_section["shape"] == "rectangular": - frustum_height = height_from_volume_rectangular( - volume=target_volume_relative, - total_frustum_height=frustum_height, - bottom_width=bottom_cross_section["xDimension"], - bottom_length=bottom_cross_section["yDimension"], - top_width=top_cross_section["xDimension"], - top_length=top_cross_section["yDimension"], - ) - else: - raise NotImplementedError( - "Height from volume calculation not yet implemented for this well shape." - ) - return frustum_height + match section: + case SphericalSegment(): + return _height_from_volume_spherical( + volume=target_volume_relative, + total_frustum_height=section_height, + radius_of_curvature=section.radiusOfCurvature, + ) + case ConicalFrustum(): + return _height_from_volume_circular( + volume=target_volume_relative, + top_radius=(section.bottomDiameter / 2), + bottom_radius=(section.topDiameter / 2), + total_frustum_height=section_height, + ) + case CuboidalFrustum(): + return _height_from_volume_rectangular( + volume=target_volume_relative, + total_frustum_height=section_height, + bottom_width=section.bottomXDimension, + bottom_length=section.bottomYDimension, + top_width=section.topXDimension, + top_length=section.topYDimension, + ) + case _: + raise NotImplementedError( + "Height from volume calculation not yet implemented for this well shape." + ) def volume_at_height_within_section( - top_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], - bottom_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], + section: WellSegment, target_height_relative: float, - frustum_height: float, + section_height: float, ) -> float: """Calculate a volume within a bounded section according to geometry.""" - if top_cross_section["shape"] == bottom_cross_section["shape"] == "circular": - frustum_volume = volume_from_height_circular( - target_height=target_height_relative, - total_frustum_height=frustum_height, - bottom_radius=(bottom_cross_section["diameter"] / 2), - top_radius=(top_cross_section["diameter"] / 2), - ) - elif top_cross_section["shape"] == bottom_cross_section["shape"] == "rectangular": - frustum_volume = volume_from_height_rectangular( - target_height=target_height_relative, - total_frustum_height=frustum_height, - bottom_width=bottom_cross_section["xDimension"], - bottom_length=bottom_cross_section["yDimension"], - top_width=top_cross_section["xDimension"], - top_length=top_cross_section["yDimension"], - ) - # TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712 - # we need to input the math attached to that issue - else: - raise NotImplementedError( - "Height from volume calculation not yet implemented for this well shape." - ) - return frustum_volume + match section: + case SphericalSegment(): + return _volume_from_height_spherical( + target_height=target_height_relative, + radius_of_curvature=section.radiusOfCurvature, + ) + case ConicalFrustum(): + return _volume_from_height_circular( + target_height=target_height_relative, + total_frustum_height=section_height, + bottom_radius=(section.bottomDiameter / 2), + top_radius=(section.topDiameter / 2), + ) + case CuboidalFrustum(): + return _volume_from_height_rectangular( + target_height=target_height_relative, + total_frustum_height=section_height, + bottom_width=section.bottomXDimension, + bottom_length=section.bottomYDimension, + top_width=section.topXDimension, + top_length=section.topYDimension, + ) + case _: + # TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712 + # we need to input the math attached to that issue + raise NotImplementedError( + "Height from volume calculation not yet implemented for this well shape." + ) def _find_volume_in_partial_frustum( - sorted_frusta: List[Any], + sorted_well: List[WellSegment], target_height: float, -) -> Optional[float]: +) -> float: """Look through a sorted list of frusta for a target height, and find the volume at that height.""" - partial_volume: Optional[float] = None - for bottom_cross_section, top_cross_section in get_boundary_pairs(sorted_frusta): - if ( - bottom_cross_section["topHeight"] - < target_height - < top_cross_section["targetHeight"] - ): - relative_target_height = target_height - bottom_cross_section["topHeight"] - frustum_height = ( - top_cross_section["topHeight"] - bottom_cross_section["topHeight"] - ) - partial_volume = volume_at_height_within_section( - top_cross_section=top_cross_section, - bottom_cross_section=bottom_cross_section, + for segment in sorted_well: + if segment.bottomHeight < target_height < segment.topHeight: + relative_target_height = target_height - segment.bottomHeight + section_height = segment.topHeight - segment.bottomHeight + return volume_at_height_within_section( + section=segment, target_height_relative=relative_target_height, - frustum_height=frustum_height, + section_height=section_height, ) - return partial_volume + # if we've looked through all sections and can't find the target volume, raise an error + raise InvalidLiquidHeightFound( + f"Unable to find volume at given well-height {target_height}." + ) def find_volume_at_well_height( @@ -384,53 +357,41 @@ def find_volume_at_well_height( if target_height == boundary_height: return closed_section_volume # find the section the target height is in and compute the volume - # since bottomShape is not in list of frusta, check here first - if well_geometry.bottomShape: - bottom_segment_height = volumetric_capacity[0][0] - if ( - target_height < bottom_segment_height - and well_geometry.bottomShape.shape == "spherical" - ): - return volume_from_height_spherical( - target_height=target_height, - radius_of_curvature=well_geometry.bottomShape.radiusOfCurvature, - ) - sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight) - # TODO(cm): handle non-frustum section that is not at the bottom. + + sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight) partial_volume = _find_volume_in_partial_frustum( - sorted_frusta=sorted_frusta, + sorted_well=sorted_well, target_height=target_height, ) - if not partial_volume: - raise InvalidLiquidHeightFound("Unable to find volume at given well-height.") return partial_volume + closed_section_volume def _find_height_in_partial_frustum( - sorted_frusta: List[Any], + sorted_well: List[WellSegment], volumetric_capacity: List[Tuple[float, float]], target_volume: float, -) -> Optional[float]: +) -> float: """Look through a sorted list of frusta for a target volume, and find the height at that volume.""" - well_height: Optional[float] = None - for cross_sections, capacity in zip( - get_boundary_pairs(sorted_frusta), - get_boundary_pairs(volumetric_capacity), - ): - bottom_cross_section, top_cross_section = cross_sections - (bottom_height, bottom_volume), (top_height, top_volume) = capacity - - if bottom_volume < target_volume < top_volume: - relative_target_volume = target_volume - bottom_volume - frustum_height = top_height - bottom_height + bottom_section_volume = 0.0 + for section, capacity in zip(sorted_well, volumetric_capacity): + section_top_height, section_volume = capacity + if bottom_section_volume < target_volume < section_volume: + relative_target_volume = target_volume - bottom_section_volume + relative_section_height = section.topHeight - section.bottomHeight partial_height = height_at_volume_within_section( - top_cross_section=top_cross_section, - bottom_cross_section=bottom_cross_section, + section=section, target_volume_relative=relative_target_volume, - frustum_height=frustum_height, + section_height=relative_section_height, ) - well_height = partial_height + bottom_height - return well_height + return partial_height + section.bottomHeight + # bottom section volume should always be the volume enclosed in the previously + # viewed section + bottom_section_volume = section_volume + + # if we've looked through all sections and can't find the target volume, raise an error + raise InvalidLiquidHeightFound( + f"Unable to find height at given volume {target_volume}." + ) def find_height_at_well_volume( @@ -442,29 +403,10 @@ def find_height_at_well_volume( if target_volume < 0 or target_volume > max_volume: raise InvalidLiquidHeightFound("Invalid target volume.") - sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight) + sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight) # find the section the target volume is in and compute the height - # since bottomShape is not in list of frusta, check here first - if well_geometry.bottomShape: - volume_within_bottom_segment = volumetric_capacity[0][1] - if ( - target_volume < volume_within_bottom_segment - and well_geometry.bottomShape.shape == "spherical" - ): - return height_from_volume_spherical( - volume=target_volume, - radius_of_curvature=well_geometry.bottomShape.radiusOfCurvature, - total_frustum_height=well_geometry.bottomShape.depth, - ) - # if bottom shape is present but doesn't contain the target volume, - # then we need to look through the volumetric capacity list without the bottom shape - # so volumetric_capacity and sorted_frusta will be aligned - volumetric_capacity.pop(0) - well_height = _find_height_in_partial_frustum( - sorted_frusta=sorted_frusta, + return _find_height_in_partial_frustum( + sorted_well=sorted_well, volumetric_capacity=volumetric_capacity, target_volume=target_volume, ) - if not well_height: - raise InvalidLiquidHeightFound("Unable to find height at given well-volume.") - return well_height diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 6bbd13c5e25..427dececa7b 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -83,10 +83,10 @@ ) from opentrons.protocol_engine.state.geometry import GeometryView, _GripperMoveType from opentrons.protocol_engine.state.frustum_helpers import ( - height_from_volume_circular, - height_from_volume_rectangular, - volume_from_height_circular, - volume_from_height_rectangular, + _height_from_volume_circular, + _height_from_volume_rectangular, + _volume_from_height_circular, + _volume_from_height_rectangular, ) from ..pipette_fixtures import get_default_nozzle_map from ..mock_circular_frusta import TEST_EXAMPLES as CIRCULAR_TEST_EXAMPLES @@ -2776,7 +2776,7 @@ def _find_volume_from_height_(index: int) -> None: top_width = frustum["width"][index] target_height = frustum["height"][index] - found_volume = volume_from_height_rectangular( + found_volume = _volume_from_height_rectangular( target_height=target_height, total_frustum_height=total_frustum_height, top_length=top_length, @@ -2785,7 +2785,7 @@ def _find_volume_from_height_(index: int) -> None: bottom_width=bottom_width, ) - found_height = height_from_volume_rectangular( + found_height = _height_from_volume_rectangular( volume=found_volume, total_frustum_height=total_frustum_height, top_length=top_length, @@ -2815,14 +2815,14 @@ def _find_volume_from_height_(index: int) -> None: top_radius = frustum["radius"][index] target_height = frustum["height"][index] - found_volume = volume_from_height_circular( + found_volume = _volume_from_height_circular( target_height=target_height, total_frustum_height=total_frustum_height, top_radius=top_radius, bottom_radius=bottom_radius, ) - found_height = height_from_volume_circular( + found_height = _height_from_volume_circular( volume=found_volume, total_frustum_height=total_frustum_height, top_radius=top_radius, diff --git a/api/tests/opentrons/protocol_runner/test_json_translator.py b/api/tests/opentrons/protocol_runner/test_json_translator.py index a583fcbf1c4..afaf105f347 100644 --- a/api/tests/opentrons/protocol_runner/test_json_translator.py +++ b/api/tests/opentrons/protocol_runner/test_json_translator.py @@ -13,7 +13,7 @@ Group, Metadata1, WellDefinition, - RectangularBoundedSection, + CuboidalFrustum, InnerWellGeometry, SphericalSegment, ) @@ -685,32 +685,39 @@ def _load_labware_definition_data() -> LabwareDefinition: y=75.43, z=75, totalLiquidVolume=1100000, - shape="rectangular", + shape="circular", ) }, dimensions=Dimensions(yDimension=85.5, zDimension=100, xDimension=127.75), cornerOffsetFromSlot=CornerOffsetFromSlot(x=0, y=0, z=0), innerLabwareGeometry={ "welldefinition1111": InnerWellGeometry( - frusta=[ - RectangularBoundedSection( - shape="rectangular", - xDimension=7.6, - yDimension=8.5, + sections=[ + CuboidalFrustum( + shape="cuboidal", + topXDimension=7.6, + topYDimension=8.5, + bottomXDimension=5.6, + bottomYDimension=6.5, topHeight=45, + bottomHeight=20, ), - RectangularBoundedSection( - shape="rectangular", - xDimension=5.6, - yDimension=6.5, + CuboidalFrustum( + shape="cuboidal", + topXDimension=5.6, + topYDimension=6.5, + bottomXDimension=4.5, + bottomYDimension=4.0, topHeight=20, + bottomHeight=10, + ), + SphericalSegment( + shape="spherical", + radiusOfCurvature=6, + topHeight=10, + bottomHeight=0.0, ), ], - bottomShape=SphericalSegment( - shape="spherical", - radiusOfCurvature=6, - depth=10, - ), ) }, brand=BrandData(brand="foo"), diff --git a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py index 0bf74aae5b2..0b8d3429527 100644 --- a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py +++ b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py @@ -2,24 +2,25 @@ from math import pi, isclose from typing import Any, List -from opentrons_shared_data.labware.types import ( - RectangularBoundedSection, - CircularBoundedSection, +from opentrons_shared_data.labware.labware_definition import ( + ConicalFrustum, + CuboidalFrustum, SphericalSegment, ) from opentrons.protocol_engine.state.frustum_helpers import ( - cross_section_area_rectangular, - cross_section_area_circular, - reject_unacceptable_heights, - get_boundary_pairs, - circular_frustum_polynomial_roots, - rectangular_frustum_polynomial_roots, - volume_from_height_rectangular, - volume_from_height_circular, - volume_from_height_spherical, - height_from_volume_circular, - height_from_volume_rectangular, - height_from_volume_spherical, + _cross_section_area_rectangular, + _cross_section_area_circular, + _reject_unacceptable_heights, + _circular_frustum_polynomial_roots, + _rectangular_frustum_polynomial_roots, + _volume_from_height_rectangular, + _volume_from_height_circular, + _volume_from_height_spherical, + _height_from_volume_circular, + _height_from_volume_rectangular, + _height_from_volume_spherical, + height_at_volume_within_section, + _get_segment_capacity, ) from opentrons.protocol_engine.errors.exceptions import InvalidLiquidHeightFound @@ -29,59 +30,130 @@ def fake_frusta() -> List[List[Any]]: frusta = [] frusta.append( [ - RectangularBoundedSection( - shape="rectangular", xDimension=9.0, yDimension=10.0, topHeight=10.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=9.0, + topYDimension=10.0, + bottomXDimension=8.0, + bottomYDimension=9.0, + topHeight=10.0, + bottomHeight=5.0, ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=9.0, topHeight=5.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=8.0, + topYDimension=9.0, + bottomXDimension=15.0, + bottomYDimension=18.0, + topHeight=5.0, + bottomHeight=1.0, + ), + ConicalFrustum( + shape="conical", + topDiameter=23.0, + bottomDiameter=3.0, + topHeight=2.0, + bottomHeight=1.0, + ), + SphericalSegment( + shape="spherical", + radiusOfCurvature=4.0, + topHeight=1.0, + bottomHeight=0.0, ), - CircularBoundedSection(shape="circular", diameter=23.0, topHeight=1.0), - SphericalSegment(shape="spherical", radiusOfCurvature=4.0, depth=1.0), ] ) frusta.append( [ - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=70.0, topHeight=3.5 - ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=75.0, topHeight=2.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=8.0, + topYDimension=70.0, + bottomXDimension=7.0, + bottomYDimension=75.0, + topHeight=3.5, + bottomHeight=2.0, ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=80.0, topHeight=1.0 - ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=90.0, topHeight=0.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=8.0, + topYDimension=80.0, + bottomXDimension=8.0, + bottomYDimension=90.0, + topHeight=1.0, + bottomHeight=0.0, ), ] ) frusta.append( [ - CircularBoundedSection(shape="circular", diameter=23.0, topHeight=7.5), - CircularBoundedSection(shape="circular", diameter=11.5, topHeight=5.0), - CircularBoundedSection(shape="circular", diameter=23.0, topHeight=2.5), - CircularBoundedSection(shape="circular", diameter=11.5, topHeight=0.0), + ConicalFrustum( + shape="conical", + topDiameter=23.0, + bottomDiameter=11.5, + topHeight=7.5, + bottomHeight=5.0, + ), + ConicalFrustum( + shape="conical", + topDiameter=11.5, + bottomDiameter=23.0, + topHeight=5.0, + bottomHeight=2.5, + ), + ConicalFrustum( + shape="conical", + topDiameter=23.0, + bottomDiameter=11.5, + topHeight=2.5, + bottomHeight=0.0, + ), ] ) frusta.append( [ - CircularBoundedSection(shape="circular", diameter=4.0, topHeight=3.0), - CircularBoundedSection(shape="circular", diameter=5.0, topHeight=2.0), - SphericalSegment(shape="spherical", radiusOfCurvature=3.5, depth=2.0), + ConicalFrustum( + shape="conical", + topDiameter=4.0, + bottomDiameter=5.0, + topHeight=3.0, + bottomHeight=2.0, + ), + SphericalSegment( + shape="spherical", + radiusOfCurvature=3.5, + topHeight=2.0, + bottomHeight=0.0, + ), ] ) frusta.append( - [SphericalSegment(shape="spherical", radiusOfCurvature=4.0, depth=3.0)] + [ + SphericalSegment( + shape="spherical", + radiusOfCurvature=4.0, + topHeight=3.0, + bottomHeight=0.0, + ) + ] ) frusta.append( [ - RectangularBoundedSection( - shape="rectangular", xDimension=27.0, yDimension=36.0, topHeight=3.5 + CuboidalFrustum( + shape="cuboidal", + topXDimension=27.0, + topYDimension=36.0, + bottomXDimension=36.0, + bottomYDimension=26.0, + topHeight=3.5, + bottomHeight=1.5, ), - RectangularBoundedSection( - shape="rectangular", xDimension=36.0, yDimension=26.0, topHeight=1.5 + SphericalSegment( + shape="spherical", + radiusOfCurvature=4.0, + topHeight=1.5, + bottomHeight=0.0, ), - SphericalSegment(shape="spherical", radiusOfCurvature=4.0, depth=1.5), ] ) return frusta @@ -103,11 +175,11 @@ def test_reject_unacceptable_heights( """Make sure we reject all mathematical solutions that are physically not possible.""" if len(expected_heights) != 1: with pytest.raises(InvalidLiquidHeightFound): - reject_unacceptable_heights( + _reject_unacceptable_heights( max_height=max_height, potential_heights=potential_heights ) else: - found_heights = reject_unacceptable_heights( + found_heights = _reject_unacceptable_heights( max_height=max_height, potential_heights=potential_heights ) assert found_heights == expected_heights[0] @@ -117,7 +189,7 @@ def test_reject_unacceptable_heights( def test_cross_section_area_circular(diameter: float) -> None: """Test circular area calculation.""" expected_area = pi * (diameter / 2) ** 2 - assert cross_section_area_circular(diameter) == expected_area + assert _cross_section_area_circular(diameter) == expected_area @pytest.mark.parametrize( @@ -127,35 +199,27 @@ def test_cross_section_area_rectangular(x_dimension: float, y_dimension: float) """Test rectangular area calculation.""" expected_area = x_dimension * y_dimension assert ( - cross_section_area_rectangular(x_dimension=x_dimension, y_dimension=y_dimension) + _cross_section_area_rectangular( + x_dimension=x_dimension, y_dimension=y_dimension + ) == expected_area ) -@pytest.mark.parametrize("well", fake_frusta()) -def test_get_cross_section_boundaries(well: List[List[Any]]) -> None: - """Make sure get_cross_section_boundaries returns the expected list indices.""" - i = 0 - for f, next_f in get_boundary_pairs(well): - assert f == well[i] - assert next_f == well[i + 1] - i += 1 - - @pytest.mark.parametrize("well", fake_frusta()) def test_volume_and_height_circular(well: List[Any]) -> None: """Test both volume and height calculations for circular frusta.""" - if well[-1]["shape"] == "spherical": + if well[-1].shape == "spherical": return - total_height = well[0]["topHeight"] - for f, next_f in get_boundary_pairs(well): - if f["shape"] == next_f["shape"] == "circular": - top_radius = next_f["diameter"] / 2 - bottom_radius = f["diameter"] / 2 + total_height = well[0].topHeight + for segment in well: + if segment.shape == "conical": + top_radius = segment.topDiameter / 2 + bottom_radius = segment.bottomDiameter / 2 a = pi * ((top_radius - bottom_radius) ** 2) / (3 * total_height**2) b = pi * bottom_radius * (top_radius - bottom_radius) / total_height c = pi * bottom_radius**2 - assert circular_frustum_polynomial_roots( + assert _circular_frustum_polynomial_roots( top_radius=top_radius, bottom_radius=bottom_radius, total_frustum_height=total_height, @@ -167,7 +231,7 @@ def test_volume_and_height_circular(well: List[Any]) -> None: + b * (target_height**2) + c * target_height ) - found_volume = volume_from_height_circular( + found_volume = _volume_from_height_circular( target_height=target_height, total_frustum_height=total_height, bottom_radius=bottom_radius, @@ -175,7 +239,7 @@ def test_volume_and_height_circular(well: List[Any]) -> None: ) assert found_volume == expected_volume # test going backwards to get height back - found_height = height_from_volume_circular( + found_height = _height_from_volume_circular( volume=found_volume, total_frustum_height=total_height, bottom_radius=bottom_radius, @@ -187,15 +251,15 @@ def test_volume_and_height_circular(well: List[Any]) -> None: @pytest.mark.parametrize("well", fake_frusta()) def test_volume_and_height_rectangular(well: List[Any]) -> None: """Test both volume and height calculations for rectangular frusta.""" - if well[-1]["shape"] == "spherical": + if well[-1].shape == "spherical": return - total_height = well[0]["topHeight"] - for f, next_f in get_boundary_pairs(well): - if f["shape"] == next_f["shape"] == "rectangular": - top_length = next_f["yDimension"] - top_width = next_f["xDimension"] - bottom_length = f["yDimension"] - bottom_width = f["xDimension"] + total_height = well[0].topHeight + for segment in well: + if segment.shape == "cuboidal": + top_length = segment.topYDimension + top_width = segment.topXDimension + bottom_length = segment.bottomYDimension + bottom_width = segment.bottomXDimension a = ( (top_length - bottom_length) * (top_width - bottom_width) @@ -206,7 +270,7 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: + (bottom_width * (top_length - bottom_length)) ) / (2 * total_height) c = bottom_length * bottom_width - assert rectangular_frustum_polynomial_roots( + assert _rectangular_frustum_polynomial_roots( top_length=top_length, bottom_length=bottom_length, top_width=top_width, @@ -220,7 +284,7 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: + b * (target_height**2) + c * target_height ) - found_volume = volume_from_height_rectangular( + found_volume = _volume_from_height_rectangular( target_height=target_height, total_frustum_height=total_height, bottom_length=bottom_length, @@ -230,7 +294,7 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: ) assert found_volume == expected_volume # test going backwards to get height back - found_height = height_from_volume_rectangular( + found_height = _height_from_volume_rectangular( volume=found_volume, total_frustum_height=total_height, bottom_length=bottom_length, @@ -244,22 +308,33 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: @pytest.mark.parametrize("well", fake_frusta()) def test_volume_and_height_spherical(well: List[Any]) -> None: """Test both volume and height calculations for spherical segments.""" - if well[0]["shape"] == "spherical": - for target_height in range(round(well[0]["depth"])): + if well[0].shape == "spherical": + for target_height in range(round(well[0].topHeight)): expected_volume = ( (1 / 3) * pi * (target_height**2) - * (3 * well[0]["radiusOfCurvature"] - target_height) + * (3 * well[0].radiusOfCurvature - target_height) ) - found_volume = volume_from_height_spherical( + found_volume = _volume_from_height_spherical( target_height=target_height, - radius_of_curvature=well[0]["radiusOfCurvature"], + radius_of_curvature=well[0].radiusOfCurvature, ) assert found_volume == expected_volume - found_height = height_from_volume_spherical( + found_height = _height_from_volume_spherical( volume=found_volume, - radius_of_curvature=well[0]["radiusOfCurvature"], - total_frustum_height=well[0]["depth"], + radius_of_curvature=well[0].radiusOfCurvature, + total_frustum_height=well[0].topHeight, ) assert isclose(found_height, target_height) + + +@pytest.mark.parametrize("well", fake_frusta()) +def test_height_at_volume_within_section(well: List[Any]) -> None: + """Test that finding the height when volume ~= capacity works.""" + for segment in well: + segment_height = segment.topHeight - segment.bottomHeight + height = height_at_volume_within_section( + segment, _get_segment_capacity(segment), segment_height + ) + assert isclose(height, segment_height) diff --git a/shared-data/js/__tests__/labwareDefSchemaV3.test.ts b/shared-data/js/__tests__/labwareDefSchemaV3.test.ts index 8416e8b60c5..14d0c4bf968 100644 --- a/shared-data/js/__tests__/labwareDefSchemaV3.test.ts +++ b/shared-data/js/__tests__/labwareDefSchemaV3.test.ts @@ -33,14 +33,10 @@ const checkGeometryDefinitions = ( expect(wellGeometryId in labwareDef.innerLabwareGeometry).toBe(true) const wellDepth = labwareDef.wells[wellName].depth - const wellShape = labwareDef.wells[wellName].shape const topFrustumHeight = - labwareDef.innerLabwareGeometry[wellGeometryId].frusta[0].topHeight - const topFrustumShape = - labwareDef.innerLabwareGeometry[wellGeometryId].frusta[0].shape + labwareDef.innerLabwareGeometry[wellGeometryId].sections[0].topHeight expect(wellDepth).toEqual(topFrustumHeight) - expect(wellShape).toEqual(topFrustumShape) } }) } diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index dbd8c7f59c7..0ffb3f7a649 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -162,25 +162,57 @@ export type LabwareWell = LabwareWellProperties & { export interface SphericalSegment { shape: 'spherical' radiusOfCurvature: number - depth: number + topHeight: number + bottomHeight: number } -export interface CircularBoundedSection { - shape: 'circular' - diameter: number +export interface ConicalFrustum { + shape: 'conical' + bottomDiameter: number + topDiameter: number topHeight: number + bottomHeight: number } -export interface RectangularBoundedSection { - shape: 'rectangular' - xDimension: number - yDimension: number +export interface CuboidalFrustum { + shape: 'cuboidal' + bottomXDimension: number + bottomYDimension: number + topXDimension: number + topYDimension: number topHeight: number + bottomHeight: number } +export interface SquaredConeSegment { + shape: 'squaredcone' + bottomCrossSection: string + circleDiameter: number + rectangleXDimension: number + rectangleYDimension: number + topHeight: number + bottomHeight: number +} + +export interface RoundedCuboidSegment { + shape: 'roundedcuboid' + bottomCrossSection: string + circleDiameter: number + rectangleXDimension: number + rectangleYDimension: number + topHeight: number + bottomHeight: number +} + +export type WellSegment = + | CuboidalFrustum + | ConicalFrustum + | SquaredConeSegment + | SphericalSegment + | RoundedCuboidSegment + export interface InnerWellGeometry { - frusta: CircularBoundedSection[] | RectangularBoundedSection[] - bottomShape?: SphericalSegment | null + sections: WellSegment[] } // TODO(mc, 2019-03-21): exact object is tough to use with the initial value in diff --git a/shared-data/labware/fixtures/3/fixture_2_plate.json b/shared-data/labware/fixtures/3/fixture_2_plate.json index a2e1bb5a3ea..19ea2f82ffc 100644 --- a/shared-data/labware/fixtures/3/fixture_2_plate.json +++ b/shared-data/labware/fixtures/3/fixture_2_plate.json @@ -62,39 +62,34 @@ }, "innerLabwareGeometry": { "daiwudhadfhiew": { - "frusta": [ + "sections": [ { - "shape": "rectangular", - "xDimension": 127.76, - "yDimension": 85.8, - "topHeight": 42.16 - }, - { - "shape": "rectangular", - "xDimension": 70.0, - "yDimension": 50.0, - "topHeight": 20.0 + "shape": "cuboidal", + "topXDimension": 127.76, + "topYDimension": 85.8, + "bottomXDimension": 70.0, + "bottomYDimension": 50.0, + "topHeight": 42.16, + "bottomHeight": 20.0 } ] }, "iuweofiuwhfn": { - "frusta": [ + "sections": [ { - "shape": "circular", - "diameter": 35.0, - "topHeight": 42.16 + "shape": "conical", + "bottomDiameter": 35.0, + "topDiameter": 35.0, + "topHeight": 42.16, + "bottomHeight": 10.0 }, { - "shape": "circular", - "diameter": 35.0, - "topHeight": 20.0 + "shape": "spherical", + "radiusOfCurvature": 20.0, + "topHeight": 10.0, + "bottomHeight": 0.0 } - ], - "bottomShape": { - "shape": "spherical", - "radiusOfCurvature": 20.0, - "depth": 6.0 - } + ] } } } diff --git a/shared-data/labware/fixtures/3/fixture_corning_24_plate.json b/shared-data/labware/fixtures/3/fixture_corning_24_plate.json index d53a6f017ca..679f8916377 100644 --- a/shared-data/labware/fixtures/3/fixture_corning_24_plate.json +++ b/shared-data/labware/fixtures/3/fixture_corning_24_plate.json @@ -323,16 +323,13 @@ }, "innerLabwareGeometry": { "venirhgerug": { - "frusta": [ + "sections": [ { - "shape": "circular", - "diameter": 16.26, - "topHeight": 17.4 - }, - { - "shape": "circular", - "diameter": 16.26, - "topHeight": 0.0 + "shape": "conical", + "bottomDiameter": 16.26, + "topDiameter": 16.26, + "topHeight": 17.4, + "bottomHeight": 0.0 } ] } diff --git a/shared-data/labware/schemas/3.json b/shared-data/labware/schemas/3.json index e03b1c8f064..ecd285c554a 100644 --- a/shared-data/labware/schemas/3.json +++ b/shared-data/labware/schemas/3.json @@ -67,8 +67,9 @@ }, "SphericalSegment": { "type": "object", + "description": "A partial sphere shaped section at the bottom of the well.", "additionalProperties": false, - "required": ["shape", "radiusOfCurvature", "depth"], + "required": ["shape", "radiusOfCurvature", "topHeight", "bottomHeight"], "properties": { "shape": { "type": "string", @@ -77,70 +78,182 @@ "radiusOfCurvature": { "type": "number" }, - "depth": { + "topHeight": { + "type": "number" + }, + "bottomHeight": { "type": "number" } } }, - "CircularBoundedSection": { + "ConicalFrustum": { "type": "object", - "required": ["shape", "diameter", "topHeight"], + "description": "A cone or conical segment, bounded by two circles on the top and bottom.", + "required": [ + "shape", + "bottomDiameter", + "topDiameter", + "topHeight", + "bottomHeight" + ], "properties": { "shape": { "type": "string", - "enum": ["circular"] + "enum": ["conical"] }, - "diameter": { + "bottomDiameter": { + "type": "number" + }, + "topDiameter": { "type": "number" }, "topHeight": { - "type": "number", - "description": "The height at the top of a bounded subsection of a well, relative to the bottom" + "type": "number" + }, + "bottomHeight": { + "type": "number" } } }, - "RectangularBoundedSection": { + "CuboidalFrustum": { "type": "object", - "required": ["shape", "xDimension", "yDimension", "topHeight"], + "description": "A cuboidal shape bounded by two rectangles on the top and bottom", + "required": [ + "shape", + "bottomXDimension", + "bottomYDimension", + "topXDimension", + "topYDimension", + "topHeight", + "bottomHeight" + ], "properties": { "shape": { "type": "string", - "enum": ["rectangular"] + "enum": ["cuboidal"] }, - "xDimension": { + "bottomXDimension": { "type": "number" }, - "yDimension": { + "bottomYDimension": { + "type": "number" + }, + "topXDimension": { + "type": "number" + }, + "topYDimension": { "type": "number" }, "topHeight": { - "type": "number", - "description": "The height at the top of a bounded subsection of a well, relative to the bottom" + "type": "number" + }, + "bottomHeight": { + "type": "number" + } + } + }, + "SquaredConeSegment": { + "type": "object", + "description": "The intersection of a pyramid and a cone that both share a central axis where one face is a circle and one face is a rectangle", + "required": [ + "shape", + "bottomCrossSection", + "circleDiameter", + "rectangleXDimension", + "rectangleYDimension", + "topHeight", + "bottomHeight" + ], + "properties": { + "shape": { + "type": "string", + "enum": ["squaredcone"] + }, + "bottomCrossSection": { + "type": "string", + "enum": ["circular", "rectangular"] + }, + "circleDiameter": { + "type": "number" + }, + "rectangleXDimension": { + "type": "number" + }, + "rectangleYDimension": { + "type": "number" + }, + "topHeight": { + "type": "number" + }, + "bottomHeight": { + "type": "number" + } + } + }, + "RoundedCuboidSegment": { + "type": "object", + "description": "A cuboidal frustum where each corner is filleted out by circles with centers on the diagonals between opposite corners", + "required": [ + "shape", + "bottomCrossSection", + "circleDiameter", + "rectangleXDimension", + "rectangleYDimension", + "topHeight", + "bottomHeight" + ], + "properties": { + "shape": { + "type": "string", + "enum": ["roundedcuboid"] + }, + "bottomCrossSection": { + "type": "string", + "enum": ["circular", "rectangular"] + }, + "circleDiameter": { + "type": "number" + }, + "rectangleXDimension": { + "type": "number" + }, + "rectangleYDimension": { + "type": "number" + }, + "topHeight": { + "type": "number" + }, + "bottomHeight": { + "type": "number" } } }, "InnerWellGeometry": { "type": "object", - "required": ["frusta"], + "required": ["sections"], "properties": { - "frusta": { + "sections": { "description": "A list of all of the sections of the well that have a contiguous shape", "type": "array", "items": { "oneOf": [ { - "$ref": "#/definitions/CircularBoundedSection" + "$ref": "#/definitions/ConicalFrustum" + }, + { + "$ref": "#/definitions/CuboidalFrustum" + }, + { + "$ref": "#/definitions/SquaredConeSegment" }, { - "$ref": "#/definitions/RectangularBoundedSection" + "$ref": "#/definitions/RoundedCuboidSegment" + }, + { + "$ref": "#/definitions/SphericalSegment" } ] } - }, - "bottomShape": { - "type": "object", - "description": "The shape at the bottom of the well: either a spherical segment or a cross-section", - "$ref": "#/definitions/SphericalSegment" } } } diff --git a/shared-data/python/opentrons_shared_data/labware/constants.py b/shared-data/python/opentrons_shared_data/labware/constants.py index 00fbef3c160..9973604937b 100644 --- a/shared-data/python/opentrons_shared_data/labware/constants.py +++ b/shared-data/python/opentrons_shared_data/labware/constants.py @@ -1,7 +1,20 @@ import re from typing_extensions import Final +from typing import Literal, Union # Regular expression to validate and extract row, column from well name # (ie A3, C1) WELL_NAME_PATTERN: Final["re.Pattern[str]"] = re.compile(r"^([A-Z]+)([0-9]+)$", re.X) + +# These shapes are for wellshape definitions and describe the top of the well +Circular = Literal["circular"] +Rectangular = Literal["rectangular"] +WellShape = Union[Circular, Rectangular] + +# These shapes are used to describe the 3D primatives used to build wells +Conical = Literal["conical"] +Cuboidal = Literal["cuboidal"] +SquaredCone = Literal["squaredcone"] +RoundedCuboid = Literal["roundedcuboid"] +Spherical = Literal["spherical"] diff --git a/shared-data/python/opentrons_shared_data/labware/labware_definition.py b/shared-data/python/opentrons_shared_data/labware/labware_definition.py index a6ee1804cde..a818afc106a 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -19,6 +19,15 @@ ) from typing_extensions import Literal +from .constants import ( + Conical, + Cuboidal, + RoundedCuboid, + SquaredCone, + Spherical, + WellShape, +) + SAFE_STRING_REGEX = "^[a-z0-9._]+$" @@ -228,45 +237,227 @@ class Config: class SphericalSegment(BaseModel): - shape: Literal["spherical"] = Field(..., description="Denote shape as spherical") + shape: Spherical = Field(..., description="Denote shape as spherical") radiusOfCurvature: _NonNegativeNumber = Field( ..., description="radius of curvature of bottom subsection of wells", ) - depth: _NonNegativeNumber = Field( + topHeight: _NonNegativeNumber = Field( ..., description="The depth of a spherical bottom of a well" ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="Height of the bottom of the segment, must be 0.0", + ) + + +class ConicalFrustum(BaseModel): + shape: Conical = Field(..., description="Denote shape as conical") + bottomDiameter: _NonNegativeNumber = Field( + ..., + description="The diameter at the bottom cross-section of a circular frustum", + ) + topDiameter: _NonNegativeNumber = Field( + ..., description="The diameter at the top cross-section of a circular frustum" + ) + topHeight: _NonNegativeNumber = Field( + ..., + description="The height at the top of a bounded subsection of a well, relative to the bottom" + "of the well", + ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) + + +class CuboidalFrustum(BaseModel): + shape: Cuboidal = Field(..., description="Denote shape as cuboidal") + bottomXDimension: _NonNegativeNumber = Field( + ..., + description="x dimension of the bottom cross-section of a rectangular frustum", + ) + bottomYDimension: _NonNegativeNumber = Field( + ..., + description="y dimension of the bottom cross-section of a rectangular frustum", + ) + topXDimension: _NonNegativeNumber = Field( + ..., + description="x dimension of the top cross-section of a rectangular frustum", + ) + topYDimension: _NonNegativeNumber = Field( + ..., + description="y dimension of the top cross-section of a rectangular frustum", + ) + topHeight: _NonNegativeNumber = Field( + ..., + description="The height at the top of a bounded subsection of a well, relative to the bottom" + "of the well", + ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) + + +# A squared cone is the intersection of a cube and a cone that both +# share a central axis, and is a transitional shape between a cone and pyramid +""" +module RectangularPrismToCone(bottom_shape, diameter, x, y, z) { + circle_radius = diameter/2; + r1 = sqrt(x*x + y*y)/2; + r2 = circle_radius/2; + top_r = bottom_shape == "square" ? r1 : r2; + bottom_r = bottom_shape == "square" ? r2 : r1; + intersection() { + cylinder(z,top_r,bottom_r,$fn=100); + translate([0,0,z/2])cube([x, y, z], center=true); + } +} +""" -class CircularBoundedSection(BaseModel): - shape: Literal["circular"] = Field(..., description="Denote shape as circular") - diameter: _NonNegativeNumber = Field( - ..., description="The diameter of a circular cross section of a well" +class SquaredConeSegment(BaseModel): + shape: SquaredCone = Field( + ..., description="Denote shape as a squared conical segment" + ) + bottomCrossSection: WellShape = Field( + ..., + description="Denote if the shape is going from circular to rectangular or vise versa", + ) + circleDiameter: _NonNegativeNumber = Field( + ..., + description="diameter of the circular face of a truncated circular segment", + ) + + rectangleXDimension: _NonNegativeNumber = Field( + ..., + description="x dimension of the rectangular face of a truncated circular segment", + ) + rectangleYDimension: _NonNegativeNumber = Field( + ..., + description="y dimension of the rectangular face of a truncated circular segment", ) topHeight: _NonNegativeNumber = Field( ..., description="The height at the top of a bounded subsection of a well, relative to the bottom" "of the well", ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) -class RectangularBoundedSection(BaseModel): - shape: Literal["rectangular"] = Field( - ..., description="Denote shape as rectangular" +""" +module filitedCuboidSquare(bottom_shape, diameter, width, length, height, steps) { + module _slice(depth, x, y, r) { + echo("called with: ", depth, x, y, r); + circle_centers = [ + [(x/2)-r, (y/2)-r, 0], + [(-x/2)+r, (y/2)-r, 0], + [(x/2)-r, (-y/2)+r, 0], + [(-x/2)+r, (-y/2)+r, 0] + + ]; + translate([0,0,depth/2])cube([x-2*r,y,depth], center=true); + translate([0,0,depth/2])cube([x,y-2*r,depth], center=true); + for (center = circle_centers) { + translate(center) cylinder(depth, r, r, $fn=100); + } + } + for (slice_height = [0:height/steps:height]) { + r = (diameter) * (slice_height/height); + translate([0,0,slice_height]) { + _slice(height/steps , width, length, r/2); + } + } +} +module filitedCuboidForce(bottom_shape, diameter, width, length, height, steps) { + module single_cone(r,x,y,z) { + r = diameter/2; + circle_face = [[ for (i = [0:1: steps]) i ]]; + theta = 360/steps; + circle_points = [for (step = [0:1:steps]) [r*cos(theta*step), r*sin(theta*step), z]]; + final_points = [[x,y,0]]; + all_points = concat(circle_points, final_points); + triangles = [for (step = [0:1:steps-1]) [step, step+1, steps+1]]; + faces = concat(circle_face, triangles); + polyhedron(all_points, faces); + } + module square_section(r, x, y, z) { + points = [ + [x,y,0], + [-x,y,0], + [-x,-y,0], + [x,-y,0], + [r,0,z], + [0,r,z], + [-r,0,z], + [0,-r,z], + ]; + faces = [ + [0,1,2,3], + [4,5,6,7], + [4, 0, 3], + [5, 0, 1], + [6, 1, 2], + [7, 2, 3], + ]; + polyhedron(points, faces); + } + circle_height = bottom_shape == "square" ? height : -height; + translate_height = bottom_shape == "square" ? 0 : height; + translate ([0,0, translate_height]) { + union() { + single_cone(diameter/2, width/2, length/2, circle_height); + single_cone(diameter/2, -width/2, length/2, circle_height); + single_cone(diameter/2, width/2, -length/2, circle_height); + single_cone(diameter/2, -width/2, -length/2, circle_height); + square_section(diameter/2, width/2, length/2, circle_height); + } + } +} + +module filitedCuboid(bottom_shape, diameter, width, length, height) { + if (width == length && width == diameter) { + filitedCuboidSquare(bottom_shape, diameter, width, length, height, 100); + } + else { + filitedCuboidForce(bottom_shape, diameter, width, length, height, 100); + } +}""" + + +class RoundedCuboidSegment(BaseModel): + shape: RoundedCuboid = Field( + ..., description="Denote shape as a rounded cuboidal segment" + ) + bottomCrossSection: WellShape = Field( + ..., + description="Denote if the shape is going from circular to rectangular or vise versa", + ) + circleDiameter: _NonNegativeNumber = Field( + ..., + description="diameter of the circular face of a rounded rectangular segment", ) - xDimension: _NonNegativeNumber = Field( + rectangleXDimension: _NonNegativeNumber = Field( ..., - description="x dimension of a subsection of wells", + description="x dimension of the rectangular face of a rounded rectangular segment", ) - yDimension: _NonNegativeNumber = Field( + rectangleYDimension: _NonNegativeNumber = Field( ..., - description="y dimension of a subsection of wells", + description="y dimension of the rectangular face of a rounded rectangular segment", ) topHeight: _NonNegativeNumber = Field( ..., description="The height at the top of a bounded subsection of a well, relative to the bottom" "of the well", ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) class Metadata1(BaseModel): @@ -297,17 +488,20 @@ class Group(BaseModel): ) +WellSegment = Union[ + ConicalFrustum, + CuboidalFrustum, + SquaredConeSegment, + RoundedCuboidSegment, + SphericalSegment, +] + + class InnerWellGeometry(BaseModel): - frusta: Union[ - List[CircularBoundedSection], List[RectangularBoundedSection] - ] = Field( + sections: List[WellSegment] = Field( ..., description="A list of all of the sections of the well that have a contiguous shape", ) - bottomShape: Optional[SphericalSegment] = Field( - None, - description="The shape at the bottom of the well: either a spherical segment or a cross-section", - ) class LabwareDefinition(BaseModel): diff --git a/shared-data/python/opentrons_shared_data/labware/types.py b/shared-data/python/opentrons_shared_data/labware/types.py index 9ea7a83fb6b..d3f6599848c 100644 --- a/shared-data/python/opentrons_shared_data/labware/types.py +++ b/shared-data/python/opentrons_shared_data/labware/types.py @@ -3,9 +3,13 @@ types in this file by and large require the use of typing_extensions. this module shouldn't be imported unless typing.TYPE_CHECKING is true. """ -from typing import Dict, List, NewType, Union, Optional, Any -from typing_extensions import Literal, TypedDict, NotRequired, TypeGuard - +from typing import Dict, List, NewType, Union +from typing_extensions import Literal, TypedDict, NotRequired +from .labware_definition import InnerWellGeometry +from .constants import ( + Circular, + Rectangular, +) LabwareUri = NewType("LabwareUri", str) @@ -35,11 +39,6 @@ Literal["maintenance"], ] -Circular = Literal["circular"] -Rectangular = Literal["rectangular"] -Spherical = Literal["spherical"] -WellShape = Union[Circular, Rectangular] - class NamedOffset(TypedDict): x: float @@ -120,42 +119,6 @@ class WellGroup(TypedDict, total=False): brand: LabwareBrandData -class SphericalSegment(TypedDict): - shape: Spherical - radiusOfCurvature: float - depth: float - - -class RectangularBoundedSection(TypedDict): - shape: Rectangular - xDimension: float - yDimension: float - topHeight: float - - -class CircularBoundedSection(TypedDict): - shape: Circular - diameter: float - topHeight: float - - -def is_circular_frusta_list( - items: List[Any], -) -> TypeGuard[List[CircularBoundedSection]]: - return all(item.shape == "circular" for item in items) - - -def is_rectangular_frusta_list( - items: List[Any], -) -> TypeGuard[List[RectangularBoundedSection]]: - return all(item.shape == "rectangular" for item in items) - - -class InnerWellGeometry(TypedDict): - frusta: Union[List[CircularBoundedSection], List[RectangularBoundedSection]] - bottomShape: Optional[SphericalSegment] - - class LabwareDefinition(TypedDict): schemaVersion: Literal[2] version: int From 26da9922631995e5f0d7f125fd93bdf965c73dc0 Mon Sep 17 00:00:00 2001 From: Sanniti Pimpley Date: Wed, 9 Oct 2024 14:24:36 -0400 Subject: [PATCH 024/101] refactor(api): move pipette movement conflict checks to separate file (#16439) # Overview Addresses a long-standing TODO to separate out the pipette movement conflict and deck-placement conflict code since they are completely exclusive of each other and don't need to be in the same file. ## Changelog - moved all pipette movement conflict checking code to `pipette_movement_conflict.py` ## Risk assessment None. Refactor only --- .../protocol_api/core/engine/deck_conflict.py | 297 --------------- .../protocol_api/core/engine/instrument.py | 18 +- .../core/engine/pipette_movement_conflict.py | 348 ++++++++++++++++++ .../core/engine/test_deck_conflict.py | 22 +- .../core/engine/test_instrument_core.py | 26 +- .../test_pipette_movement_deck_conflicts.py | 2 +- .../hardware_testing/gravimetric/helpers.py | 6 +- 7 files changed, 386 insertions(+), 333 deletions(-) create mode 100644 api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py index abf47212dac..ee724ea5ca3 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -10,16 +10,13 @@ overload, Union, TYPE_CHECKING, - List, ) from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError from opentrons_shared_data.module import FLEX_TC_LID_COLLISION_ZONE -from opentrons.hardware_control import CriticalPoint from opentrons.hardware_control.modules.types import ModuleType from opentrons.motion_planning import deck_conflict as wrapped_deck_conflict -from opentrons.motion_planning import adjacent_slots_getters from opentrons.protocol_engine import ( StateView, @@ -28,16 +25,10 @@ OnLabwareLocation, AddressableAreaLocation, OFF_DECK_LOCATION, - WellLocation, - DropTipWellLocation, ) from opentrons.protocol_engine.errors.exceptions import LabwareNotLoadedOnModuleError -from opentrons.protocol_engine.types import ( - StagingSlotLocation, -) from opentrons.types import DeckSlotName, StagingSlotName, Point from ...disposal_locations import TrashBin, WasteChute -from . import point_calculations if TYPE_CHECKING: from ...labware import Labware @@ -193,294 +184,6 @@ def check( ) -# TODO (spp, 2023-02-16): move pipette movement safety checks to its own separate file. -def check_safe_for_pipette_movement( - engine_state: StateView, - pipette_id: str, - labware_id: str, - well_name: str, - well_location: Union[WellLocation, DropTipWellLocation], -) -> None: - """Check if the labware is safe to move to with a pipette in partial tip configuration. - - Args: - engine_state: engine state view - pipette_id: ID of the pipette to be moved - labware_id: ID of the labware we are moving to - well_name: Name of the well to move to - well_location: exact location within the well to move to - """ - # TODO (spp, 2023-02-06): remove this check after thorough testing. - # This function is capable of checking for movement conflict regardless of - # nozzle configuration. - if not engine_state.pipettes.get_is_partially_configured(pipette_id): - return - - if isinstance(well_location, DropTipWellLocation): - # convert to WellLocation - well_location = engine_state.geometry.get_checked_tip_drop_location( - pipette_id=pipette_id, - labware_id=labware_id, - well_location=well_location, - partially_configured=True, - ) - well_location_point = engine_state.geometry.get_well_position( - labware_id=labware_id, well_name=well_name, well_location=well_location - ) - primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id) - - destination_cp = _get_critical_point_to_use(engine_state, labware_id) - - pipette_bounds_at_well_location = ( - engine_state.pipettes.get_pipette_bounds_at_specified_move_to_position( - pipette_id=pipette_id, - destination_position=well_location_point, - critical_point=destination_cp, - ) - ) - if not _is_within_pipette_extents( - engine_state=engine_state, - pipette_id=pipette_id, - pipette_bounding_box_at_loc=pipette_bounds_at_well_location, - ): - raise PartialTipMovementNotAllowedError( - f"Requested motion with the {primary_nozzle} nozzle partial configuration" - f" is outside of robot bounds for the pipette." - ) - - labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id) - - surrounding_slots = adjacent_slots_getters.get_surrounding_slots( - slot=labware_slot.as_int(), robot_type=engine_state.config.robot_type - ) - - if _will_collide_with_thermocycler_lid( - engine_state=engine_state, - pipette_bounds=pipette_bounds_at_well_location, - surrounding_regular_slots=surrounding_slots.regular_slots, - ): - raise PartialTipMovementNotAllowedError( - f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot" - f" {labware_slot} with {primary_nozzle} nozzle partial configuration" - f" will result in collision with thermocycler lid in deck slot A1." - ) - - for regular_slot in surrounding_slots.regular_slots: - if _slot_has_potential_colliding_object( - engine_state=engine_state, - pipette_bounds=pipette_bounds_at_well_location, - surrounding_slot=regular_slot, - ): - raise PartialTipMovementNotAllowedError( - f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot" - f" {labware_slot} with {primary_nozzle} nozzle partial configuration" - f" will result in collision with items in deck slot {regular_slot}." - ) - for staging_slot in surrounding_slots.staging_slots: - if _slot_has_potential_colliding_object( - engine_state=engine_state, - pipette_bounds=pipette_bounds_at_well_location, - surrounding_slot=staging_slot, - ): - raise PartialTipMovementNotAllowedError( - f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot" - f" {labware_slot} with {primary_nozzle} nozzle partial configuration" - f" will result in collision with items in staging slot {staging_slot}." - ) - - -def _get_critical_point_to_use( - engine_state: StateView, labware_id: str -) -> Optional[CriticalPoint]: - """Return the critical point to use when accessing the given labware.""" - # TODO (spp, 2024-09-17): looks like Y_CENTER of column is the same as its XY_CENTER. - # I'm using this if-else ladder to be consistent with what we do in - # `MotionPlanning.get_movement_waypoints_to_well()`. - # We should probably use only XY_CENTER in both places. - if engine_state.labware.get_should_center_column_on_target_well(labware_id): - return CriticalPoint.Y_CENTER - elif engine_state.labware.get_should_center_pipette_on_target_well(labware_id): - return CriticalPoint.XY_CENTER - return None - - -def _slot_has_potential_colliding_object( - engine_state: StateView, - pipette_bounds: Tuple[Point, Point, Point, Point], - surrounding_slot: Union[DeckSlotName, StagingSlotName], -) -> bool: - """Return the slot, if any, that has an item that the pipette might collide into.""" - # Check if slot overlaps with pipette position - slot_pos = engine_state.addressable_areas.get_addressable_area_position( - addressable_area_name=surrounding_slot.id, - do_compatibility_check=False, - ) - slot_bounds = engine_state.addressable_areas.get_addressable_area_bounding_box( - addressable_area_name=surrounding_slot.id, - do_compatibility_check=False, - ) - slot_back_left_coords = Point(slot_pos.x, slot_pos.y + slot_bounds.y, slot_pos.z) - slot_front_right_coords = Point(slot_pos.x + slot_bounds.x, slot_pos.y, slot_pos.z) - - # If slot overlaps with pipette bounds - if point_calculations.are_overlapping_rectangles( - rectangle1=(pipette_bounds[0], pipette_bounds[1]), - rectangle2=(slot_back_left_coords, slot_front_right_coords), - ): - # Check z-height of items in overlapping slot - if isinstance(surrounding_slot, DeckSlotName): - slot_highest_z = engine_state.geometry.get_highest_z_in_slot( - DeckSlotLocation(slotName=surrounding_slot) - ) - else: - slot_highest_z = engine_state.geometry.get_highest_z_in_slot( - StagingSlotLocation(slotName=surrounding_slot) - ) - return slot_highest_z >= pipette_bounds[0].z - return False - - -def _will_collide_with_thermocycler_lid( - engine_state: StateView, - pipette_bounds: Tuple[Point, Point, Point, Point], - surrounding_regular_slots: List[DeckSlotName], -) -> bool: - """Return whether the pipette might collide with thermocycler's lid/clips on a Flex. - - If any of the pipette's bounding vertices lie inside the no-go zone of the thermocycler- - which is the area that's to the left, back and below the thermocycler's lid's - protruding clips, then we will mark the movement for possible collision. - - This could cause false raises for the case where an 8-channel is accessing the - thermocycler labware in a location such that the pipette is in the area between - the clips but not touching either clips. But that's a tradeoff we'll need to make - between a complicated check involving accurate positions of all entities involved - and a crude check that disallows all partial tip movements around the thermocycler. - """ - # TODO (spp, 2024-02-27): Improvements: - # - make the check dynamic according to lid state: - # - if lid is open, check if pipette is in no-go zone - # - if lid is closed, use the closed lid height to check for conflict - if ( - DeckSlotName.SLOT_A1 in surrounding_regular_slots - and engine_state.modules.is_flex_deck_with_thermocycler() - ): - return ( - point_calculations.are_overlapping_rectangles( - rectangle1=(_FLEX_TC_LID_BACK_LEFT_PT, _FLEX_TC_LID_FRONT_RIGHT_PT), - rectangle2=(pipette_bounds[0], pipette_bounds[1]), - ) - and pipette_bounds[0].z <= _FLEX_TC_LID_BACK_LEFT_PT.z - ) - - return False - - -def check_safe_for_tip_pickup_and_return( - engine_state: StateView, - pipette_id: str, - labware_id: str, -) -> None: - """Check if the presence or absence of a tiprack adapter might cause any pipette movement issues. - - A 96 channel pipette will pick up tips using cam action when it's configured - to use ALL nozzles. For this, the tiprack needs to be on the Flex 96 channel tiprack adapter - or similar or the tips will not be picked up. - - On the other hand, if the pipette is configured with partial nozzle configuration, - it uses the usual pipette presses to pick the tips up, in which case, having the tiprack - on the Flex 96 channel tiprack adapter (or similar) will cause the pipette to - crash against the adapter posts. - - In order to check if the 96-channel can move and pickup/drop tips safely, this method - checks for the height attribute of the tiprack adapter rather than checking for the - specific official adapter since users might create custom labware &/or definitions - compatible with the official adapter. - """ - if not engine_state.pipettes.get_channels(pipette_id) == 96: - # Adapters only matter to 96 ch. - return - - is_partial_config = engine_state.pipettes.get_is_partially_configured(pipette_id) - tiprack_name = engine_state.labware.get_display_name(labware_id) - tiprack_parent = engine_state.labware.get_location(labware_id) - if isinstance(tiprack_parent, OnLabwareLocation): # tiprack is on an adapter - is_96_ch_tiprack_adapter = engine_state.labware.get_has_quirk( - labware_id=tiprack_parent.labwareId, quirk="tiprackAdapterFor96Channel" - ) - tiprack_height = engine_state.labware.get_dimensions(labware_id).z - adapter_height = engine_state.labware.get_dimensions(tiprack_parent.labwareId).z - if is_partial_config and tiprack_height < adapter_height: - raise PartialTipMovementNotAllowedError( - f"{tiprack_name} cannot be on an adapter taller than the tip rack" - f" when picking up fewer than 96 tips." - ) - elif not is_partial_config and not is_96_ch_tiprack_adapter: - raise UnsuitableTiprackForPipetteMotion( - f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" - f" in order to pick up or return all 96 tips simultaneously." - ) - - elif ( - not is_partial_config - ): # tiprack is not on adapter and pipette is in full config - raise UnsuitableTiprackForPipetteMotion( - f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" - f" in order to pick up or return all 96 tips simultaneously." - ) - - -def _is_within_pipette_extents( - engine_state: StateView, - pipette_id: str, - pipette_bounding_box_at_loc: Tuple[Point, Point, Point, Point], -) -> bool: - """Whether a given point is within the extents of a configured pipette on the specified robot.""" - channels = engine_state.pipettes.get_channels(pipette_id) - robot_extents = engine_state.geometry.absolute_deck_extents - ( - pip_back_left_bound, - pip_front_right_bound, - pip_back_right_bound, - pip_front_left_bound, - ) = pipette_bounding_box_at_loc - - # Given the padding values accounted for against the deck extents, - # a pipette is within extents when all of the following are true: - - # Each corner slot full pickup case: - # A1: Front right nozzle is within the rear and left-side padding limits - # D1: Back right nozzle is within the front and left-side padding limits - # A3 Front left nozzle is within the rear and right-side padding limits - # D3: Back left nozzle is within the front and right-side padding limits - # Thermocycler Column A2: Front right nozzle is within padding limits - - if channels == 96: - return ( - pip_front_right_bound.y - <= robot_extents.deck_extents.y + robot_extents.padding_rear - and pip_front_right_bound.x >= robot_extents.padding_left_side - and pip_back_right_bound.y >= robot_extents.padding_front - and pip_back_right_bound.x >= robot_extents.padding_left_side - and pip_front_left_bound.y - <= robot_extents.deck_extents.y + robot_extents.padding_rear - and pip_front_left_bound.x - <= robot_extents.deck_extents.x + robot_extents.padding_right_side - and pip_back_left_bound.y >= robot_extents.padding_front - and pip_back_left_bound.x - <= robot_extents.deck_extents.x + robot_extents.padding_right_side - ) - # For 8ch pipettes we only check the rear and front extents - return ( - pip_front_right_bound.y - <= robot_extents.deck_extents.y + robot_extents.padding_rear - and pip_back_right_bound.y >= robot_extents.padding_front - and pip_front_left_bound.y - <= robot_extents.deck_extents.y + robot_extents.padding_rear - and pip_back_left_bound.y >= robot_extents.padding_front - ) - - def _map_labware( engine_state: StateView, labware_id: str, diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 55519e7899c..8fe2b8d7f6e 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -34,7 +34,7 @@ from opentrons.protocol_api._nozzle_layout import NozzleLayout from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType from opentrons.hardware_control.nozzle_manager import NozzleMap -from . import deck_conflict, overlap_versions +from . import overlap_versions, pipette_movement_conflict from ..instrument import AbstractInstrument from .well import WellCore @@ -153,7 +153,7 @@ def aspirate( absolute_point=location.point, ) ) - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, @@ -244,7 +244,7 @@ def dispense( absolute_point=location.point, ) ) - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, @@ -321,7 +321,7 @@ def blow_out( absolute_point=location.point, ) ) - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, @@ -371,7 +371,7 @@ def touch_tip( well_location = WellLocation( origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=z_offset) ) - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, @@ -421,12 +421,12 @@ def pick_up_tip( well_name=well_name, absolute_point=location.point, ) - deck_conflict.check_safe_for_tip_pickup_and_return( + pipette_movement_conflict.check_safe_for_tip_pickup_and_return( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, ) - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, @@ -486,12 +486,12 @@ def drop_tip( well_location = DropTipWellLocation() if self._engine_client.state.labware.is_tiprack(labware_id): - deck_conflict.check_safe_for_tip_pickup_and_return( + pipette_movement_conflict.check_safe_for_tip_pickup_and_return( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, ) - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, diff --git a/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py new file mode 100644 index 00000000000..bfe98e1f217 --- /dev/null +++ b/api/src/opentrons/protocol_api/core/engine/pipette_movement_conflict.py @@ -0,0 +1,348 @@ +"""A Protocol-Engine-friendly wrapper for opentrons.motion_planning.deck_conflict.""" +from __future__ import annotations +import logging +from typing import ( + Optional, + Tuple, + Union, + List, +) + +from opentrons_shared_data.errors.exceptions import MotionPlanningFailureError +from opentrons_shared_data.module import FLEX_TC_LID_COLLISION_ZONE + +from opentrons.hardware_control import CriticalPoint +from opentrons.motion_planning import adjacent_slots_getters + +from opentrons.protocol_engine import ( + StateView, + DeckSlotLocation, + OnLabwareLocation, + WellLocation, + DropTipWellLocation, +) +from opentrons.protocol_engine.types import ( + StagingSlotLocation, +) +from opentrons.types import DeckSlotName, StagingSlotName, Point +from . import point_calculations + + +class PartialTipMovementNotAllowedError(MotionPlanningFailureError): + """Error raised when trying to perform a partial tip movement to an illegal location.""" + + def __init__(self, message: str) -> None: + super().__init__( + message=message, + ) + + +class UnsuitableTiprackForPipetteMotion(MotionPlanningFailureError): + """Error raised when trying to perform a pipette movement to a tip rack, based on adapter status.""" + + def __init__(self, message: str) -> None: + super().__init__( + message=message, + ) + + +_log = logging.getLogger(__name__) + +_FLEX_TC_LID_BACK_LEFT_PT = Point( + x=FLEX_TC_LID_COLLISION_ZONE["back_left"]["x"], + y=FLEX_TC_LID_COLLISION_ZONE["back_left"]["y"], + z=FLEX_TC_LID_COLLISION_ZONE["back_left"]["z"], +) + +_FLEX_TC_LID_FRONT_RIGHT_PT = Point( + x=FLEX_TC_LID_COLLISION_ZONE["front_right"]["x"], + y=FLEX_TC_LID_COLLISION_ZONE["front_right"]["y"], + z=FLEX_TC_LID_COLLISION_ZONE["front_right"]["z"], +) + + +def check_safe_for_pipette_movement( + engine_state: StateView, + pipette_id: str, + labware_id: str, + well_name: str, + well_location: Union[WellLocation, DropTipWellLocation], +) -> None: + """Check if the labware is safe to move to with a pipette in partial tip configuration. + + Args: + engine_state: engine state view + pipette_id: ID of the pipette to be moved + labware_id: ID of the labware we are moving to + well_name: Name of the well to move to + well_location: exact location within the well to move to + """ + # TODO (spp, 2023-02-06): remove this check after thorough testing. + # This function is capable of checking for movement conflict regardless of + # nozzle configuration. + if not engine_state.pipettes.get_is_partially_configured(pipette_id): + return + + if isinstance(well_location, DropTipWellLocation): + # convert to WellLocation + well_location = engine_state.geometry.get_checked_tip_drop_location( + pipette_id=pipette_id, + labware_id=labware_id, + well_location=well_location, + partially_configured=True, + ) + well_location_point = engine_state.geometry.get_well_position( + labware_id=labware_id, well_name=well_name, well_location=well_location + ) + primary_nozzle = engine_state.pipettes.get_primary_nozzle(pipette_id) + + destination_cp = _get_critical_point_to_use(engine_state, labware_id) + + pipette_bounds_at_well_location = ( + engine_state.pipettes.get_pipette_bounds_at_specified_move_to_position( + pipette_id=pipette_id, + destination_position=well_location_point, + critical_point=destination_cp, + ) + ) + if not _is_within_pipette_extents( + engine_state=engine_state, + pipette_id=pipette_id, + pipette_bounding_box_at_loc=pipette_bounds_at_well_location, + ): + raise PartialTipMovementNotAllowedError( + f"Requested motion with the {primary_nozzle} nozzle partial configuration" + f" is outside of robot bounds for the pipette." + ) + + labware_slot = engine_state.geometry.get_ancestor_slot_name(labware_id) + + surrounding_slots = adjacent_slots_getters.get_surrounding_slots( + slot=labware_slot.as_int(), robot_type=engine_state.config.robot_type + ) + + if _will_collide_with_thermocycler_lid( + engine_state=engine_state, + pipette_bounds=pipette_bounds_at_well_location, + surrounding_regular_slots=surrounding_slots.regular_slots, + ): + raise PartialTipMovementNotAllowedError( + f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot" + f" {labware_slot} with {primary_nozzle} nozzle partial configuration" + f" will result in collision with thermocycler lid in deck slot A1." + ) + + for regular_slot in surrounding_slots.regular_slots: + if _slot_has_potential_colliding_object( + engine_state=engine_state, + pipette_bounds=pipette_bounds_at_well_location, + surrounding_slot=regular_slot, + ): + raise PartialTipMovementNotAllowedError( + f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot" + f" {labware_slot} with {primary_nozzle} nozzle partial configuration" + f" will result in collision with items in deck slot {regular_slot}." + ) + for staging_slot in surrounding_slots.staging_slots: + if _slot_has_potential_colliding_object( + engine_state=engine_state, + pipette_bounds=pipette_bounds_at_well_location, + surrounding_slot=staging_slot, + ): + raise PartialTipMovementNotAllowedError( + f"Moving to {engine_state.labware.get_display_name(labware_id)} in slot" + f" {labware_slot} with {primary_nozzle} nozzle partial configuration" + f" will result in collision with items in staging slot {staging_slot}." + ) + + +def _get_critical_point_to_use( + engine_state: StateView, labware_id: str +) -> Optional[CriticalPoint]: + """Return the critical point to use when accessing the given labware.""" + # TODO (spp, 2024-09-17): looks like Y_CENTER of column is the same as its XY_CENTER. + # I'm using this if-else ladder to be consistent with what we do in + # `MotionPlanning.get_movement_waypoints_to_well()`. + # We should probably use only XY_CENTER in both places. + if engine_state.labware.get_should_center_column_on_target_well(labware_id): + return CriticalPoint.Y_CENTER + elif engine_state.labware.get_should_center_pipette_on_target_well(labware_id): + return CriticalPoint.XY_CENTER + return None + + +def _slot_has_potential_colliding_object( + engine_state: StateView, + pipette_bounds: Tuple[Point, Point, Point, Point], + surrounding_slot: Union[DeckSlotName, StagingSlotName], +) -> bool: + """Return the slot, if any, that has an item that the pipette might collide into.""" + # Check if slot overlaps with pipette position + slot_pos = engine_state.addressable_areas.get_addressable_area_position( + addressable_area_name=surrounding_slot.id, + do_compatibility_check=False, + ) + slot_bounds = engine_state.addressable_areas.get_addressable_area_bounding_box( + addressable_area_name=surrounding_slot.id, + do_compatibility_check=False, + ) + slot_back_left_coords = Point(slot_pos.x, slot_pos.y + slot_bounds.y, slot_pos.z) + slot_front_right_coords = Point(slot_pos.x + slot_bounds.x, slot_pos.y, slot_pos.z) + + # If slot overlaps with pipette bounds + if point_calculations.are_overlapping_rectangles( + rectangle1=(pipette_bounds[0], pipette_bounds[1]), + rectangle2=(slot_back_left_coords, slot_front_right_coords), + ): + # Check z-height of items in overlapping slot + if isinstance(surrounding_slot, DeckSlotName): + slot_highest_z = engine_state.geometry.get_highest_z_in_slot( + DeckSlotLocation(slotName=surrounding_slot) + ) + else: + slot_highest_z = engine_state.geometry.get_highest_z_in_slot( + StagingSlotLocation(slotName=surrounding_slot) + ) + return slot_highest_z >= pipette_bounds[0].z + return False + + +def _will_collide_with_thermocycler_lid( + engine_state: StateView, + pipette_bounds: Tuple[Point, Point, Point, Point], + surrounding_regular_slots: List[DeckSlotName], +) -> bool: + """Return whether the pipette might collide with thermocycler's lid/clips on a Flex. + + If any of the pipette's bounding vertices lie inside the no-go zone of the thermocycler- + which is the area that's to the left, back and below the thermocycler's lid's + protruding clips, then we will mark the movement for possible collision. + + This could cause false raises for the case where an 8-channel is accessing the + thermocycler labware in a location such that the pipette is in the area between + the clips but not touching either clips. But that's a tradeoff we'll need to make + between a complicated check involving accurate positions of all entities involved + and a crude check that disallows all partial tip movements around the thermocycler. + """ + # TODO (spp, 2024-02-27): Improvements: + # - make the check dynamic according to lid state: + # - if lid is open, check if pipette is in no-go zone + # - if lid is closed, use the closed lid height to check for conflict + if ( + DeckSlotName.SLOT_A1 in surrounding_regular_slots + and engine_state.modules.is_flex_deck_with_thermocycler() + ): + return ( + point_calculations.are_overlapping_rectangles( + rectangle1=(_FLEX_TC_LID_BACK_LEFT_PT, _FLEX_TC_LID_FRONT_RIGHT_PT), + rectangle2=(pipette_bounds[0], pipette_bounds[1]), + ) + and pipette_bounds[0].z <= _FLEX_TC_LID_BACK_LEFT_PT.z + ) + + return False + + +def check_safe_for_tip_pickup_and_return( + engine_state: StateView, + pipette_id: str, + labware_id: str, +) -> None: + """Check if the presence or absence of a tiprack adapter might cause any pipette movement issues. + + A 96 channel pipette will pick up tips using cam action when it's configured + to use ALL nozzles. For this, the tiprack needs to be on the Flex 96 channel tiprack adapter + or similar or the tips will not be picked up. + + On the other hand, if the pipette is configured with partial nozzle configuration, + it uses the usual pipette presses to pick the tips up, in which case, having the tiprack + on the Flex 96 channel tiprack adapter (or similar) will cause the pipette to + crash against the adapter posts. + + In order to check if the 96-channel can move and pickup/drop tips safely, this method + checks for the height attribute of the tiprack adapter rather than checking for the + specific official adapter since users might create custom labware &/or definitions + compatible with the official adapter. + """ + if not engine_state.pipettes.get_channels(pipette_id) == 96: + # Adapters only matter to 96 ch. + return + + is_partial_config = engine_state.pipettes.get_is_partially_configured(pipette_id) + tiprack_name = engine_state.labware.get_display_name(labware_id) + tiprack_parent = engine_state.labware.get_location(labware_id) + if isinstance(tiprack_parent, OnLabwareLocation): # tiprack is on an adapter + is_96_ch_tiprack_adapter = engine_state.labware.get_has_quirk( + labware_id=tiprack_parent.labwareId, quirk="tiprackAdapterFor96Channel" + ) + tiprack_height = engine_state.labware.get_dimensions(labware_id).z + adapter_height = engine_state.labware.get_dimensions(tiprack_parent.labwareId).z + if is_partial_config and tiprack_height < adapter_height: + raise PartialTipMovementNotAllowedError( + f"{tiprack_name} cannot be on an adapter taller than the tip rack" + f" when picking up fewer than 96 tips." + ) + elif not is_partial_config and not is_96_ch_tiprack_adapter: + raise UnsuitableTiprackForPipetteMotion( + f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" + f" in order to pick up or return all 96 tips simultaneously." + ) + + elif ( + not is_partial_config + ): # tiprack is not on adapter and pipette is in full config + raise UnsuitableTiprackForPipetteMotion( + f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" + f" in order to pick up or return all 96 tips simultaneously." + ) + + +def _is_within_pipette_extents( + engine_state: StateView, + pipette_id: str, + pipette_bounding_box_at_loc: Tuple[Point, Point, Point, Point], +) -> bool: + """Whether a given point is within the extents of a configured pipette on the specified robot.""" + channels = engine_state.pipettes.get_channels(pipette_id) + robot_extents = engine_state.geometry.absolute_deck_extents + ( + pip_back_left_bound, + pip_front_right_bound, + pip_back_right_bound, + pip_front_left_bound, + ) = pipette_bounding_box_at_loc + + # Given the padding values accounted for against the deck extents, + # a pipette is within extents when all of the following are true: + + # Each corner slot full pickup case: + # A1: Front right nozzle is within the rear and left-side padding limits + # D1: Back right nozzle is within the front and left-side padding limits + # A3 Front left nozzle is within the rear and right-side padding limits + # D3: Back left nozzle is within the front and right-side padding limits + # Thermocycler Column A2: Front right nozzle is within padding limits + + if channels == 96: + return ( + pip_front_right_bound.y + <= robot_extents.deck_extents.y + robot_extents.padding_rear + and pip_front_right_bound.x >= robot_extents.padding_left_side + and pip_back_right_bound.y >= robot_extents.padding_front + and pip_back_right_bound.x >= robot_extents.padding_left_side + and pip_front_left_bound.y + <= robot_extents.deck_extents.y + robot_extents.padding_rear + and pip_front_left_bound.x + <= robot_extents.deck_extents.x + robot_extents.padding_right_side + and pip_back_left_bound.y >= robot_extents.padding_front + and pip_back_left_bound.x + <= robot_extents.deck_extents.x + robot_extents.padding_right_side + ) + # For 8ch pipettes we only check the rear and front extents + return ( + pip_front_right_bound.y + <= robot_extents.deck_extents.y + robot_extents.padding_rear + and pip_back_right_bound.y >= robot_extents.padding_front + and pip_front_left_bound.y + <= robot_extents.deck_extents.y + robot_extents.padding_rear + and pip_back_left_bound.y >= robot_extents.padding_front + ) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index 9a46318c8b8..42e17983018 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -19,7 +19,7 @@ _TRASH_BIN_CUTOUT_FIXTURE, ) from opentrons.protocol_api.labware import Labware -from opentrons.protocol_api.core.engine import deck_conflict +from opentrons.protocol_api.core.engine import deck_conflict, pipette_movement_conflict from opentrons.protocol_engine import ( Config, DeckSlotLocation, @@ -441,7 +441,7 @@ def test_maps_trash_bins( Point(x=50, y=50, z=40), ), pytest.raises( - deck_conflict.PartialTipMovementNotAllowedError, + pipette_movement_conflict.PartialTipMovementNotAllowedError, match="collision with items in deck slot D1", ), 0, @@ -454,7 +454,7 @@ def test_maps_trash_bins( Point(x=101, y=50, z=40), ), pytest.raises( - deck_conflict.PartialTipMovementNotAllowedError, + pipette_movement_conflict.PartialTipMovementNotAllowedError, match="collision with items in deck slot D2", ), 0, @@ -467,7 +467,7 @@ def test_maps_trash_bins( Point(x=250, y=150, z=40), ), pytest.raises( - deck_conflict.PartialTipMovementNotAllowedError, + pipette_movement_conflict.PartialTipMovementNotAllowedError, match="will result in collision with items in staging slot C4.", ), 170, @@ -623,7 +623,7 @@ def test_deck_conflict_raises_for_bad_pipette_move( ).then_return(Dimensions(90, 90, 0)) with expected_raise: - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_state_view, pipette_id="pipette-id", labware_id="destination-labware-id", @@ -726,10 +726,10 @@ def test_deck_conflict_raises_for_collision_with_tc_lid( True ) with pytest.raises( - deck_conflict.PartialTipMovementNotAllowedError, + pipette_movement_conflict.PartialTipMovementNotAllowedError, match="Requested motion with the A12 nozzle partial configuration is outside of robot bounds for the pipette.", ): - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_state_view, pipette_id="pipette-id", labware_id="destination-labware-id", @@ -829,7 +829,7 @@ class PipetteMovementSpec(NamedTuple): is_on_flex_adapter=False, is_partial_config=False, expected_raise=pytest.raises( - deck_conflict.UnsuitableTiprackForPipetteMotion, + pipette_movement_conflict.UnsuitableTiprackForPipetteMotion, match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter", ), ), @@ -846,7 +846,7 @@ class PipetteMovementSpec(NamedTuple): is_on_flex_adapter=False, is_partial_config=False, expected_raise=pytest.raises( - deck_conflict.UnsuitableTiprackForPipetteMotion, + pipette_movement_conflict.UnsuitableTiprackForPipetteMotion, match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter", ), ), @@ -856,7 +856,7 @@ class PipetteMovementSpec(NamedTuple): is_on_flex_adapter=True, is_partial_config=True, expected_raise=pytest.raises( - deck_conflict.PartialTipMovementNotAllowedError, + pipette_movement_conflict.PartialTipMovementNotAllowedError, match="A cool tiprack cannot be on an adapter taller than the tip rack", ), ), @@ -918,7 +918,7 @@ def test_valid_96_pipette_movement_for_tiprack_and_adapter( ).then_return(is_on_flex_adapter) with expected_raise: - deck_conflict.check_safe_for_tip_pickup_and_return( + pipette_movement_conflict.check_safe_for_tip_pickup_and_return( engine_state=mock_state_view, pipette_id="pipette-id", labware_id="labware-id", diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 8854c070ef0..bd3cebe94d7 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -44,7 +44,7 @@ InstrumentCore, WellCore, ProtocolCore, - deck_conflict, + pipette_movement_conflict, ) from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION from opentrons.protocols.api_support.types import APIVersion @@ -76,8 +76,10 @@ def patch_mock_pipette_movement_safety_check( decoy: Decoy, monkeypatch: pytest.MonkeyPatch ) -> None: """Replace deck_conflict.check() with a mock.""" - mock = decoy.mock(func=deck_conflict.check_safe_for_pipette_movement) - monkeypatch.setattr(deck_conflict, "check_safe_for_pipette_movement", mock) + mock = decoy.mock(func=pipette_movement_conflict.check_safe_for_pipette_movement) + monkeypatch.setattr( + pipette_movement_conflict, "check_safe_for_pipette_movement", mock + ) @pytest.fixture @@ -271,12 +273,12 @@ def test_pick_up_tip( ) decoy.verify( - deck_conflict.check_safe_for_tip_pickup_and_return( + pipette_movement_conflict.check_safe_for_tip_pickup_and_return( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="labware-id", ), - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="labware-id", @@ -325,7 +327,7 @@ def test_drop_tip_no_location( subject.drop_tip(location=None, well_core=well_core, home_after=True) decoy.verify( - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="labware-id", @@ -376,12 +378,12 @@ def test_drop_tip_with_location( subject.drop_tip(location=location, well_core=well_core, home_after=True) decoy.verify( - deck_conflict.check_safe_for_tip_pickup_and_return( + pipette_movement_conflict.check_safe_for_tip_pickup_and_return( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="labware-id", ), - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="labware-id", @@ -504,7 +506,7 @@ def test_aspirate_from_well( ) decoy.verify( - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="123abc", @@ -618,7 +620,7 @@ def test_blow_out_to_well( subject.blow_out(location=location, well_core=well_core, in_place=False) decoy.verify( - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="123abc", @@ -729,7 +731,7 @@ def test_dispense_to_well( ) decoy.verify( - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="123abc", @@ -1113,7 +1115,7 @@ def test_touch_tip( ) decoy.verify( - deck_conflict.check_safe_for_pipette_movement( + pipette_movement_conflict.check_safe_for_pipette_movement( engine_state=mock_engine_client.state, pipette_id="abc123", labware_id="123abc", diff --git a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py index ebaf5e49971..cad2bffddf9 100644 --- a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py +++ b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py @@ -4,7 +4,7 @@ from opentrons import simulate from opentrons.protocol_api import COLUMN, ALL, SINGLE, ROW -from opentrons.protocol_api.core.engine.deck_conflict import ( +from opentrons.protocol_api.core.engine.pipette_movement_conflict import ( PartialTipMovementNotAllowedError, ) diff --git a/hardware-testing/hardware_testing/gravimetric/helpers.py b/hardware-testing/hardware_testing/gravimetric/helpers.py index eaadca2c6a9..b3533a002b0 100644 --- a/hardware-testing/hardware_testing/gravimetric/helpers.py +++ b/hardware-testing/hardware_testing/gravimetric/helpers.py @@ -41,7 +41,7 @@ WellLocation, DropTipWellLocation, ) -from opentrons.protocol_api.core.engine import deck_conflict as DeckConflit +from opentrons.protocol_api.core.engine import pipette_movement_conflict def _add_fake_simulate( @@ -455,8 +455,8 @@ def _load_pipette( front_right_nozzle="A1", back_left_nozzle="A1", ) - # override deck conflict checking cause we specially lay out our tipracks - DeckConflit.check_safe_for_pipette_movement = ( + # override pipette movement conflict checking 'cause we specially lay out our tipracks + pipette_movement_conflict.check_safe_for_pipette_movement = ( _override_check_safe_for_pipette_movement ) pipette.trash_container = trash From 0567d3623341badaf58e9adcfda92485f2759f44 Mon Sep 17 00:00:00 2001 From: syao1226 <146495172+syao1226@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:01:50 -0400 Subject: [PATCH 025/101] fix(protocol-designer): update Magnetic Module step form (#16424) fixes RQA-3277 # Overview I noticed that the label text next to the toggle button no longer displays on the Magnetic Module step form. This PR aims to fix that and update the form to better match the [design](https://www.figma.com/design/WbkiUyU8VhtKz0JSuIFA45/Feature%3A-Protocol-Designer-Phase-1?node-id=5536-13337&node-type=canvas&t=F8BuTfbRtt7v67rR-0). ## Test Plan and Hands on Testing - Create an OT-2 protocol and add a magnetic module GEN1 or GEN2 - Go to the Protocol Steps tab and add a step for Magnet ## Changelog - Added an optional boolean `isLabel` prop in `ToggleExpandStepFormField` to handle displaying label text next to the toggle button when needed - Changed `magnetAction.label `from "Magnet action" to "Magnet state" in `form.json` - Remove Box component with borderBottom in `ToolBox` to get rid of double grey separation lines - Used `getInitialDeckSetup` and `getModulesOnDeckByType` to get the slot location info for displaying the icon ## Review requests ## Risk assessment --------- Co-authored-by: shiyaochen --- components/src/organisms/Toolbox/index.tsx | 1 - .../src/assets/localization/en/form.json | 2 +- .../forms/__tests__/MagnetForm.test.tsx | 2 +- .../ToggleExpandStepFormField/index.tsx | 27 ++++++++---- .../StepForm/StepTools/MagnetTools/index.tsx | 42 +++++++++++++++---- .../StepTools/__tests__/MagnetTools.test.tsx | 27 ++++++++++-- 6 files changed, 80 insertions(+), 21 deletions(-) diff --git a/components/src/organisms/Toolbox/index.tsx b/components/src/organisms/Toolbox/index.tsx index 1a6cb435a9e..566bcf1e4bf 100644 --- a/components/src/organisms/Toolbox/index.tsx +++ b/components/src/organisms/Toolbox/index.tsx @@ -119,7 +119,6 @@ export function Toolbox(props: ToolboxProps): JSX.Element { - { screen.getByText('magnet') screen.getByText('module') screen.getByText('mock name') - screen.getByText('Magnet action') + screen.getByText('Magnet state') const engage = screen.getByText('Engage') screen.getByText('Disengage') fireEvent.click(engage) diff --git a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx index 29977196a3d..ed57de37f3b 100644 --- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx @@ -23,6 +23,7 @@ interface ToggleExpandStepFormFieldProps extends FieldProps { toggleUpdateValue: (value: unknown) => void toggleValue: unknown caption?: string + islabel?: boolean } export function ToggleExpandStepFormField( props: ToggleExpandStepFormFieldProps @@ -37,6 +38,7 @@ export function ToggleExpandStepFormField( toggleUpdateValue, toggleValue, caption, + islabel, ...restProps } = props @@ -58,13 +60,24 @@ export function ToggleExpandStepFormField( > {title} - { - onToggleUpdateValue() - }} - label={isSelected ? onLabel : offLabel} - toggledOn={isSelected} - /> + + {islabel ? ( + + {isSelected ? onLabel : offLabel} + + ) : null} + + { + onToggleUpdateValue() + }} + label={isSelected ? onLabel : offLabel} + toggledOn={isSelected} + /> + {isSelected ? ( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx index 7f7afd9702a..e32bbd860fb 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx @@ -3,13 +3,17 @@ import { useTranslation } from 'react-i18next' import { COLORS, DIRECTION_COLUMN, + DeckInfoLabel, Divider, Flex, ListItem, SPACING, StyledText, } from '@opentrons/components' -import { MAGNETIC_MODULE_V1 } from '@opentrons/shared-data' +import { + MAGNETIC_MODULE_TYPE, + MAGNETIC_MODULE_V1, +} from '@opentrons/shared-data' import { MAX_ENGAGE_HEIGHT_V1, MAX_ENGAGE_HEIGHT_V2, @@ -21,7 +25,11 @@ import { getMagneticLabwareOptions, } from '../../../../../../ui/modules/selectors' import { ToggleExpandStepFormField } from '../../../../../../molecules' -import { getModuleEntities } from '../../../../../../step-forms/selectors' +import { + getInitialDeckSetup, + getModuleEntities, +} from '../../../../../../step-forms/selectors' +import { getModulesOnDeckByType } from '../../../../../../ui/modules/utils' import type { StepFormProps } from '../../types' @@ -31,8 +39,16 @@ export function MagnetTools(props: StepFormProps): JSX.Element { const moduleLabwareOptions = useSelector(getMagneticLabwareOptions) const moduleEntities = useSelector(getModuleEntities) const defaultEngageHeight = useSelector(getMagnetLabwareEngageHeight) + const deckSetup = useSelector(getInitialDeckSetup) + const modulesOnDeck = getModulesOnDeckByType(deckSetup, MAGNETIC_MODULE_TYPE) + + console.log(modulesOnDeck) + const moduleModel = moduleEntities[formData.moduleId].model + const slotInfo = moduleLabwareOptions[0].name.split('in') + const slotLocation = modulesOnDeck != null ? modulesOnDeck[0].slot : '' + const mmUnits = t('units.millimeter') const isGen1 = moduleModel === MAGNETIC_MODULE_V1 const engageHeightMinMax = isGen1 @@ -53,7 +69,7 @@ export function MagnetTools(props: StepFormProps): JSX.Element { }) : '' const engageHeightCaption = `${engageHeightMinMax} ${engageHeightDefault}` - + // TODO (10-9-2024): Replace ListItem with ListItemDescriptor return ( - - - {moduleLabwareOptions[0].name} - + + + + + + + {slotInfo[0]} + + + {slotInfo[1]} + + @@ -88,6 +115,7 @@ export function MagnetTools(props: StepFormProps): JSX.Element { 'form:step_edit_form.field.magnetAction.options.disengage' )} caption={engageHeightCaption} + islabel={true} /> diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx index 968c523977e..5a901290c37 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx @@ -6,7 +6,10 @@ import { getMagneticLabwareOptions, getMagnetLabwareEngageHeight, } from '../../../../../../ui/modules/selectors' -import { getModuleEntities } from '../../../../../../step-forms/selectors' +import { + getInitialDeckSetup, + getModuleEntities, +} from '../../../../../../step-forms/selectors' import { MagnetTools } from '../MagnetTools' import type { ComponentProps } from 'react' import type * as ModulesSelectors from '../../../../../../ui/modules/selectors' @@ -67,7 +70,7 @@ describe('MagnetTools', () => { }, } vi.mocked(getMagneticLabwareOptions).mockReturnValue([ - { name: 'mock name', value: 'mockValue' }, + { name: 'mock labware in mock module in slot abc', value: 'mockValue' }, ]) vi.mocked(getModuleEntities).mockReturnValue({ magnetId: { @@ -77,13 +80,29 @@ describe('MagnetTools', () => { }, }) vi.mocked(getMagnetLabwareEngageHeight).mockReturnValue(null) + vi.mocked(getInitialDeckSetup).mockReturnValue({ + labware: {}, + modules: { + module: { + id: 'mockId', + slot: '10', + type: 'magneticModuleType', + moduleState: { engaged: false, type: 'magneticModuleType' }, + model: 'magneticModuleV1', + }, + }, + additionalEquipmentOnDeck: {}, + pipettes: {}, + }) }) it('renders the text and a switch button for v2', () => { render(props) screen.getByText('Module') - screen.getByText('mock name') - screen.getByText('Magnet action') + screen.getByText('10') + screen.getByText('mock labware') + screen.getByText('mock module') + screen.getByText('Magnet state') screen.getByLabelText('Engage') const toggleButton = screen.getByRole('switch') screen.getByText('Engage height') From b808a5443f4ed3ba7156e9126ea71ff66b13d396 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 9 Oct 2024 15:05:18 -0400 Subject: [PATCH 026/101] feat(shared-data): Support nozzle layouts in well selection (#16441) Works towards RSQ-161 When we want a well set relevant to a specific pipette's interaction with a specific labware, we use a helper, getWellSetForMultichannel. This works great, but it doesn't have the ability to return relevant wells given a nozzle layout. So for example, if the pipette has a nozzle layout of "column" on a 96 channel, and the well of interest is A2, all 96-wells are returned. This PR adds support for all current nozzle layouts, which are generally utilized in the context of partial tip configs. In nozzle configurations which do not naturally incorporate the entire row/column (this is only the 8-channel column config with <8 nozzles, currently), return thewellName and the number of relevant wells "lower" than that well name. Ex, if B1 is the wellName and the active nozzle count is 4, return B1-E1. If less wells are available than the activeNozzleCount, return fewer wells. --- api-client/src/runs/types.ts | 2 +- app/src/organisms/WellSelection/index.tsx | 22 +- .../components/labware/SelectableLabware.tsx | 23 +- .../formLevel/handleFormChange/utils.ts | 6 +- .../src/top-selectors/substep-highlight.ts | 20 +- .../js/helpers/__tests__/wellSets.test.ts | 322 +++++++++++++----- shared-data/js/helpers/wellSets.ts | 161 +++++++-- shared-data/js/types.ts | 7 + 8 files changed, 405 insertions(+), 158 deletions(-) diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 241a3892622..0415367f1e6 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -7,6 +7,7 @@ import type { RunCommandError, RunTimeCommand, RunTimeParameter, + NozzleLayoutConfig, } from '@opentrons/shared-data' import type { ResourceLink, ErrorDetails } from '../types' export * from './commands/types' @@ -200,4 +201,3 @@ export interface NozzleLayoutValues { activeNozzles: string[] config: NozzleLayoutConfig } -export type NozzleLayoutConfig = 'column' | 'row' | 'full' | 'subrect' diff --git a/app/src/organisms/WellSelection/index.tsx b/app/src/organisms/WellSelection/index.tsx index 656745c0056..eeca145497b 100644 --- a/app/src/organisms/WellSelection/index.tsx +++ b/app/src/organisms/WellSelection/index.tsx @@ -45,11 +45,11 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { const primaryWells: WellGroup = reduce( selectedWells, (acc: WellGroup, _, wellName: string): WellGroup => { - const wellSet = getWellSetForMultichannel( - definition, + const wellSet = getWellSetForMultichannel({ + labwareDef: definition, wellName, - channels - ) + channels, + }) if (!wellSet) return acc return { ...acc, [wellSet[0]]: null } }, @@ -74,7 +74,11 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { selectedWells, (acc: WellGroup, _, wellName: string): WellGroup => { const wellSetForMulti = - getWellSetForMultichannel(definition, wellName, channels) || [] + getWellSetForMultichannel({ + labwareDef: definition, + wellName, + channels, + }) || [] const channelWells = arrayToWellGroup(wellSetForMulti) return { ...acc, @@ -102,11 +106,11 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { ? reduce( selectedPrimaryWells, (acc, _, wellName): WellGroup => { - const wellSet = getWellSetForMultichannel( - definition, + const wellSet = getWellSetForMultichannel({ + labwareDef: definition, wellName, - channels - ) + channels, + }) if (!wellSet) return acc return { ...acc, ...arrayToWellGroup(wellSet) } }, diff --git a/protocol-designer/src/components/labware/SelectableLabware.tsx b/protocol-designer/src/components/labware/SelectableLabware.tsx index 55e9d07a239..bc5af2d1d30 100644 --- a/protocol-designer/src/components/labware/SelectableLabware.tsx +++ b/protocol-designer/src/components/labware/SelectableLabware.tsx @@ -75,11 +75,11 @@ export const SelectableLabware = (props: Props): JSX.Element => { const primaryWells: WellGroup = reduce( selectedWells, (acc: WellGroup, _, wellName: string): WellGroup => { - const wellSet = getWellSetForMultichannel( + const wellSet = getWellSetForMultichannel({ labwareDef, wellName, - channels - ) + channels, + }) if (!wellSet) return acc return { ...acc, [wellSet[0]]: null } }, @@ -109,7 +109,8 @@ export const SelectableLabware = (props: Props): JSX.Element => { selectedWells, (acc: WellGroup, _, wellName: string): WellGroup => { const wellSetForMulti = - getWellSetForMultichannel(labwareDef, wellName, channels) || [] + getWellSetForMultichannel({ labwareDef, wellName, channels }) || + [] const channelWells = arrayToWellGroup(wellSetForMulti) return { ...acc, @@ -144,11 +145,11 @@ export const SelectableLabware = (props: Props): JSX.Element => { const handleMouseEnterWell: (args: WellMouseEvent) => void = args => { if (nozzleType != null) { const channels = getChannelsFromNozleType(nozzleType) - const wellSet = getWellSetForMultichannel( + const wellSet = getWellSetForMultichannel({ labwareDef, - args.wellName, - channels - ) + wellName: args.wellName, + channels, + }) const nextHighlightedWells = arrayToWellGroup(wellSet || []) nextHighlightedWells && updateHighlightedWells(nextHighlightedWells) } else { @@ -163,11 +164,11 @@ export const SelectableLabware = (props: Props): JSX.Element => { selectedPrimaryWells, (acc, _, wellName): WellGroup => { const channels = getChannelsFromNozleType(nozzleType) - const wellSet = getWellSetForMultichannel( + const wellSet = getWellSetForMultichannel({ labwareDef, wellName, - channels - ) + channels, + }) if (!wellSet) return acc return { ...acc, ...arrayToWellGroup(wellSet) } }, diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/utils.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/utils.ts index 84ef2481097..169fc3256f0 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/utils.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/utils.ts @@ -28,7 +28,11 @@ export function getAllWellsFromPrimaryWells( channels: 8 | 96 ): string[] { const allWells = primaryWells.reduce((acc: string[], well: string) => { - const nextWellSet = getWellSetForMultichannel(labwareDef, well, channels) + const nextWellSet = getWellSetForMultichannel({ + labwareDef, + wellName: well, + channels, + }) // filter out any nulls (but you shouldn't get any) if (!nextWellSet) { diff --git a/protocol-designer/src/top-selectors/substep-highlight.ts b/protocol-designer/src/top-selectors/substep-highlight.ts index 8aeccdb5ef4..049bd59e7df 100644 --- a/protocol-designer/src/top-selectors/substep-highlight.ts +++ b/protocol-designer/src/top-selectors/substep-highlight.ts @@ -130,11 +130,11 @@ function _getSelectedWellsForStep( wells.push(commandWellName) } else if (channels === 8 || channels === 96) { const wellSet = - getWellSetForMultichannel( - invariantContext.labwareEntities[labwareId].def, - commandWellName, - channels - ) || [] + getWellSetForMultichannel({ + labwareDef: invariantContext.labwareEntities[labwareId].def, + wellName: commandWellName, + channels, + }) || [] wells.push(...wellSet) } else { console.error( @@ -241,11 +241,11 @@ function _getSelectedWellsForSubstep( activeTips.labwareId === labwareId && channels !== 1 ) { - const multiTipWellSet = getWellSetForMultichannel( - invariantContext.labwareEntities[labwareId].def, - activeTips.wellName, - channels - ) + const multiTipWellSet = getWellSetForMultichannel({ + labwareDef: invariantContext.labwareEntities[labwareId].def, + wellName: activeTips.wellName, + channels, + }) if (multiTipWellSet) tipWellSet = multiTipWellSet } } else { diff --git a/shared-data/js/helpers/__tests__/wellSets.test.ts b/shared-data/js/helpers/__tests__/wellSets.test.ts index ef842872f2f..115a4ee8428 100644 --- a/shared-data/js/helpers/__tests__/wellSets.test.ts +++ b/shared-data/js/helpers/__tests__/wellSets.test.ts @@ -226,53 +226,45 @@ describe('getWellSetForMultichannel (integration test)', () => { it('96-flat', () => { const labwareDef = fixture96Plate - expect(getWellSetForMultichannel(labwareDef, 'A1', EIGHT_CHANNEL)).toEqual([ - 'A1', - 'B1', - 'C1', - 'D1', - 'E1', - 'F1', - 'G1', - 'H1', - ]) - - expect(getWellSetForMultichannel(labwareDef, 'B1', EIGHT_CHANNEL)).toEqual([ - 'A1', - 'B1', - 'C1', - 'D1', - 'E1', - 'F1', - 'G1', - 'H1', - ]) - - expect(getWellSetForMultichannel(labwareDef, 'H1', EIGHT_CHANNEL)).toEqual([ - 'A1', - 'B1', - 'C1', - 'D1', - 'E1', - 'F1', - 'G1', - 'H1', - ]) - - expect(getWellSetForMultichannel(labwareDef, 'A2', EIGHT_CHANNEL)).toEqual([ - 'A2', - 'B2', - 'C2', - 'D2', - 'E2', - 'F2', - 'G2', - 'H2', - ]) + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'A1', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']) + + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'B1', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']) + + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'H1', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']) + + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'A2', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2']) // 96-channel expect( - getWellSetForMultichannel(labwareDef, 'A1', NINETY_SIX_CHANNEL) + getWellSetForMultichannel({ + labwareDef, + wellName: 'A1', + channels: NINETY_SIX_CHANNEL, + }) ).toEqual(wellsFor96WellPlate) }) @@ -280,38 +272,40 @@ describe('getWellSetForMultichannel (integration test)', () => { const labwareDef = fixture96Plate expect( - getWellSetForMultichannel(labwareDef, 'A13', EIGHT_CHANNEL) + getWellSetForMultichannel({ + labwareDef, + wellName: 'A13', + channels: EIGHT_CHANNEL, + }) ).toBeFalsy() }) it('trough-12row', () => { const labwareDef = fixture12Trough - expect(getWellSetForMultichannel(labwareDef, 'A1', EIGHT_CHANNEL)).toEqual([ - 'A1', - 'A1', - 'A1', - 'A1', - 'A1', - 'A1', - 'A1', - 'A1', - ]) - - expect(getWellSetForMultichannel(labwareDef, 'A2', EIGHT_CHANNEL)).toEqual([ - 'A2', - 'A2', - 'A2', - 'A2', - 'A2', - 'A2', - 'A2', - 'A2', - ]) + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'A1', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1']) + + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'A2', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['A2', 'A2', 'A2', 'A2', 'A2', 'A2', 'A2', 'A2']) // 96-channel expect( - getWellSetForMultichannel(labwareDef, 'A1', NINETY_SIX_CHANNEL) + getWellSetForMultichannel({ + labwareDef, + wellName: 'A1', + channels: NINETY_SIX_CHANNEL, + }) ).toEqual(wellsForReservoir) }) @@ -324,31 +318,181 @@ describe('getWellSetForMultichannel (integration test)', () => { well96Channel ) - expect(getWellSetForMultichannel(labwareDef, 'C1', EIGHT_CHANNEL)).toEqual([ - 'A1', - 'C1', - 'E1', - 'G1', - 'I1', - 'K1', - 'M1', - 'O1', - ]) - - expect(getWellSetForMultichannel(labwareDef, 'F2', EIGHT_CHANNEL)).toEqual([ - 'B2', - 'D2', - 'F2', - 'H2', - 'J2', - 'L2', - 'N2', - 'P2', - ]) + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'C1', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['A1', 'C1', 'E1', 'G1', 'I1', 'K1', 'M1', 'O1']) + + expect( + getWellSetForMultichannel({ + labwareDef, + wellName: 'F2', + channels: EIGHT_CHANNEL, + }) + ).toEqual(['B2', 'D2', 'F2', 'H2', 'J2', 'L2', 'N2', 'P2']) // 96-channel expect( - getWellSetForMultichannel(labwareDef, well96Channel, NINETY_SIX_CHANNEL) + getWellSetForMultichannel({ + labwareDef, + wellName: well96Channel, + channels: NINETY_SIX_CHANNEL, + }) ).toEqual(ninetySixChannelWells) }) }) + +describe('getWellSetForMultichannel with pipetteNozzleDetails', () => { + let getWellSetForMultichannel: ReturnType< + typeof makeWellSetHelpers + >['getWellSetForMultichannel'] + + const labwareDef = fixture96Plate + + beforeEach(() => { + const helpers = makeWellSetHelpers() + getWellSetForMultichannel = helpers.getWellSetForMultichannel + }) + + it('returns full column for 8-channel pipette with no config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 8, + }) + expect(result).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']) + }) + + it('returns full column for 8-channel pipette with full config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 8, + pipetteNozzleDetails: { + nozzleConfig: 'full', + activeNozzleCount: 8, + }, + }) + expect(result).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']) + }) + + it('returns single well for 8-channel pipette with single nozzle config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 8, + pipetteNozzleDetails: { + nozzleConfig: 'single', + activeNozzleCount: 1, + }, + }) + expect(result).toEqual(['C1']) + }) + + it('returns null for 8-channel pipette with row nozzle config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 8, + pipetteNozzleDetails: { + nozzleConfig: 'row', + activeNozzleCount: 8, + }, + }) + expect(result).toEqual(null) + }) + + it('returns null for 8-channel pipette with subrect nozzle config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 8, + pipetteNozzleDetails: { + nozzleConfig: 'subrect', + activeNozzleCount: 8, + }, + }) + expect(result).toEqual(null) + }) + + it('returns partial column for 8-channel pipette with partial column config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 8, + pipetteNozzleDetails: { + nozzleConfig: 'column', + activeNozzleCount: 4, + }, + }) + expect(result).toEqual(['C1', 'D1', 'E1', 'F1']) + }) + + it('handles edge cases for 8-channel partial column selection', () => { + const bottomEdgeResult = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'G1', + channels: 8, + pipetteNozzleDetails: { + nozzleConfig: 'column', + activeNozzleCount: 4, + }, + }) + expect(bottomEdgeResult).toEqual(['G1', 'H1']) + }) + + it('returns full plate for 96-channel pipette with no config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'A1', + channels: 96, + }) + expect(result?.length).toBe(96) + expect(result?.[0]).toBe('A1') + expect(result?.[95]).toBe('H12') + }) + + it('returns full plate for 96-channel pipette with full config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'A1', + channels: 96, + pipetteNozzleDetails: { + nozzleConfig: 'full', + activeNozzleCount: 96, + }, + }) + expect(result?.length).toBe(96) + expect(result?.[0]).toBe('A1') + expect(result?.[95]).toBe('H12') + }) + + it('returns single column for 96-channel pipette with column config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 96, + pipetteNozzleDetails: { + nozzleConfig: 'column', + activeNozzleCount: 8, + }, + }) + expect(result).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']) + }) + + it('returns null for 8-channel pipette with subrect nozzle config', () => { + const result = getWellSetForMultichannel({ + labwareDef: labwareDef, + wellName: 'C1', + channels: 96, + pipetteNozzleDetails: { + nozzleConfig: 'subrect', + activeNozzleCount: 96, + }, + }) + expect(result).toEqual(null) + }) +}) diff --git a/shared-data/js/helpers/wellSets.ts b/shared-data/js/helpers/wellSets.ts index dc369ba0f97..a896e807c62 100644 --- a/shared-data/js/helpers/wellSets.ts +++ b/shared-data/js/helpers/wellSets.ts @@ -15,7 +15,11 @@ import uniq from 'lodash/uniq' import { getWellNamePerMultiTip } from './getWellNamePerMultiTip' import { get96Channel384WellPlateWells, getLabwareDefURI, orderWells } from '.' -import type { LabwareDefinition2, PipetteV2Specs } from '../types' +import type { + LabwareDefinition2, + NozzleLayoutConfig, + PipetteV2Specs, +} from '../types' type WellSetByPrimaryWell = string[][] @@ -38,16 +42,37 @@ function _getAllWellSetsForLabware( ) } +export interface NozzleLayoutDetails { + nozzleConfig: NozzleLayoutConfig + /* The number of nozzles actively used by the pipette in the current configuration. + * Ex, if a 96-channel uses a column config, this will be 8. + * */ + activeNozzleCount: number +} + +export interface WellSetForMultiChannelParams { + labwareDef: LabwareDefinition2 + wellName: string + channels: 8 | 96 + pipetteNozzleDetails?: NozzleLayoutDetails +} + // creates memoized getAllWellSetsForLabware + getWellSetForMultichannel fns. export interface WellSetHelpers { getAllWellSetsForLabware: ( labwareDef: LabwareDefinition2 ) => WellSetByPrimaryWell + /** Given a well for a labware, returns the well set it belongs to (or null) + * for 8-channel and 96-channel access. + * Ex: C2 for 96-flat => ['A2', 'B2', 'C2', ... 'H2'] + * Or A1 for trough => ['A1', 'A1', 'A1', ...] + * + * @param {string[] | undefined} pipetteNozzleDetails If specified, return only the wells utilized by the active pipette + * nozzle configuration. + **/ getWellSetForMultichannel: ( - labwareDef: LabwareDefinition2, - well: string, - channels: 8 | 96 + params: WellSetForMultiChannelParams ) => string[] | null | undefined canPipetteUseLabware: ( @@ -86,40 +111,102 @@ export const makeWellSetHelpers = (): WellSetHelpers => { return wellSetByPrimaryWell } - const getWellSetForMultichannel = ( - labwareDef: LabwareDefinition2, - well: string, - channels: 8 | 96 - ): string[] | null | undefined => { - /** Given a well for a labware, returns the well set it belongs to (or null) - * for 8-channel access. - * Ie: C2 for 96-flat => ['A2', 'B2', 'C2', ... 'H2'] - * Or A1 for trough => ['A1', 'A1', 'A1', ...] - **/ - const allWellSetsFor8Channel = getAllWellSetsForLabware(labwareDef) - /** getting all wells from the plate and turning into 1D array for 96-channel - */ - const orderedWellsFor96Channel = orderWells( - labwareDef.ordering, - 't2b', - 'l2r' - ) - - let ninetySixChannelWells = orderedWellsFor96Channel - /** special casing 384 well plates to be every other well - * both on the x and y ases. - */ - if (orderedWellsFor96Channel.length === 384) { - ninetySixChannelWells = get96Channel384WellPlateWells( - orderedWellsFor96Channel, - well - ) + const getWellSetForMultichannel = ({ + labwareDef, + wellName, + channels, + pipetteNozzleDetails, + }: WellSetForMultiChannelParams): string[] | null => { + // If the nozzle config isn't specified, assume the "full" config. + const nozzleConfig = pipetteNozzleDetails?.nozzleConfig ?? null + + const getActiveRowFromWell = (wellSet: WellSetByPrimaryWell): string[] => { + const rowLetter = wellName.slice(0, 1) + // A = 0, B = 1, Z = 25, etc. + const rowIndex = rowLetter.toUpperCase().charCodeAt(0) - 65 + + return wellSet.map(columnOfWells => columnOfWells[rowIndex]) + } + + const get8ChPartialColumnFromWell = ( + wellSet: WellSetByPrimaryWell + ): string[] => { + const activeNozzleCount = pipetteNozzleDetails?.activeNozzleCount ?? 0 + + // Find the column that contains the given well. + const targetColumn = wellSet.find(column => column.includes(wellName)) + if (targetColumn == null || activeNozzleCount === 0) { + return [] + } + + const wellIndex = targetColumn.indexOf(wellName) + + // If there are fewer wells than active nozzles, only select as many wells as there are nozzles. + return targetColumn.slice(wellIndex, wellIndex + activeNozzleCount) + } + + if (channels === 8) { + const allWellSetsFor8Channel = getAllWellSetsForLabware(labwareDef) + + switch (nozzleConfig) { + case null: + case 'full': + case 'column': { + if ( + pipetteNozzleDetails == null || + pipetteNozzleDetails.activeNozzleCount === 8 + ) { + return ( + allWellSetsFor8Channel.find((wellSet: string[]) => + wellSet.includes(wellName) + ) ?? null + ) + } else { + return get8ChPartialColumnFromWell(allWellSetsFor8Channel) + } + } + case 'single': + return [wellName] + case 'row': + case 'subrect': + default: + console.error('Unhandled nozzleConfig case.') + return null + } + } else { + switch (nozzleConfig) { + case null: + case 'full': { + /** getting all wells from the plate and turning into 1D array for 96-channel + */ + const orderedWellsFor96Channel = orderWells( + labwareDef.ordering, + 't2b', + 'l2r' + ) + /** special casing 384 well plates to be every other well + * both on the x and y ases. + */ + return orderedWellsFor96Channel.length === 384 + ? get96Channel384WellPlateWells(orderedWellsFor96Channel, wellName) + : orderedWellsFor96Channel + } + case 'single': + return [wellName] + case 'column': + return ( + labwareDef.ordering.find((wellSet: string[]) => + wellSet.includes(wellName) + ) ?? null + ) + case 'row': + return getActiveRowFromWell(labwareDef.ordering) + case 'subrect': + default: + console.error('Unhandled nozzleConfig case.') + return null + } } - return channels === 8 - ? allWellSetsFor8Channel.find((wellSet: string[]) => - wellSet.includes(well) - ) - : ninetySixChannelWells } const canPipetteUseLabware = ( diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index 0ffb3f7a649..d903403dfb8 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -878,3 +878,10 @@ export interface CutoutConfig { } export type DeckConfiguration = CutoutConfig[] + +export type NozzleLayoutConfig = + | 'single' + | 'column' + | 'row' + | 'full' + | 'subrect' From 212cae53f1cde19685e27a64538c2f6c53ca320f Mon Sep 17 00:00:00 2001 From: Rhyann Clarke <146747548+rclarke0@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:35:49 -0400 Subject: [PATCH 027/101] feat(abr-testing): count TC disposable lid actions and plate multi reads (#16397) # Overview Counts Gripper Actions with `opentrons_tough_pcr_auto_sealing_lid` and increased tracking with Plate Reader ## Test Plan and Hands on Testing Ran scripts and checked results ## Changelog Added function to count number of times opentrons_tough_pcr_auto_sealing_lid lid is picked up by gripper Added functionality to record each byonoy plate read and specify read type and wavelength Error handling for google sheets interaction. ## Review requests ## Risk assessment --- .../automation/google_sheets_tool.py | 10 +- .../data_collection/abr_google_drive.py | 6 +- .../data_collection/abr_robot_error.py | 5 +- .../data_collection/read_robot_logs.py | 150 ++++++++++++------ 4 files changed, 122 insertions(+), 49 deletions(-) diff --git a/abr-testing/abr_testing/automation/google_sheets_tool.py b/abr-testing/abr_testing/automation/google_sheets_tool.py index beeb141dc9a..3ca3bd38f9b 100644 --- a/abr-testing/abr_testing/automation/google_sheets_tool.py +++ b/abr-testing/abr_testing/automation/google_sheets_tool.py @@ -172,7 +172,15 @@ def update_cell( self, sheet_title: str, row: int, column: int, single_data: Any ) -> Tuple[int, int, Any]: """Update ONE individual cell according to a row and column.""" - self.spread_sheet.worksheet(sheet_title).update_cell(row, column, single_data) + try: + self.spread_sheet.worksheet(sheet_title).update_cell( + row, column, single_data + ) + except gspread.exceptions.APIError: + t.sleep(30) + self.spread_sheet.worksheet(sheet_title).update_cell( + row, column, single_data + ) return row, column, single_data def get_all_data( diff --git a/abr-testing/abr_testing/data_collection/abr_google_drive.py b/abr-testing/abr_testing/data_collection/abr_google_drive.py index 4556b62800b..e1924e3c53e 100644 --- a/abr-testing/abr_testing/data_collection/abr_google_drive.py +++ b/abr-testing/abr_testing/data_collection/abr_google_drive.py @@ -60,7 +60,7 @@ def create_data_dictionary( print(f"Run {run_id} is incomplete. Skipping run.") continue if run_id in runs_to_save: - print("started reading run.") + print(f"started reading run {run_id}.") robot = file_results.get("robot_name") protocol_name = file_results["protocol"]["metadata"].get("protocolName", "") software_version = file_results.get("API_Version", "") @@ -114,7 +114,9 @@ def create_data_dictionary( tc_dict = read_robot_logs.thermocycler_commands(file_results) hs_dict = read_robot_logs.hs_commands(file_results) tm_dict = read_robot_logs.temperature_module_commands(file_results) - pipette_dict = read_robot_logs.instrument_commands(file_results) + pipette_dict = read_robot_logs.instrument_commands( + file_results, labware_name="opentrons_tough_pcr_auto_sealing_lid" + ) plate_reader_dict = read_robot_logs.plate_reader_commands( file_results, hellma_plate_standards ) diff --git a/abr-testing/abr_testing/data_collection/abr_robot_error.py b/abr-testing/abr_testing/data_collection/abr_robot_error.py index 3edf9b315c8..1849699bfa1 100644 --- a/abr-testing/abr_testing/data_collection/abr_robot_error.py +++ b/abr-testing/abr_testing/data_collection/abr_robot_error.py @@ -526,7 +526,10 @@ def get_run_error_info_from_robot( # TODO: make argument or see if I can get rid of with using board_id. project_key = "RABR" print(robot) - parent_key = project_key + "-" + robot.split("ABR")[1] + try: + parent_key = project_key + "-" + robot.split("ABR")[1] + except IndexError: + parent_key = "" # Grab all previous issues all_issues = ticket.issues_on_board(project_key) diff --git a/abr-testing/abr_testing/data_collection/read_robot_logs.py b/abr-testing/abr_testing/data_collection/read_robot_logs.py index 3501a330a70..be74294fbe5 100644 --- a/abr-testing/abr_testing/data_collection/read_robot_logs.py +++ b/abr-testing/abr_testing/data_collection/read_robot_logs.py @@ -9,7 +9,7 @@ from datetime import datetime import os from abr_testing.data_collection.error_levels import ERROR_LEVELS_PATH -from typing import List, Dict, Any, Tuple, Set +from typing import List, Dict, Any, Tuple, Set, Optional import time as t import json import requests @@ -107,7 +107,44 @@ def count_command_in_run_data( return total_command, avg_time -def instrument_commands(file_results: Dict[str, Any]) -> Dict[str, float]: +def identify_labware_ids( + file_results: Dict[str, Any], labware_name: Optional[str] +) -> List[str]: + """Determine what type of labware is being picked up.""" + if labware_name: + labwares = file_results.get("labware", "") + list_of_labware_ids = [] + if len(labwares) > 1: + for labware in labwares: + load_name = labware["loadName"] + if load_name == labware_name: + labware_id = labware["id"] + list_of_labware_ids.append(labware_id) + return list_of_labware_ids + + +def match_pipette_to_action( + command_dict: Dict[str, Any], + commandTypes: List[str], + right_pipette: Optional[str], + left_pipette: Optional[str], +) -> Tuple[int, int]: + """Match pipette id to id in command.""" + right_pipette_add = 0 + left_pipette_add = 0 + for command in commandTypes: + command_type = command_dict["commandType"] + command_pipette = command_dict.get("pipetteId", "") + if command_type == command and command_pipette == right_pipette: + right_pipette_add = 1 + elif command_type == command and command_pipette == left_pipette: + left_pipette_add = 1 + return left_pipette_add, right_pipette_add + + +def instrument_commands( + file_results: Dict[str, Any], labware_name: Optional[str] +) -> Dict[str, float]: """Count number of pipette and gripper commands per run.""" pipettes = file_results.get("pipettes", "") commandData = file_results.get("commands", "") @@ -120,7 +157,9 @@ def instrument_commands(file_results: Dict[str, Any]) -> Dict[str, float]: right_pipette_id = "" left_pipette_id = "" gripper_pickups = 0.0 + gripper_labware_of_interest = 0.0 avg_liquid_probe_time_sec = 0.0 + list_of_labware_ids = identify_labware_ids(file_results, labware_name) # Match pipette mount to id for pipette in pipettes: if pipette["mount"] == "right": @@ -128,30 +167,34 @@ def instrument_commands(file_results: Dict[str, Any]) -> Dict[str, float]: elif pipette["mount"] == "left": left_pipette_id = pipette["id"] for command in commandData: - commandType = command["commandType"] - # Count tip pick ups - if commandType == "pickUpTip": - if command["params"].get("pipetteId", "") == right_pipette_id: - right_tip_pick_up += 1 - elif command["params"].get("pipetteId", "") == left_pipette_id: - left_tip_pick_up += 1 + # Count pick ups + single_left_pickup, single_right_pickup = match_pipette_to_action( + command, ["pickUpTip"], right_pipette_id, left_pipette_id + ) + right_tip_pick_up += single_right_pickup + left_tip_pick_up += single_left_pickup # Count aspirates - elif commandType == "aspirate": - if command["params"].get("pipetteId", "") == right_pipette_id: - right_aspirate += 1 - elif command["params"].get("pipetteId", "") == left_pipette_id: - left_aspirate += 1 + single_left_aspirate, single_right_aspirate = match_pipette_to_action( + command, ["aspirate"], right_pipette_id, left_pipette_id + ) + right_aspirate += single_right_aspirate + left_aspirate += single_left_aspirate # count dispenses/blowouts - elif commandType == "dispense" or commandType == "blowout": - if command["params"].get("pipetteId", "") == right_pipette_id: - right_dispense += 1 - elif command["params"].get("pipetteId", "") == left_pipette_id: - left_dispense += 1 - elif ( + single_left_dispense, single_right_dispense = match_pipette_to_action( + command, ["blowOut", "dispense"], right_pipette_id, left_pipette_id + ) + right_dispense += single_right_dispense + left_dispense += single_left_dispense + # count gripper actions + commandType = command["commandType"] + if ( commandType == "moveLabware" and command["params"]["strategy"] == "usingGripper" ): gripper_pickups += 1 + labware_moving = command["params"]["labwareId"] + if labware_moving in list_of_labware_ids: + gripper_labware_of_interest += 1 liquid_probes, avg_liquid_probe_time_sec = count_command_in_run_data( commandData, "liquidProbe", True ) @@ -163,6 +206,7 @@ def instrument_commands(file_results: Dict[str, Any]) -> Dict[str, float]: "Right Pipette Total Aspirates": right_aspirate, "Right Pipette Total Dispenses": right_dispense, "Gripper Pick Ups": gripper_pickups, + f"Gripper Pick Ups of {labware_name}": gripper_labware_of_interest, "Total Liquid Probes": liquid_probes, "Average Liquid Probe Time (sec)": avg_liquid_probe_time_sec, } @@ -178,11 +222,12 @@ def plate_reader_commands( initialize_count: int = 0 read = "no" final_result = {} - # Count Number of Reads + read_num = 0 + # Count Number of Reads per measure mode read_count, avg_read_time = count_command_in_run_data( commandData, "absorbanceReader/read", True ) - # Count Number of Initializations + # Count Number of Initializations per measure mode initialize_count, avg_initialize_time = count_command_in_run_data( commandData, "absorbanceReader/initialize", True ) @@ -198,28 +243,37 @@ def plate_reader_commands( read = "yes" elif read == "yes" and commandType == "comment": result = command["params"].get("message", "") - wavelength = result.split("result: {")[1].split(":")[0] - wavelength_str = wavelength + ": " - rest_of_string = result.split(wavelength_str)[1][:-1] - result_dict = eval(rest_of_string) - result_ndarray = plate_reader.convert_read_dictionary_to_array(result_dict) - for item in hellma_plate_standards: - wavelength_of_interest = item["wavelength"] - if str(wavelength) == str(wavelength_of_interest): - error_cells = plate_reader.check_byonoy_data_accuracy( - result_ndarray, item, False - ) - if len(error_cells[0]) > 0: - percent = (96 - len(error_cells)) / 96 * 100 - for cell in error_cells: - print("FAIL: Cell " + str(cell) + " out of accuracy spec.") - else: - percent = 100 - print( - f"PASS: {wavelength_of_interest} meet accuracy specification" + formatted_result = result.split("result: ")[1] + result_dict = eval(formatted_result) + result_dict_keys = list(result_dict.keys()) + if len(result_dict_keys) > 1: + read_type = "multi" + else: + read_type = "single" + for wavelength in result_dict_keys: + one_wavelength_dict = result_dict.get(wavelength) + result_ndarray = plate_reader.convert_read_dictionary_to_array( + one_wavelength_dict + ) + for item in hellma_plate_standards: + wavelength_of_interest = item["wavelength"] + if str(wavelength) == str(wavelength_of_interest): + error_cells = plate_reader.check_byonoy_data_accuracy( + result_ndarray, item, False ) - final_result[wavelength] = percent - input("###########################") + if len(error_cells[0]) > 0: + percent = (96 - len(error_cells)) / 96 * 100 + for cell in error_cells: + print( + "FAIL: Cell " + str(cell) + " out of accuracy spec." + ) + else: + percent = 100 + print( + f"PASS: {wavelength_of_interest} meet accuracy specification" + ) + final_result[read_type, wavelength, read_num] = percent + read_num += 1 read = "no" plate_dict = { "Plate Reader # of Reads": read_count, @@ -372,7 +426,10 @@ def thermocycler_commands(file_results: Dict[str, Any]) -> Dict[str, float]: or commandType == "thermocycler/closeLid" ): lid_engagements += 1 - if commandType == "thermocycler/setTargetBlockTemperature": + if ( + commandType == "thermocycler/setTargetBlockTemperature" + and command["status"] != "queued" + ): block_temp = command["params"]["celsius"] block_temp_changes += 1 block_on_time = datetime.strptime( @@ -502,7 +559,10 @@ def get_error_info(file_results: Dict[str, Any]) -> Dict[str, Any]: error_code = error_details.get("errorCode", "") error_instrument = error_details.get("detail", "") # Determine error level - error_level = error_levels.get(error_code, "4") + if end_run_errors > 0: + error_level = error_levels.get(error_code, "4") + else: + error_level = "" # Create dictionary with all error descriptions error_dict = { "Total Recoverable Error(s)": total_recoverable_errors, From 1c052862bb086f02108f73bb0ada2933bd876536 Mon Sep 17 00:00:00 2001 From: Ed Cormany Date: Wed, 9 Oct 2024 15:58:04 -0400 Subject: [PATCH 028/101] chore(release): 8.1.0 stable release notes (#16436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Overview Release notes, meant to be final for 8.1.0 stable. ## Test Plan and Hands on Testing 👀 ## Changelog Added a line in `app-shell` notes for fixing the bug that shows the "welcome" screen on robots that are already set up. ## Review requests 🚢 ❓ ## Risk assessment none. --- app-shell/build/release-notes.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 5a425ce0cc6..27ff23e0909 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -12,7 +12,11 @@ By installing and using Opentrons software, you agree to the Opentrons End-User Welcome to the v8.1.0 release of the Opentrons App! -There are no changes to the Opentrons App in v8.1.0, but it is required for updating the robot software to support the latest production version of Flex robots. +There are no new features in the Opentrons App in v8.1.0, but it is required for updating the robot software to support the latest production version of Flex robots. + +### Bug Fixes + +- Prevented Flex from showing the first-run screen when powering on a robot that's already set up. --- From 0a3f1a801ffc483c63e1c2680b69050598652b0b Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:01:27 -0400 Subject: [PATCH 029/101] chore(shared-data): create liquid class schema v1 and fixture (#16267) # Overview This PR introduces the first version of an Opentrons liquid class schema. Single liquid class definitions will adhere to this schema and be keyed by pipette and tip type. Closes AUTH-832 ## Test Plan and Hands on Testing - Look through schema and ensure it adheres to our finalized liquid class testing matrix. Any feedback on schema structure/style is appreciated. - Try out some test data in a JSON validator. I created a base fixture that passes schema validation. We can modify that for testing. ## Changelog - create liquid class schema v1 - add fixture ## Review requests See test plan. Should we leave keys for `patternProperties` pipette and tip type less restrictive strings? Perhaps any `safeString` rather than following more stringent regex? ## Risk assessment low --- .../fixtures/fixture_glycerol50.json | 241 +++++++++ shared-data/liquid-class/schemas/1.json | 480 ++++++++++++++++++ 2 files changed, 721 insertions(+) create mode 100644 shared-data/liquid-class/fixtures/fixture_glycerol50.json create mode 100644 shared-data/liquid-class/schemas/1.json diff --git a/shared-data/liquid-class/fixtures/fixture_glycerol50.json b/shared-data/liquid-class/fixtures/fixture_glycerol50.json new file mode 100644 index 00000000000..fd655c66a61 --- /dev/null +++ b/shared-data/liquid-class/fixtures/fixture_glycerol50.json @@ -0,0 +1,241 @@ +{ + "liquidName": "Glycerol 50%", + "schemaVersion": 1, + "namespace": "opentrons", + "byPipette": [ + { + "pipetteModel": "p20_single_gen2", + "byTipType": [ + { + "tipType": "p20_tip", + "aspirate": { + "submerge": { + "positionReference": "liquid-meniscus", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + }, + "speed": 100, + "airGapByVolume": { + "default": 2, + "5": 3, + "10": 4 + }, + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmToEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + }, + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "flowRateByVolume": { + "default": 50, + "10": 40, + "20": 30 + }, + "preWet": true, + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 2 + } + } + }, + "singleDispense": { + "submerge": { + "positionReference": "liquid-meniscus", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + }, + "speed": 100, + "airGapByVolume": { + "default": 2, + "5": 3, + "10": 4 + }, + "blowout": { + "enable": true, + "params": { + "location": "trash", + "flowRate": 100 + } + }, + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmToEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + }, + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "flowRateByVolume": { + "default": 50, + "10": 40, + "20": 30 + }, + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "pushOutByVolume": { + "default": 5, + "10": 7, + "20": 10 + }, + "delay": 1 + }, + "multiDispense": { + "submerge": { + "positionReference": "liquid-meniscus", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + }, + "speed": 100, + "airGapByVolume": { + "default": 2, + "5": 3, + "10": 4 + }, + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmToEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + }, + "blowout": { + "enable": false + } + }, + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "flowRateByVolume": { + "default": 50, + "10": 40, + "20": 30 + }, + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "conditioningByVolume": { + "default": 10, + "5": 5 + }, + "disposalByVolume": { + "default": 2, + "5": 3 + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + } + } + ] + } + ] +} diff --git a/shared-data/liquid-class/schemas/1.json b/shared-data/liquid-class/schemas/1.json new file mode 100644 index 00000000000..6af0ff9babe --- /dev/null +++ b/shared-data/liquid-class/schemas/1.json @@ -0,0 +1,480 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "opentronsLiquidClassSchemaV1", + "title": "Liquid Class Schema", + "description": "Schema for defining a single liquid class's properties for liquid handling functions.", + "type": "object", + "definitions": { + "positiveNumber": { + "type": "number", + "minimum": 0 + }, + "safeString": { + "description": "A string safe to use for namespace. Lowercase-only.", + "type": "string", + "pattern": "^[a-z0-9._]+$" + }, + "delay": { + "type": "object", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether delay is enabled." + }, + "params": { + "type": "object", + "properties": { + "duration": { + "#ref": "#/definitions/positiveNumber", + "description": "Duration of delay, in seconds." + } + }, + "required": ["duration"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "positionReference": { + "type": "string", + "description": "Reference point for positioning.", + "enum": ["well-bottom", "well-top", "well-center", "liquid-meniscus"] + }, + "coordinate": { + "type": "object", + "description": "3-dimensional coordinate.", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "touchTip": { + "type": "object", + "description": "Shared properties for the touch-tip function.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether touch-tip is enabled." + }, + "params": { + "type": "object", + "properties": { + "zOffset": { + "type": "number", + "description": "Offset from the top of the well for touch-tip, in millimeters." + }, + "mmToEdge": { + "type": "number", + "description": "Offset away from the the well edge, in millimeters." + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Touch-tip speed, in millimeters per second." + } + }, + "required": ["zOffset", "mmToEdge", "speed"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "airGapByVolume": { + "type": "object", + "description": "Settings for air gap keyed by target aspiration volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "flowRateByVolume": { + "type": "object", + "description": "Settings for flow rate keyed by target aspiration/dispense volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "pushOutByVolume": { + "type": "object", + "description": "Settings for pushout keyed by target aspiration volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "disposalByVolume": { + "type": "object", + "description": "Settings for disposal volume keyed by target dispense volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "conditioningByVolume": { + "type": "object", + "description": "Settings for conditioning volume keyed by target dispense volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "mix": { + "type": "object", + "description": "Mixing properties.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether mix is enabled." + }, + "params": { + "type": "object", + "properties": { + "repetitions": { + "type": "integer", + "description": "Number of mixing repetitions.", + "minimum": 0 + }, + "volume": { + "$ref": "#/definitions/positiveNumber", + "description": "Volume used for mixing, in microliters." + } + }, + "required": ["repetitions", "volume"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "blowout": { + "type": "object", + "description": "Blowout properties.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether blow-out is enabled." + }, + "params": { + "type": "object", + "properties": { + "location": { + "type": "string", + "enum": ["source", "destination", "trash"], + "description": "Location well or trash entity for blow out." + }, + "flowRate": { + "$ref": "#/definitions/positiveNumber", + "description": "Flow rate for blow out, in microliters per second." + } + }, + "required": ["location", "flowRate"] + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "submerge": { + "type": "object", + "description": "Shared properties for the submerge function before aspiration or dispense.", + "properties": { + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of submerging, in millimeters per second." + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": ["positionReference", "offset", "speed", "delay"], + "additionalProperties": false + }, + "retractAspirate": { + "type": "object", + "description": "Shared properties for the retract function after aspiration or dispense.", + "properties": { + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of retraction, in millimeters per second." + }, + "airGapByVolume": { + "$ref": "#/definitions/airGapByVolume" + }, + "touchTip": { + "$ref": "#/definitions/touchTip" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "positionReference", + "offset", + "speed", + "airGapByVolume", + "delay" + ], + "additionalProperties": false + }, + "retractDispense": { + "type": "object", + "description": "Shared properties for the retract function after aspiration or dispense.", + "properties": { + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of retraction, in millimeters per second." + }, + "airGapByVolume": { + "$ref": "#/definitions/airGapByVolume" + }, + "blowout": { + "$ref": "#/definitions/blowout" + }, + "touchTip": { + "$ref": "#/definitions/touchTip" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "positionReference", + "offset", + "speed", + "airGapByVolume", + "blowout", + "touchTip", + "delay" + ], + "additionalProperties": false + }, + "aspirateParams": { + "type": "object", + "description": "Parameters specific to the aspirate function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractAspirate" + }, + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "preWet": { + "type": "boolean", + "description": "Whether to perform a pre-wet action." + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "submerge", + "retract", + "positionReference", + "offset", + "flowRateByVolume", + "preWet", + "mix", + "delay" + ], + "additionalProperties": false + }, + "singleDispenseParams": { + "type": "object", + "description": "Parameters specific to the single-dispense function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractDispense" + }, + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "pushOutByVolume": { + "$ref": "#/definitions/pushOutByVolume" + }, + "delay": { + "$ref": "#/definitions/positiveNumber", + "description": "Delay after dispense, in seconds." + } + }, + "required": [ + "submerge", + "retract", + "positionReference", + "offset", + "flowRateByVolume", + "mix", + "pushOutByVolume", + "delay" + ], + "additionalProperties": false + }, + "multiDispenseParams": { + "type": "object", + "description": "Parameters specific to the multi-dispense function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractDispense" + }, + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "conditioningByVolume": { + "$ref": "#/definitions/conditioningByVolume" + }, + "disposalByVolume": { + "$ref": "#/definitions/disposalByVolume" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "submerge", + "retract", + "positionReference", + "offset", + "flowRateByVolume", + "mix", + "conditioningByVolume", + "disposalByVolume", + "delay" + ], + "additionalProperties": false + } + }, + "properties": { + "liquidName": { + "type": "string", + "description": "The name of the liquid (e.g., water, ethanol, serum)." + }, + "schemaVersion": { + "description": "Which schema version a liquid class is using", + "type": "number", + "enum": [1] + }, + "namespace": { + "$ref": "#/definitions/safeString" + }, + "byPipette": { + "type": "array", + "description": "Liquid class settings by each pipette compatible with this liquid class.", + "items": { + "type": "object", + "description": "The settings for a specific kind of pipette when interacting with this liquid class", + "properties": { + "pipetteModel": { + "type": "string", + "description": "The pipette model this applies to" + }, + "byTipType": { + "type": "array", + "description": "Settings for each kind of tip this pipette can use", + "items": { + "type": "object", + "properties": { + "tipType": { + "type": "string", + "description": "The tip type whose properties will be used when handling this specific liquid class with this pipette" + }, + "aspirate": { + "$ref": "#/definitions/aspirateParams" + }, + "singleDispense": { + "$ref": "#/definitions/singleDispenseParams" + }, + "multiDispense": { + "$ref": "#/definitions/multiDispenseParams" + } + }, + "required": ["tipType", "aspirate", "singleDispense"], + "additionalProperties": false + } + } + }, + "required": ["pipetteModel", "byTipType"], + "additionalProperties": false + } + } + }, + "required": ["liquidName", "schemaVersion", "namespace", "byPipette"], + "additionalProperties": false +} From 601229742624494f82639dfa2903b035fec8490d Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Thu, 10 Oct 2024 10:33:42 -0400 Subject: [PATCH 030/101] refactor(api): Remove redundant tip length state (#16450) `PipetteState` and `TipState` were both storing the length of the pipette's currently-attached tip. I think that duplication was accidental. The `TipState` one was only used in one place, so this deletes that in favor of the `PipetteState` one. --- .../protocol_engine/commands/pick_up_tip.py | 8 +- .../protocol_engine/execution/gantry_mover.py | 4 +- .../opentrons/protocol_engine/state/tips.py | 10 +-- .../execution/test_gantry_mover.py | 15 +++- .../protocol_engine/state/test_tip_state.py | 87 ------------------- 5 files changed, 18 insertions(+), 106 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py index 86d64d3034e..465ede2f86f 100644 --- a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py +++ b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py @@ -9,7 +9,7 @@ from ..errors import ErrorOccurrence, TipNotAttachedError from ..resources import ModelUtils from ..state import update_types -from ..types import DeckPoint, TipGeometry +from ..types import DeckPoint from .pipetting_common import ( PipetteIdMixin, WellLocationMixin, @@ -132,11 +132,7 @@ async def execute( ) state_update.update_tip_state( pipette_id=pipette_id, - tip_geometry=TipGeometry( - volume=tip_geometry.volume, - length=tip_geometry.length, - diameter=tip_geometry.diameter, - ), + tip_geometry=tip_geometry, ) except TipNotAttachedError as e: return DefinedErrorData( diff --git a/api/src/opentrons/protocol_engine/execution/gantry_mover.py b/api/src/opentrons/protocol_engine/execution/gantry_mover.py index 98a3d19b8d5..8b33e43f437 100644 --- a/api/src/opentrons/protocol_engine/execution/gantry_mover.py +++ b/api/src/opentrons/protocol_engine/execution/gantry_mover.py @@ -273,7 +273,9 @@ def get_max_travel_z(self, pipette_id: str) -> float: ) else: instrument_height = VIRTUAL_MAX_OT3_HEIGHT - tip_length = self._state_view.tips.get_tip_length(pipette_id) + + tip = self._state_view.pipettes.get_attached_tip(pipette_id=pipette_id) + tip_length = tip.length if tip is not None else 0 return instrument_height - tip_length async def move_to( diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index 7b50b291f4d..1e14843114c 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -44,8 +44,8 @@ class TipState: tips_by_labware_id: Dict[str, TipRackStateByWellName] column_by_labware_id: Dict[str, List[List[str]]] + channels_by_pipette_id: Dict[str, int] - length_by_pipette_id: Dict[str, float] active_channels_by_pipette_id: Dict[str, int] nozzle_map_by_pipette_id: Dict[str, NozzleMap] @@ -61,7 +61,6 @@ def __init__(self) -> None: tips_by_labware_id={}, column_by_labware_id={}, channels_by_pipette_id={}, - length_by_pipette_id={}, active_channels_by_pipette_id={}, nozzle_map_by_pipette_id={}, ) @@ -121,18 +120,15 @@ def _handle_succeeded_command(self, command: Command) -> None: labware_id = command.params.labwareId well_name = command.params.wellName pipette_id = command.params.pipetteId - length = command.result.tipLength self._set_used_tips( pipette_id=pipette_id, well_name=well_name, labware_id=labware_id ) - self._state.length_by_pipette_id[pipette_id] = length elif isinstance( command.result, (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), ): pipette_id = command.params.pipetteId - self._state.length_by_pipette_id.pop(pipette_id, None) def _handle_failed_command( self, @@ -506,10 +502,6 @@ def has_clean_tip(self, labware_id: str, well_name: str) -> bool: return well_state == TipRackWellState.CLEAN - def get_tip_length(self, pipette_id: str) -> float: - """Return the given pipette's tip length.""" - return self._state.length_by_pipette_id.get(pipette_id, 0) - def _drop_wells_before_starting_tip( wells: TipRackStateByWellName, starting_tip_name: str 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 6f6d3274532..b9dbd798ff2 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py +++ b/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py @@ -17,7 +17,12 @@ from opentrons.protocol_engine.state.state import StateView from opentrons.protocol_engine.state.motion import PipetteLocationData -from opentrons.protocol_engine.types import MotorAxis, DeckPoint, CurrentWell +from opentrons.protocol_engine.types import ( + MotorAxis, + DeckPoint, + CurrentWell, + TipGeometry, +) from opentrons.protocol_engine.errors import MustHomeError, InvalidAxisForRobotType from opentrons.protocol_engine.execution.gantry_mover import ( @@ -499,7 +504,9 @@ def test_virtual_get_max_travel_z_ot2( decoy.when( mock_state_view.pipettes.get_instrument_max_height_ot2("pipette-id") ).then_return(42) - decoy.when(mock_state_view.tips.get_tip_length("pipette-id")).then_return(20) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=20, diameter=0, volume=0) + ) result = virtual_subject.get_max_travel_z("pipette-id") @@ -513,7 +520,9 @@ def test_virtual_get_max_travel_z_ot3( ) -> None: """It should get the max travel z height with the state store.""" decoy.when(mock_state_view.config.robot_type).then_return("OT-3 Standard") - decoy.when(mock_state_view.tips.get_tip_length("pipette-id")).then_return(48) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=48, diameter=0, volume=0) + ) result = virtual_subject.get_max_travel_z("pipette-id") diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index da570c940cd..bdc5cc639f4 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -908,93 +908,6 @@ def test_has_tip_tip_rack( assert result is True -def test_drop_tip( - subject: TipStore, - load_labware_command: commands.LoadLabware, - pick_up_tip_command: commands.PickUpTip, - drop_tip_command: commands.DropTip, - drop_tip_in_place_command: commands.DropTipInPlace, - unsafe_drop_tip_in_place_command: commands.unsafe.UnsafeDropTipInPlace, - supported_tip_fixture: pipette_definition.SupportedTipsDefinition, -) -> None: - """It should be clear tip length when a tip is dropped.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( - pipette_id="pipette-id", - serial_number="pipette-serial", - config=LoadedStaticPipetteData( - channels=8, - max_volume=15, - min_volume=3, - model="gen a", - display_name="display name", - flow_rates=FlowRates( - default_aspirate={}, - default_dispense={}, - default_blow_out={}, - ), - tip_configuration_lookup_table={15: supported_tip_fixture}, - nominal_tip_overlap={}, - nozzle_offset_z=1.23, - home_position=4.56, - nozzle_map=get_default_nozzle_map(PipetteNameType.P300_SINGLE_GEN2), - back_left_corner_offset=Point(x=1, y=2, z=3), - front_right_corner_offset=Point(x=4, y=5, z=6), - pipette_lld_settings={}, - ), - ) - subject.handle_action( - actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command - ) - ) - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 1.23 - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=drop_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 0 - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 1.23 - - subject.handle_action( - actions.SucceedCommandAction( - private_result=None, command=drop_tip_in_place_command - ) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 0 - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 1.23 - - subject.handle_action( - actions.SucceedCommandAction( - private_result=None, command=unsafe_drop_tip_in_place_command - ) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 0 - - @pytest.mark.parametrize( argnames=["nozzle_map", "expected_channels"], argvalues=[ From eeb8972ab63e5c53df8f03b4193cc0748f1043b9 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Thu, 10 Oct 2024 11:07:25 -0400 Subject: [PATCH 031/101] feat(api, shared-data): Add support for labware lids and publish tc lid seal labware (#16345) Covers PLAT-356, PLAT-264, PLAT-540 Publicizes "opentrons_tough_pcr_auto_sealing_lid" labware. Implements multilabware stacks, new labware allowedRole "lid" for labware, alongside "lidOffsets" subcategory of the gripper offsets. --- .../execution/labware_movement.py | 2 + .../resources/labware_validation.py | 5 + .../protocol_engine/state/geometry.py | 83 ++++++- .../protocol_engine/state/labware.py | 66 +++++- .../test_labware_movement_handler.py | 10 +- .../state/test_geometry_view.py | 31 +++ .../state/test_labware_view.py | 93 +++++++- .../LabwarePositionCheck/useLaunchLPC.tsx | 15 +- .../protocols/tc_auto_seal_lid/__init__.py | 1 + .../tc_auto_seal_lid/tc_lid_evap_test.py | 208 ++++++++++++++++++ .../tc_auto_seal_lid/tc_lid_movement.py | 73 ++++++ .../js/__tests__/labwareDefQuirks.test.ts | 1 + .../1.json | 93 ++++++++ shared-data/labware/schemas/2.json | 2 +- shared-data/labware/schemas/3.json | 2 +- .../labware/labware_definition.py | 1 + .../opentrons_shared_data/labware/types.py | 1 + 17 files changed, 664 insertions(+), 23 deletions(-) create mode 100644 hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py create mode 100644 hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py create mode 100644 hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py create mode 100644 shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json diff --git a/api/src/opentrons/protocol_engine/execution/labware_movement.py b/api/src/opentrons/protocol_engine/execution/labware_movement.py index 30feb6517ff..8ede6f6085b 100644 --- a/api/src/opentrons/protocol_engine/execution/labware_movement.py +++ b/api/src/opentrons/protocol_engine/execution/labware_movement.py @@ -126,6 +126,7 @@ async def move_labware_with_gripper( current_location=current_location, ) + current_labware = self._state_store.labware.get_definition(labware_id) async with self._thermocycler_plate_lifter.lift_plate_for_labware_movement( labware_location=current_location ): @@ -134,6 +135,7 @@ async def move_labware_with_gripper( from_location=current_location, to_location=new_location, additional_offset_vector=user_offset_data, + current_labware=current_labware, ) ) from_labware_center = self._state_store.geometry.get_labware_grip_point( diff --git a/api/src/opentrons/protocol_engine/resources/labware_validation.py b/api/src/opentrons/protocol_engine/resources/labware_validation.py index 3b4ed14166c..090723ffb7e 100644 --- a/api/src/opentrons/protocol_engine/resources/labware_validation.py +++ b/api/src/opentrons/protocol_engine/resources/labware_validation.py @@ -27,6 +27,11 @@ def validate_definition_is_adapter(definition: LabwareDefinition) -> bool: return LabwareRole.adapter in definition.allowedRoles +def validate_definition_is_lid(definition: LabwareDefinition) -> bool: + """Validate that one of the definition's allowed roles is `lid`.""" + return LabwareRole.lid in definition.allowedRoles + + def validate_labware_can_be_stacked( top_labware_definition: LabwareDefinition, below_labware_load_name: str ) -> bool: diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 502f0d4d8eb..e37a460d226 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -12,6 +12,7 @@ from opentrons_shared_data.deck.types import CutoutFixture from opentrons_shared_data.pipette import PIPETTE_X_SPAN from opentrons_shared_data.pipette.types import ChannelCount +from opentrons.protocols.models import LabwareDefinition from .. import errors from ..errors import ( @@ -20,7 +21,7 @@ LabwareMovementNotAllowedError, InvalidWellDefinitionError, ) -from ..resources import fixture_validation +from ..resources import fixture_validation, labware_validation from ..types import ( OFF_DECK_LOCATION, LoadedLabware, @@ -46,6 +47,7 @@ AddressableOffsetVector, StagingSlotLocation, LabwareOffsetLocation, + ModuleModel, ) from .config import Config from .labware import LabwareView @@ -997,17 +999,22 @@ def get_final_labware_movement_offset_vectors( from_location: OnDeckLabwareLocation, to_location: OnDeckLabwareLocation, additional_offset_vector: LabwareMovementOffsetData, + current_labware: LabwareDefinition, ) -> LabwareMovementOffsetData: """Calculate the final labware offset vector to use in labware movement.""" pick_up_offset = ( self.get_total_nominal_gripper_offset_for_move_type( - location=from_location, move_type=_GripperMoveType.PICK_UP_LABWARE + location=from_location, + move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=current_labware, ) + additional_offset_vector.pickUpOffset ) drop_offset = ( self.get_total_nominal_gripper_offset_for_move_type( - location=to_location, move_type=_GripperMoveType.DROP_LABWARE + location=to_location, + move_type=_GripperMoveType.DROP_LABWARE, + current_labware=current_labware, ) + additional_offset_vector.dropOffset ) @@ -1038,7 +1045,10 @@ def ensure_valid_gripper_location( return location def get_total_nominal_gripper_offset_for_move_type( - self, location: OnDeckLabwareLocation, move_type: _GripperMoveType + self, + location: OnDeckLabwareLocation, + move_type: _GripperMoveType, + current_labware: LabwareDefinition, ) -> LabwareOffsetVector: """Get the total of the offsets to be used to pick up labware in its current location.""" if move_type == _GripperMoveType.PICK_UP_LABWARE: @@ -1054,14 +1064,39 @@ def get_total_nominal_gripper_offset_for_move_type( location ) ancestor = self._labware.get_parent_location(location.labwareId) + extra_offset = LabwareOffsetVector(x=0, y=0, z=0) + if ( + isinstance(ancestor, ModuleLocation) + and self._modules._state.requested_model_by_id[ancestor.moduleId] + == ModuleModel.THERMOCYCLER_MODULE_V2 + and labware_validation.validate_definition_is_lid(current_labware) + ): + if "lidOffsets" in current_labware.gripperOffsets.keys(): + extra_offset = LabwareOffsetVector( + x=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.x, + y=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.y, + z=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.z, + ) + else: + raise errors.LabwareOffsetDoesNotExistError( + f"Labware Definition {current_labware.parameters.loadName} does not contain required field 'lidOffsets' of 'gripperOffsets'." + ) + assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation) + ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.pickUpOffset + self._nominal_gripper_offsets_for_location( location=ancestor ).pickUpOffset + + extra_offset ) else: if isinstance( @@ -1076,14 +1111,39 @@ def get_total_nominal_gripper_offset_for_move_type( location ) ancestor = self._labware.get_parent_location(location.labwareId) + extra_offset = LabwareOffsetVector(x=0, y=0, z=0) + if ( + isinstance(ancestor, ModuleLocation) + and self._modules._state.requested_model_by_id[ancestor.moduleId] + == ModuleModel.THERMOCYCLER_MODULE_V2 + and labware_validation.validate_definition_is_lid(current_labware) + ): + if "lidOffsets" in current_labware.gripperOffsets.keys(): + extra_offset = LabwareOffsetVector( + x=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.x, + y=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.y, + z=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.z, + ) + else: + raise errors.LabwareOffsetDoesNotExistError( + f"Labware Definition {current_labware.parameters.loadName} does not contain required field 'lidOffsets' of 'gripperOffsets'." + ) + assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation) + ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.dropOffset + self._nominal_gripper_offsets_for_location( location=ancestor ).dropOffset + + extra_offset ) def check_gripper_labware_tip_collision( @@ -1147,11 +1207,20 @@ def _labware_gripper_offsets( """ parent_location = self._labware.get_parent_location(labware_id) assert isinstance( - parent_location, (DeckSlotLocation, ModuleLocation) + parent_location, + ( + DeckSlotLocation, + ModuleLocation, + AddressableAreaLocation, + ), ), "No gripper offsets for off-deck labware" if isinstance(parent_location, DeckSlotLocation): slot_name = parent_location.slotName + elif isinstance(parent_location, AddressableAreaLocation): + slot_name = self._addressable_areas.get_addressable_area_base_slot( + parent_location.addressableAreaName + ) else: module_loc = self._modules.get_location(parent_location.moduleId) slot_name = module_loc.slotName diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 0db6b310e1e..78f2124bdb4 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -405,6 +405,16 @@ def get_parent_location(self, labware_id: str) -> NonStackedLocation: return self.get_parent_location(parent.labwareId) return parent + def get_labware_stack( + self, labware_stack: List[LoadedLabware] + ) -> List[LoadedLabware]: + """Get the a stack of labware starting from a given labware or existing stack.""" + parent = self.get_location(labware_stack[-1].id) + if isinstance(parent, OnLabwareLocation): + labware_stack.append(self.get(parent.labwareId)) + return self.get_labware_stack(labware_stack) + return labware_stack + def get_all(self) -> List[LoadedLabware]: """Get a list of all labware entries in state.""" return list(self._state.labware_by_id.values()) @@ -429,6 +439,27 @@ def get_should_center_column_on_target_well(self, labware_id: str) -> bool: and len(self.get_definition(labware_id).wells) < 96 ) + def get_labware_stacking_maximum(self, labware: LabwareDefinition) -> int: + """Returns the maximum number of labware allowed in a stack for a given labware definition. + + If not defined within a labware, defaults to one. + """ + stacking_quirks = { + "stackingMaxFive": 5, + "stackingMaxFour": 4, + "stackingMaxThree": 3, + "stackingMaxTwo": 2, + "stackingMaxOne": 1, + "stackingMaxZero": 0, + } + for quirk in stacking_quirks.keys(): + if ( + labware.parameters.quirks is not None + and quirk in labware.parameters.quirks + ): + return stacking_quirks[quirk] + return 1 + def get_should_center_pipette_on_target_well(self, labware_id: str) -> bool: """True if a pipette moving to a well of this labware should center its body on the target. @@ -596,9 +627,14 @@ def get_labware_overlap_offsets( ) -> OverlapOffset: """Get the labware's overlap with requested labware's load name.""" definition = self.get_definition(labware_id) - stacking_overlap = definition.stackingOffsetWithLabware.get( - below_labware_name, OverlapOffset(x=0, y=0, z=0) - ) + if below_labware_name in definition.stackingOffsetWithLabware.keys(): + stacking_overlap = definition.stackingOffsetWithLabware.get( + below_labware_name, OverlapOffset(x=0, y=0, z=0) + ) + else: + stacking_overlap = definition.stackingOffsetWithLabware.get( + "default", OverlapOffset(x=0, y=0, z=0) + ) return OverlapOffset( x=stacking_overlap.x, y=stacking_overlap.y, z=stacking_overlap.z ) @@ -767,7 +803,7 @@ def raise_if_labware_in_location( f"Labware {labware.loadName} is already present at {location}." ) - def raise_if_labware_cannot_be_stacked( + def raise_if_labware_cannot_be_stacked( # noqa: C901 self, top_labware_definition: LabwareDefinition, bottom_labware_id: str ) -> None: """Raise if the specified labware definition cannot be placed on top of the bottom labware.""" @@ -786,17 +822,37 @@ def raise_if_labware_cannot_be_stacked( ) elif isinstance(below_labware.location, ModuleLocation): below_definition = self.get_definition(labware_id=below_labware.id) - if not labware_validation.validate_definition_is_adapter(below_definition): + if not labware_validation.validate_definition_is_adapter( + below_definition + ) and not labware_validation.validate_definition_is_lid( + top_labware_definition + ): raise errors.LabwareCannotBeStackedError( f"Labware {top_labware_definition.parameters.loadName} cannot be loaded" f" onto a labware on top of a module" ) elif isinstance(below_labware.location, OnLabwareLocation): + labware_stack = self.get_labware_stack([below_labware]) + stack_without_adapters = [] + for lw in labware_stack: + if not labware_validation.validate_definition_is_adapter( + self.get_definition(lw.id) + ): + stack_without_adapters.append(lw) + if len(stack_without_adapters) >= self.get_labware_stacking_maximum( + top_labware_definition + ): + raise errors.LabwareCannotBeStackedError( + f"Labware {top_labware_definition.parameters.loadName} cannot be loaded to stack of more than {self.get_labware_stacking_maximum(top_labware_definition)} labware." + ) + further_below_definition = self.get_definition( labware_id=below_labware.location.labwareId ) if labware_validation.validate_definition_is_adapter( further_below_definition + ) and not labware_validation.validate_definition_is_lid( + top_labware_definition ): raise errors.LabwareCannotBeStackedError( f"Labware {top_labware_definition.parameters.loadName} cannot be loaded" diff --git a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py index c434995ee52..6032bad81b8 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py @@ -202,11 +202,16 @@ async def test_raise_error_if_gripper_pickup_failed( ) ).then_return(mock_tc_context_manager) + current_labware = state_store.labware.get_definition( + labware_id="my-teleporting-labware" + ) + decoy.when( state_store.geometry.get_final_labware_movement_offset_vectors( from_location=starting_location, to_location=to_location, additional_offset_vector=user_offset_data, + current_labware=current_labware, ) ).then_return(final_offset_data) @@ -316,12 +321,15 @@ async def test_move_labware_with_gripper( await set_up_decoy_hardware_gripper(decoy, ot3_hardware_api, state_store) user_offset_data, final_offset_data = hardware_gripper_offset_data - + current_labware = state_store.labware.get_definition( + labware_id="my-teleporting-labware" + ) decoy.when( state_store.geometry.get_final_labware_movement_offset_vectors( from_location=from_location, to_location=to_location, additional_offset_vector=user_offset_data, + current_labware=current_labware, ) ).then_return(final_offset_data) diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 427dececa7b..3f824da7193 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -2233,6 +2233,7 @@ def test_get_final_labware_movement_offset_vectors( mock_module_view: ModuleView, mock_labware_view: LabwareView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """It should provide the final labware movement offset data based on locations.""" decoy.when(mock_labware_view.get_deck_default_gripper_offsets()).then_return( @@ -2248,6 +2249,10 @@ def test_get_final_labware_movement_offset_vectors( ) ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + final_offsets = subject.get_final_labware_movement_offset_vectors( from_location=DeckSlotLocation(slotName=DeckSlotName("D2")), to_location=ModuleLocation(moduleId="module-id"), @@ -2255,6 +2260,7 @@ def test_get_final_labware_movement_offset_vectors( pickUpOffset=LabwareOffsetVector(x=100, y=200, z=300), dropOffset=LabwareOffsetVector(x=400, y=500, z=600), ), + current_labware=mock_labware_view.get_definition("labware-id"), ) assert final_offsets == LabwareMovementOffsetData( pickUpOffset=LabwareOffsetVector(x=101, y=202, z=303), @@ -2285,6 +2291,7 @@ def test_get_total_nominal_gripper_offset( mock_labware_view: LabwareView, mock_module_view: ModuleView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """It should calculate the correct gripper offsets given the location and move type..""" decoy.when(mock_labware_view.get_deck_default_gripper_offsets()).then_return( @@ -2301,10 +2308,15 @@ def test_get_total_nominal_gripper_offset( ) ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + # Case 1: labware on deck result1 = subject.get_total_nominal_gripper_offset_for_move_type( location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-d"), ) assert result1 == LabwareOffsetVector(x=1, y=2, z=3) @@ -2312,6 +2324,7 @@ def test_get_total_nominal_gripper_offset( result2 = subject.get_total_nominal_gripper_offset_for_move_type( location=ModuleLocation(moduleId="module-id"), move_type=_GripperMoveType.DROP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result2 == LabwareOffsetVector(x=33, y=22, z=11) @@ -2321,6 +2334,7 @@ def test_get_stacked_labware_total_nominal_offset_slot_specific( mock_labware_view: LabwareView, mock_module_view: ModuleView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """Get nominal offset for stacked labware.""" # Case: labware on adapter on module, adapter has slot-specific offsets @@ -2346,15 +2360,23 @@ def test_get_stacked_labware_total_nominal_offset_slot_specific( decoy.when(mock_labware_view.get_parent_location("adapter-id")).then_return( ModuleLocation(moduleId="module-id") ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_module_view._state.requested_model_by_id).then_return( + {"module-id": ModuleModel.HEATER_SHAKER_MODULE_V1} + ) result1 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result1 == LabwareOffsetVector(x=111, y=222, z=333) result2 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.DROP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result2 == LabwareOffsetVector(x=333, y=222, z=111) @@ -2364,6 +2386,7 @@ def test_get_stacked_labware_total_nominal_offset_default( mock_labware_view: LabwareView, mock_module_view: ModuleView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """Get nominal offset for stacked labware.""" # Case: labware on adapter on module, adapter has only default offsets @@ -2394,15 +2417,23 @@ def test_get_stacked_labware_total_nominal_offset_default( decoy.when(mock_labware_view.get_parent_location("adapter-id")).then_return( ModuleLocation(moduleId="module-id") ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_module_view._state.requested_model_by_id).then_return( + {"module-id": ModuleModel.HEATER_SHAKER_MODULE_V1} + ) result1 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result1 == LabwareOffsetVector(x=111, y=222, z=333) result2 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.DROP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result2 == LabwareOffsetVector(x=333, y=222, z=111) diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index ab2f49cfb29..d461ddda4e6 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -1386,13 +1386,18 @@ def test_raise_if_labware_cannot_be_stacked_on_labware_on_adapter() -> None: ), }, definitions_by_uri={ + "def-uri-1": LabwareDefinition.construct( # type: ignore[call-arg] + allowedRoles=[LabwareRole.labware] + ), "def-uri-2": LabwareDefinition.construct( # type: ignore[call-arg] allowedRoles=[LabwareRole.adapter] - ) + ), }, ) - with pytest.raises(errors.LabwareCannotBeStackedError, match="on top of adapter"): + with pytest.raises( + errors.LabwareCannotBeStackedError, match="cannot be loaded to stack" + ): subject.raise_if_labware_cannot_be_stacked( top_labware_definition=LabwareDefinition.construct( # type: ignore[call-arg] parameters=Parameters.construct( # type: ignore[call-arg] @@ -1406,6 +1411,90 @@ def test_raise_if_labware_cannot_be_stacked_on_labware_on_adapter() -> None: ) +@pytest.mark.parametrize( + argnames=[ + "allowed_roles", + "stacking_quirks", + "exception", + ], + argvalues=[ + [ + [LabwareRole.labware], + [], + pytest.raises(errors.LabwareCannotBeStackedError), + ], + [ + [LabwareRole.lid], + ["stackingMaxFive"], + does_not_raise(), + ], + ], +) +def test_labware_stacking_height_passes_or_raises( + allowed_roles: List[LabwareRole], + stacking_quirks: List[str], + exception: ContextManager[None], +) -> None: + """It should raise if the labware is stacked too high, and pass if the labware definition allowed this.""" + subject = get_labware_view( + labware_by_id={ + "labware-id4": LoadedLabware( + id="labware-id4", + loadName="test", + definitionUri="def-uri-1", + location=OnLabwareLocation(labwareId="labware-id3"), + ), + "labware-id3": LoadedLabware( + id="labware-id3", + loadName="test", + definitionUri="def-uri-1", + location=OnLabwareLocation(labwareId="labware-id2"), + ), + "labware-id2": LoadedLabware( + id="labware-id2", + loadName="test", + definitionUri="def-uri-1", + location=OnLabwareLocation(labwareId="labware-id1"), + ), + "labware-id1": LoadedLabware( + id="labware-id1", + loadName="test", + definitionUri="def-uri-1", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), + ), + }, + definitions_by_uri={ + "def-uri-1": LabwareDefinition.construct( # type: ignore[call-arg] + allowedRoles=allowed_roles, + parameters=Parameters.construct( + format="irregular", + quirks=stacking_quirks, + isTiprack=False, + loadName="name", + isMagneticModuleCompatible=False, + ), + ) + }, + ) + + with exception: + subject.raise_if_labware_cannot_be_stacked( + top_labware_definition=LabwareDefinition.construct( # type: ignore[call-arg] + parameters=Parameters.construct( + format="irregular", + quirks=stacking_quirks, + isTiprack=False, + loadName="name", + isMagneticModuleCompatible=False, + ), + stackingOffsetWithLabware={ + "test": SharedDataOverlapOffset(x=0, y=0, z=0) + }, + ), + bottom_labware_id="labware-id4", + ) + + def test_get_deck_gripper_offsets(ot3_standard_deck_def: DeckDefinitionV5) -> None: """It should get the deck's gripper offsets.""" subject = get_labware_view(deck_definition=ot3_standard_deck_def) diff --git a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx index c3262af8225..0ad5ea06a50 100644 --- a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx +++ b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx @@ -15,6 +15,8 @@ import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/ import type { RobotType } from '@opentrons/shared-data' +const filteredLabware = ['opentrons_tough_pcr_auto_sealing_lid'] + export function useLaunchLPC( runId: string, robotType: RobotType, @@ -61,12 +63,13 @@ export function useLaunchLPC( Promise.all( getLabwareDefinitionsFromCommands( mostRecentAnalysis?.commands ?? [] - ).map(def => - createLabwareDefinition({ - maintenanceRunId: maintenanceRun?.data?.id, - labwareDef: def, - }) - ) + ).map(def => { + if (!filteredLabware.includes(def.parameters.loadName)) + createLabwareDefinition({ + maintenanceRunId: maintenanceRun?.data?.id, + labwareDef: def, + }) + }) ).then(() => { setMaintenanceRunId(maintenanceRun.data.id) }) diff --git a/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py new file mode 100644 index 00000000000..848fb967ae2 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py @@ -0,0 +1 @@ +"""Tough Auto Sealing Lid Tests.""" diff --git a/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py new file mode 100644 index 00000000000..5a02624f08f --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py @@ -0,0 +1,208 @@ +"""Protocol to Test Evaporation % of the Tough Auto Seal Lid.""" +from typing import List +from opentrons.hardware_control.modules.types import ThermocyclerStep +from opentrons.protocol_api import ( + ParameterContext, + ProtocolContext, + Labware, + InstrumentContext, + Well, +) +from opentrons.protocol_api.module_contexts import ThermocyclerContext +from opentrons.protocol_api.disposal_locations import WasteChute + +metadata = {"protocolName": "Tough Auto Seal Lid Evaporation Test"} +requirements = {"robotType": "Flex", "apiLevel": "2.20"} + + +def _long_hold_test(thermocycler: ThermocyclerContext, tc_lid_temp: float) -> None: + """Holds TC lid in Thermocycler for 5 min at high temp before evap test.""" + thermocycler.set_block_temperature(4, hold_time_minutes=5) + thermocycler.set_lid_temperature(tc_lid_temp) + thermocycler.set_block_temperature(98, hold_time_minutes=5) + thermocycler.set_block_temperature(4, hold_time_minutes=5) + thermocycler.open_lid() + + +def _fill_with_liquid_and_measure( + protocol: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, + plate_in_cycler: Labware, +) -> None: + """Fill plate with 10 ul per well.""" + locations: List[Well] = [ + plate_in_cycler["A1"], + plate_in_cycler["A2"], + plate_in_cycler["A3"], + plate_in_cycler["A4"], + plate_in_cycler["A5"], + plate_in_cycler["A6"], + plate_in_cycler["A7"], + plate_in_cycler["A8"], + plate_in_cycler["A9"], + plate_in_cycler["A10"], + plate_in_cycler["A11"], + plate_in_cycler["A12"], + ] + volumes = [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10] + protocol.pause("Weight Armadillo Plate, place on thermocycler") + # pipette 10uL into Armadillo wells + source_well: Well = reservoir["A1"] + pipette.distribute( + volume=volumes, + source=source_well, + dest=locations, + return_tips=True, + blow_out=False, + ) + protocol.pause("Weight Armadillo Plate, place on thermocycler, put on lid") + + +def _pcr_cycle(thermocycler: ThermocyclerContext) -> None: + """30x cycles of: 70° for 30s 72° for 30s 95° for 10s.""" + profile_TAG2: List[ThermocyclerStep] = [ + {"temperature": 70, "hold_time_seconds": 30}, + {"temperature": 72, "hold_time_seconds": 30}, + {"temperature": 95, "hold_time_seconds": 10}, + ] + thermocycler.execute_profile( + steps=profile_TAG2, repetitions=30, block_max_volume=50 + ) + + +def _move_lid( + thermocycler: ThermocyclerContext, + protocol: ProtocolContext, + top_lid: Labware, + bottom_lid: Labware, + wasteChute: WasteChute, +) -> None: + """Move lid from tc to deck.""" + # Move lid from thermocycler to deck to stack to waste chute + thermocycler.open_lid() + # Move Lid to Deck + protocol.move_labware(top_lid, "B2", use_gripper=True) + # Move Lid to Stack + protocol.move_labware(top_lid, bottom_lid, use_gripper=True) + # Move Lid to Waste Chute + protocol.move_labware(top_lid, wasteChute, use_gripper=True) + + +def add_parameters(parameters: ParameterContext) -> None: + """Add parameters.""" + parameters.add_str( + variable_name="mount_pos", + display_name="Mount Position", + description="What mount to use", + choices=[ + {"display_name": "left_mount", "value": "left"}, + {"display_name": "right_mount", "value": "right"}, + ], + default="left", + ) + parameters.add_str( + variable_name="pipette_type", + display_name="Pipette Type", + description="What pipette to use", + choices=[ + {"display_name": "8ch 50 uL", "value": "flex_8channel_50"}, + {"display_name": "8ch 1000 uL", "value": "flex_8channel_1000"}, + ], + default="flex_8channel_50", + ) + parameters.add_float( + variable_name="tc_lid_temp", + display_name="TC Lid Temp", + description="Max temp of TC Lid", + default=105, + choices=[ + {"display_name": "105", "value": 105}, + {"display_name": "107", "value": 107}, + {"display_name": "110", "value": 110}, + ], + ) + parameters.add_str( + variable_name="test_type", + display_name="Test Type", + description="Type of test to run", + default="evap_test", + choices=[ + {"display_name": "Evaporation Test", "value": "evap_test"}, + {"display_name": "Long Hold Test", "value": "long_hold_test"}, + ], + ) + + +def run(protocol: ProtocolContext) -> None: + """Run protocol.""" + # LOAD PARAMETERS + pipette_type = protocol.params.pipette_type # type: ignore[attr-defined] + mount_position = protocol.params.mount_pos # type: ignore[attr-defined] + tc_lid_temp = protocol.params.tc_lid_temp # type: ignore[attr-defined] + test_type = protocol.params.test_type # type: ignore[attr-defined] + # SETUP + # Thermocycler + thermocycler: ThermocyclerContext = protocol.load_module( + "thermocyclerModuleV2" + ) # type: ignore[assignment] + + plate_in_cycler = thermocycler.load_labware( + "armadillo_96_wellplate_200ul_pcr_full_skirt" + ) + thermocycler.open_lid() + # Labware + tiprack_50_1 = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "C3") + reservoir = protocol.load_labware("nest_12_reservoir_15ml", "A2") + lids: List[Labware] = [ + protocol.load_labware("opentrons_tough_pcr_auto_sealing_lid", "D2") + ] + for i in range(4): + lids.append(lids[-1].load_labware("opentrons_tough_pcr_auto_sealing_lid")) + lids.reverse() + top_lid = lids[0] + bottom_lid = lids[1] + # Pipette + pipette = protocol.load_instrument( + pipette_type, mount_position, tip_racks=[tiprack_50_1] + ) + # Waste Chute + wasteChute = protocol.load_waste_chute() + + # DEFINE TESTS # + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(105) + + # hold at 95° for 3 minutes + profile_TAG: List[ThermocyclerStep] = [{"temperature": 95, "hold_time_minutes": 3}] + # hold at 72° for 5min + profile_TAG3: List[ThermocyclerStep] = [{"temperature": 72, "hold_time_minutes": 5}] + + if test_type == "long_hold_test": + protocol.move_labware(top_lid, plate_in_cycler, use_gripper=True) + _long_hold_test(thermocycler, tc_lid_temp) + protocol.move_labware(top_lid, "B2", use_gripper=True) + _long_hold_test(thermocycler, tc_lid_temp) + _fill_with_liquid_and_measure(protocol, pipette, reservoir, plate_in_cycler) + thermocycler.close_lid() + _pcr_cycle(thermocycler) + + # Go through PCR cycle + if test_type == "evap_test": + _fill_with_liquid_and_measure(protocol, pipette, reservoir, plate_in_cycler) + protocol.move_labware(top_lid, plate_in_cycler, use_gripper=True) + thermocycler.close_lid() + thermocycler.execute_profile( + steps=profile_TAG, repetitions=1, block_max_volume=50 + ) + _pcr_cycle(thermocycler) + thermocycler.execute_profile( + steps=profile_TAG3, repetitions=1, block_max_volume=50 + ) + # # # Cool to 4° + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(tc_lid_temp) + # Open lid + thermocycler.open_lid() + _move_lid(thermocycler, protocol, top_lid, bottom_lid, wasteChute) + protocol.pause("Weigh armadillo plate.") diff --git a/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py new file mode 100644 index 00000000000..475c84e6516 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py @@ -0,0 +1,73 @@ +"""Protocol to Test the Stacking and Movement of Tough Auto Seal Lid.""" +from typing import List, Union +from opentrons.protocol_api import ( + ParameterContext, + ProtocolContext, + Labware, +) +from opentrons.protocol_api.module_contexts import ThermocyclerContext + + +metadata = {"protocolName": "Tough Auto Seal Lid Stacking Test"} +requirements = {"robotType": "Flex", "apiLevel": "2.20"} + + +def add_parameters(parameters: ParameterContext) -> None: + """Add parameters.""" + parameters.add_int( + variable_name="num_of_stack_ups", + display_name="Number of Stack Ups", + choices=[ + {"display_name": "1", "value": 1}, + {"display_name": "10", "value": 10}, + {"display_name": "20", "value": 20}, + {"display_name": "30", "value": 30}, + {"display_name": "40", "value": 40}, + ], + default=20, + ) + + +def run(protocol: ProtocolContext) -> None: + """Runs protocol that moves lids and stacks them.""" + # Load Parameters + iterations = protocol.params.num_of_stack_ups # type: ignore[attr-defined] + # Thermocycler + thermocycler: ThermocyclerContext = protocol.load_module( + "thermocyclerModuleV2" + ) # type: ignore[assignment] + plate_in_cycler = thermocycler.load_labware( + "armadillo_96_wellplate_200ul_pcr_full_skirt" + ) + thermocycler.open_lid() + + lids: List[Labware] = [ + protocol.load_labware("opentrons_tough_pcr_auto_sealing_lid", "D2") + ] + for i in range(4): + lids.append(lids[-1].load_labware("opentrons_tough_pcr_auto_sealing_lid")) + lids.reverse() + stack_locations = ["C2", "D2"] + slot = 0 + for iteration in range(iterations - 1): + protocol.comment(f"Stack up {iteration}") + locations_for_lid = ["D1", "C1", "C3", "B2", "B3"] + loc = 0 + for lid in lids: + # move lid to plate in thermocycler + protocol.move_labware(lid, plate_in_cycler, use_gripper=True) + # move lid to deck slot + location_to_move: Union[int, str] = locations_for_lid[loc] + protocol.move_labware(lid, location_to_move, use_gripper=True) + # move lid to lid stack + if loc == 0: + protocol.move_labware(lid, stack_locations[slot], use_gripper=True) + prev_moved_lid: Labware = lid + else: + protocol.move_labware(lid, prev_moved_lid, use_gripper=True) + prev_moved_lid = lid + loc += 1 + slot = (slot + 1) % 2 # Switch between 0 and 1 to rotate stack locations + + # reverse lid list to restart stacking exercise + lids.reverse() diff --git a/shared-data/js/__tests__/labwareDefQuirks.test.ts b/shared-data/js/__tests__/labwareDefQuirks.test.ts index 6ebc39f9f17..6251c894647 100644 --- a/shared-data/js/__tests__/labwareDefQuirks.test.ts +++ b/shared-data/js/__tests__/labwareDefQuirks.test.ts @@ -13,6 +13,7 @@ const EXPECTED_VALID_QUIRKS = [ 'fixedTrash', 'gripperIncompatible', 'tiprackAdapterFor96Channel', + 'stackingMaxFive', ] describe('check quirks for all labware defs', () => { diff --git a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json new file mode 100644 index 00000000000..ca39c122b47 --- /dev/null +++ b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json @@ -0,0 +1,93 @@ +{ + "allowedRoles": ["labware", "lid"], + "ordering": [], + "brand": { + "brand": "Opentrons", + "brandId": [] + }, + "metadata": { + "displayName": "Opentrons Tough PCR Auto-Sealing Lid", + "displayCategory": "other", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "dimensions": { + "xDimension": 127.7, + "yDimension": 85.48, + "zDimension": 12.8 + }, + "wells": {}, + "groups": [], + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": -0.71 + }, + "parameters": { + "format": "irregular", + "quirks": ["stackingMaxFive"], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "opentrons_tough_pcr_auto_sealing_lid" + }, + "namespace": "opentrons", + "version": 1, + "schemaVersion": 2, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "stackingOffsetWithLabware": { + "default": { + "x": 0, + "y": 0, + "z": 8.193 + }, + "opentrons_tough_pcr_auto_sealing_lid": { + "x": 0, + "y": 0, + "z": 6.492 + }, + "armadillo_96_wellplate_200ul_pcr_full_skirt": { + "x": 0, + "y": 0, + "z": 8.193 + }, + "opentrons_96_wellplate_200ul_pcr_full_skirt": { + "x": 0, + "y": 0, + "z": 8.193 + } + }, + "gripForce": 15, + "gripHeightFromLabwareBottom": 7.91, + "gripperOffsets": { + "default": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 1.5 + }, + "dropOffset": { + "x": 0, + "y": 0.52, + "z": -6 + } + }, + "lidOffsets": { + "pickUpOffset": { + "x": 0.5, + "y": 0, + "z": -5 + }, + "dropOffset": { + "x": 0.5, + "y": 0, + "z": -1 + } + } + } +} diff --git a/shared-data/labware/schemas/2.json b/shared-data/labware/schemas/2.json index 01931d2c2a1..203009be9f5 100644 --- a/shared-data/labware/schemas/2.json +++ b/shared-data/labware/schemas/2.json @@ -323,7 +323,7 @@ "description": "Allowed behaviors and usage of a labware in a protocol.", "items": { "type": "string", - "enum": ["labware", "adapter", "fixture", "maintenance"] + "enum": ["labware", "adapter", "fixture", "maintenance", "lid"] } }, "stackingOffsetWithLabware": { diff --git a/shared-data/labware/schemas/3.json b/shared-data/labware/schemas/3.json index ecd285c554a..e38c070919a 100644 --- a/shared-data/labware/schemas/3.json +++ b/shared-data/labware/schemas/3.json @@ -519,7 +519,7 @@ "description": "Allowed behaviors and usage of a labware in a protocol.", "items": { "type": "string", - "enum": ["labware", "adapter", "fixture", "maintenance"] + "enum": ["labware", "adapter", "fixture", "maintenance", "lid"] } }, "stackingOffsetWithLabware": { diff --git a/shared-data/python/opentrons_shared_data/labware/labware_definition.py b/shared-data/python/opentrons_shared_data/labware/labware_definition.py index a818afc106a..be4c1a17d01 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -119,6 +119,7 @@ class LabwareRole(str, Enum): fixture = "fixture" adapter = "adapter" maintenance = "maintenance" + lid = "lid" class Metadata(BaseModel): diff --git a/shared-data/python/opentrons_shared_data/labware/types.py b/shared-data/python/opentrons_shared_data/labware/types.py index d3f6599848c..5a6aebf4ff7 100644 --- a/shared-data/python/opentrons_shared_data/labware/types.py +++ b/shared-data/python/opentrons_shared_data/labware/types.py @@ -37,6 +37,7 @@ Literal["fixture"], Literal["adapter"], Literal["maintenance"], + Literal["lid"], ] From e33a2471818a8204881a772dbb3645245cf76238 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Thu, 10 Oct 2024 12:45:02 -0400 Subject: [PATCH 032/101] feat(app,app-shell,app-shell-odd): detect user system language, add language setting to app config (#16393) adds a config app language value to desktop/ODD and initializes i18n language to the stored app language config value. detects the user's system language in desktop app-shell and transmits to renderer via IPC/redux. adds a system language config value. closes PLAT-504, PLAT-497 --- .../src/config/__fixtures__/index.ts | 10 +++++++ .../src/config/__tests__/migrate.test.ts | 14 +++++++-- app-shell-odd/src/config/migrate.ts | 20 +++++++++++-- app-shell/src/__fixtures__/config.ts | 10 +++++++ .../src/config/__tests__/migrate.test.ts | 14 +++++++-- app-shell/src/config/migrate.ts | 20 +++++++++++-- app-shell/src/main.ts | 3 +- app-shell/src/ui.ts | 24 +++++++++++++-- app/src/App/DesktopApp.tsx | 7 ++--- app/src/App/OnDeviceDisplayApp.tsx | 6 ++-- app/src/App/__tests__/DesktopApp.test.tsx | 9 ++++++ .../App/__tests__/OnDeviceDisplayApp.test.tsx | 12 ++++---- app/src/LocalizationProvider.tsx | 29 ++++++++++++++----- .../Desktop/AppSettings/AdvancedSettings.tsx | 18 ++++++++++-- .../RobotSettingsList.tsx | 8 +++-- app/src/redux/config/schema-types.ts | 12 +++++++- app/src/redux/config/selectors.ts | 13 +++++++++ app/src/redux/shell/index.ts | 1 + app/src/redux/shell/reducer.ts | 12 ++++++++ app/src/redux/shell/selectors.ts | 5 ++++ app/src/redux/shell/types.ts | 10 +++++++ 21 files changed, 217 insertions(+), 40 deletions(-) create mode 100644 app/src/redux/shell/selectors.ts diff --git a/app-shell-odd/src/config/__fixtures__/index.ts b/app-shell-odd/src/config/__fixtures__/index.ts index d670234ebbc..7f9a48dc02c 100644 --- a/app-shell-odd/src/config/__fixtures__/index.ts +++ b/app-shell-odd/src/config/__fixtures__/index.ts @@ -12,6 +12,7 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' const PKG_VERSION: string = _PKG_VERSION_ @@ -171,3 +172,12 @@ export const MOCK_CONFIG_V24: ConfigV24 = { userId: 'MOCK_UUIDv4', }, } + +export const MOCK_CONFIG_V25: ConfigV25 = { + ...MOCK_CONFIG_V24, + version: 25, + language: { + appLanguage: null, + systemLanguage: null, + }, +} diff --git a/app-shell-odd/src/config/__tests__/migrate.test.ts b/app-shell-odd/src/config/__tests__/migrate.test.ts index dcc8eb03708..7ea91ee8d53 100644 --- a/app-shell-odd/src/config/__tests__/migrate.test.ts +++ b/app-shell-odd/src/config/__tests__/migrate.test.ts @@ -16,13 +16,14 @@ import { MOCK_CONFIG_V22, MOCK_CONFIG_V23, MOCK_CONFIG_V24, + MOCK_CONFIG_V25, } from '../__fixtures__' import { migrate } from '../migrate' vi.mock('uuid/v4') -const NEWEST_VERSION = 24 -const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V24 +const NEWEST_VERSION = 25 +const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V25 describe('config migration', () => { beforeEach(() => { @@ -121,10 +122,17 @@ describe('config migration', () => { expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) - it('should keep version 24', () => { + it('should migrate version 24 to latest', () => { const v24Config = MOCK_CONFIG_V24 const result = migrate(v24Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(NEWEST_MOCK_CONFIG) + }) + it('should keep version 25', () => { + const v25Config = MOCK_CONFIG_V25 + const result = migrate(v25Config) + expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) diff --git a/app-shell-odd/src/config/migrate.ts b/app-shell-odd/src/config/migrate.ts index d1e9103d430..b6977fbf489 100644 --- a/app-shell-odd/src/config/migrate.ts +++ b/app-shell-odd/src/config/migrate.ts @@ -17,13 +17,14 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' // format // base config v12 defaults // any default values for later config versions are specified in the migration // functions for those version below -const CONFIG_VERSION_LATEST = 23 // update this after each config version bump +const CONFIG_VERSION_LATEST = 25 // update this after each config version bump const PKG_VERSION: string = _PKG_VERSION_ export const DEFAULTS_V12: ConfigV12 = { @@ -226,6 +227,18 @@ const toVersion24 = (prevConfig: ConfigV23): ConfigV24 => { } } +const toVersion25 = (prevConfig: ConfigV24): ConfigV25 => { + const nextConfig = { + ...prevConfig, + version: 25 as const, + language: { + appLanguage: null, + systemLanguage: null, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV12) => ConfigV13, (prevConfig: ConfigV13) => ConfigV14, @@ -238,7 +251,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV20) => ConfigV21, (prevConfig: ConfigV21) => ConfigV22, (prevConfig: ConfigV22) => ConfigV23, - (prevConfig: ConfigV23) => ConfigV24 + (prevConfig: ConfigV23) => ConfigV24, + (prevConfig: ConfigV24) => ConfigV25 ] = [ toVersion13, toVersion14, @@ -252,6 +266,7 @@ const MIGRATIONS: [ toVersion22, toVersion23, toVersion24, + toVersion25, ] export const DEFAULTS: Config = migrate(DEFAULTS_V12) @@ -271,6 +286,7 @@ export function migrate( | ConfigV22 | ConfigV23 | ConfigV24 + | ConfigV25 ): Config { let result = prevConfig // loop through the migrations, skipping any migrations that are unnecessary diff --git a/app-shell/src/__fixtures__/config.ts b/app-shell/src/__fixtures__/config.ts index 23ef4f56f90..dd344c78532 100644 --- a/app-shell/src/__fixtures__/config.ts +++ b/app-shell/src/__fixtures__/config.ts @@ -24,6 +24,7 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V0: ConfigV0 = { @@ -302,3 +303,12 @@ export const MOCK_CONFIG_V24: ConfigV24 = { userId: 'MOCK_UUIDv4', }, } + +export const MOCK_CONFIG_V25: ConfigV25 = { + ...MOCK_CONFIG_V24, + version: 25, + language: { + appLanguage: null, + systemLanguage: null, + }, +} diff --git a/app-shell/src/config/__tests__/migrate.test.ts b/app-shell/src/config/__tests__/migrate.test.ts index dee16e0dae4..ddc151fc2cf 100644 --- a/app-shell/src/config/__tests__/migrate.test.ts +++ b/app-shell/src/config/__tests__/migrate.test.ts @@ -28,13 +28,14 @@ import { MOCK_CONFIG_V22, MOCK_CONFIG_V23, MOCK_CONFIG_V24, + MOCK_CONFIG_V25, } from '../../__fixtures__' import { migrate } from '../migrate' vi.mock('uuid/v4') -const NEWEST_VERSION = 24 -const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V24 +const NEWEST_VERSION = 25 +const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V25 describe('config migration', () => { beforeEach(() => { @@ -226,10 +227,17 @@ describe('config migration', () => { expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) - it('should keep version 24', () => { + it('should migrate version 24 to latest', () => { const v24Config = MOCK_CONFIG_V24 const result = migrate(v24Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(NEWEST_MOCK_CONFIG) + }) + it('should keep version 25', () => { + const v25Config = MOCK_CONFIG_V25 + const result = migrate(v25Config) + expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) diff --git a/app-shell/src/config/migrate.ts b/app-shell/src/config/migrate.ts index fa9ed4a91dd..69c53ab2e72 100644 --- a/app-shell/src/config/migrate.ts +++ b/app-shell/src/config/migrate.ts @@ -28,13 +28,14 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' // format // base config v0 defaults // any default values for later config versions are specified in the migration // functions for those version below -const CONFIG_VERSION_LATEST = 23 +const CONFIG_VERSION_LATEST = 25 export const DEFAULTS_V0: ConfigV0 = { version: 0, @@ -430,6 +431,18 @@ const toVersion24 = (prevConfig: ConfigV23): ConfigV24 => { } } +const toVersion25 = (prevConfig: ConfigV24): ConfigV25 => { + const nextConfig = { + ...prevConfig, + version: 25 as const, + language: { + appLanguage: null, + systemLanguage: null, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV0) => ConfigV1, (prevConfig: ConfigV1) => ConfigV2, @@ -454,7 +467,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV20) => ConfigV21, (prevConfig: ConfigV21) => ConfigV22, (prevConfig: ConfigV22) => ConfigV23, - (prevConfig: ConfigV23) => ConfigV24 + (prevConfig: ConfigV23) => ConfigV24, + (prevConfig: ConfigV24) => ConfigV25 ] = [ toVersion1, toVersion2, @@ -480,6 +494,7 @@ const MIGRATIONS: [ toVersion22, toVersion23, toVersion24, + toVersion25, ] export const DEFAULTS: Config = migrate(DEFAULTS_V0) @@ -511,6 +526,7 @@ export function migrate( | ConfigV22 | ConfigV23 | ConfigV24 + | ConfigV25 ): Config { const prevVersion = prevConfig.version let result = prevConfig diff --git a/app-shell/src/main.ts b/app-shell/src/main.ts index 10d3f02cda0..ef422a455cc 100644 --- a/app-shell/src/main.ts +++ b/app-shell/src/main.ts @@ -5,7 +5,7 @@ import dns from 'dns' import contextMenu from 'electron-context-menu' import * as electronDevtoolsInstaller from 'electron-devtools-installer' -import { createUi, registerReloadUi } from './ui' +import { createUi, registerReloadUi, registerSystemLanguage } from './ui' import { initializeMenu } from './menu' import { createLogger } from './log' import { registerProtocolAnalysis } from './protocol-analysis' @@ -110,6 +110,7 @@ function startUp(): void { registerUsb(dispatch), registerNotify(dispatch, mainWindow), registerReloadUi(mainWindow), + registerSystemLanguage(dispatch), ] ipcMain.on('dispatch', (_, action) => { diff --git a/app-shell/src/ui.ts b/app-shell/src/ui.ts index 6f7a2a360fd..25dcf133ad4 100644 --- a/app-shell/src/ui.ts +++ b/app-shell/src/ui.ts @@ -3,10 +3,10 @@ import { app, shell, BrowserWindow } from 'electron' import path from 'path' import { getConfig } from './config' -import { RELOAD_UI } from './constants' +import { RELOAD_UI, UI_INITIALIZED } from './constants' import { createLogger } from './log' -import type { Action } from './types' +import type { Action, Dispatch } from './types' const config = getConfig('ui') const log = createLogger('ui') @@ -78,3 +78,23 @@ export function registerReloadUi( } } } + +export function registerSystemLanguage( + dispatch: Dispatch +): (action: Action) => unknown { + return function handleAction(action: Action) { + switch (action.type) { + case UI_INITIALIZED: { + const systemLanguage = app.getPreferredSystemLanguages() + + dispatch({ + type: 'shell:SYSTEM_LANGUAGE', + payload: { systemLanguage }, + meta: { shell: true }, + }) + + break + } + } + } +} diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx index 3c9dc3ae253..27d7fd4f238 100644 --- a/app/src/App/DesktopApp.tsx +++ b/app/src/App/DesktopApp.tsx @@ -1,7 +1,6 @@ import { useState, Fragment } from 'react' import { Navigate, Route, Routes, useMatch } from 'react-router-dom' import { ErrorBoundary } from 'react-error-boundary' -import { I18nextProvider } from 'react-i18next' import { Box, @@ -12,7 +11,7 @@ import { import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' -import { i18n } from '/app/i18n' +import { LocalizationProvider } from '/app/LocalizationProvider' import { Alerts } from '/app/organisms/Desktop/Alerts' import { Breadcrumbs } from '/app/organisms/Desktop/Breadcrumbs' import { ToasterOven } from '/app/organisms/ToasterOven' @@ -106,7 +105,7 @@ export const DesktopApp = (): JSX.Element => { return ( - + @@ -155,7 +154,7 @@ export const DesktopApp = (): JSX.Element => { - + ) } diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 46fb91b21f4..42335754432 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -16,7 +16,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' import { SleepScreen } from '/app/atoms/SleepScreen' -import { OnDeviceLocalizationProvider } from '../LocalizationProvider' +import { LocalizationProvider } from '../LocalizationProvider' import { ToasterOven } from '/app/organisms/ToasterOven' import { MaintenanceRunTakeover } from '/app/organisms/TakeoverModal' import { FirmwareUpdateTakeover } from '/app/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover' @@ -180,7 +180,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { return ( - + {isIdle ? ( @@ -203,7 +203,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { - + ) diff --git a/app/src/App/__tests__/DesktopApp.test.tsx b/app/src/App/__tests__/DesktopApp.test.tsx index cd769ec0a1b..6510dd49e31 100644 --- a/app/src/App/__tests__/DesktopApp.test.tsx +++ b/app/src/App/__tests__/DesktopApp.test.tsx @@ -5,6 +5,7 @@ import { vi, describe, beforeEach, afterEach, expect, it } from 'vitest' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' +import { LocalizationProvider } from '/app/LocalizationProvider' import { Breadcrumbs } from '/app/organisms/Desktop/Breadcrumbs' import { CalibrationDashboard } from '/app/pages/Desktop/Devices/CalibrationDashboard' import { DeviceDetails } from '/app/pages/Desktop/Devices/DeviceDetails' @@ -20,6 +21,9 @@ import { ProtocolTimeline } from '/app/pages/Desktop/Protocols/ProtocolDetails/P import { useSoftwareUpdatePoll } from '../hooks' import { DesktopApp } from '../DesktopApp' +import type { LocalizationProviderProps } from '/app/LocalizationProvider' + +vi.mock('/app/LocalizationProvider') vi.mock('/app/organisms/Desktop/Breadcrumbs') vi.mock('/app/pages/Desktop/AppSettings/GeneralSettings') vi.mock('/app/pages/Desktop/Devices/CalibrationDashboard') @@ -67,6 +71,11 @@ describe('DesktopApp', () => { vi.mocked(Breadcrumbs).mockReturnValue(
    Mock Breadcrumbs
    ) vi.mocked(AlertsModal).mockReturnValue(<>) vi.mocked(useIsFlex).mockReturnValue(true) + vi.mocked( + LocalizationProvider + ).mockImplementation((props: LocalizationProviderProps) => ( + <>{props.children} + )) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx index fae54eb2fed..662b2523436 100644 --- a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx +++ b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' -import { OnDeviceLocalizationProvider } from '../../LocalizationProvider' +import { LocalizationProvider } from '../../LocalizationProvider' import { ConnectViaEthernet } from '/app/pages/ODD/ConnectViaEthernet' import { ConnectViaUSB } from '/app/pages/ODD/ConnectViaUSB' import { ConnectViaWifi } from '/app/pages/ODD/ConnectViaWifi' @@ -32,7 +32,7 @@ import { ODDTopLevelRedirects } from '../ODDTopLevelRedirects' import type { UseQueryResult } from 'react-query' import type { RobotSettingsResponse } from '@opentrons/api-client' -import type { OnDeviceLocalizationProviderProps } from '../../LocalizationProvider' +import type { LocalizationProviderProps } from '../../LocalizationProvider' import type { OnDeviceDisplaySettings } from '/app/redux/config/schema-types' vi.mock('@opentrons/react-api-client', async () => { @@ -100,8 +100,8 @@ describe('OnDeviceDisplayApp', () => { } as any) // TODO(bh, 2024-03-27): implement testing of branded and anonymous i18n, but for now pass through vi.mocked( - OnDeviceLocalizationProvider - ).mockImplementation((props: OnDeviceLocalizationProviderProps) => ( + LocalizationProvider + ).mockImplementation((props: LocalizationProviderProps) => ( <>{props.children} )) }) @@ -163,14 +163,14 @@ describe('OnDeviceDisplayApp', () => { }) it('renders the localization provider and not the loading screen when app-shell is ready', () => { render('/') - expect(vi.mocked(OnDeviceLocalizationProvider)).toHaveBeenCalled() + expect(vi.mocked(LocalizationProvider)).toHaveBeenCalled() expect(screen.queryByLabelText('loading indicator')).toBeNull() }) it('renders the loading screen when app-shell is not ready', () => { vi.mocked(getIsShellReady).mockReturnValue(false) render('/') screen.getByLabelText('loading indicator') - expect(vi.mocked(OnDeviceLocalizationProvider)).not.toHaveBeenCalled() + expect(vi.mocked(LocalizationProvider)).not.toHaveBeenCalled() }) it('renders EmergencyStop component from /emergency-stop', () => { render('/emergency-stop') diff --git a/app/src/LocalizationProvider.tsx b/app/src/LocalizationProvider.tsx index 1cd676d2095..b8fc0149673 100644 --- a/app/src/LocalizationProvider.tsx +++ b/app/src/LocalizationProvider.tsx @@ -1,24 +1,38 @@ import type * as React from 'react' import { I18nextProvider } from 'react-i18next' +import { useSelector } from 'react-redux' import reduce from 'lodash/reduce' -import { resources } from './assets/localization' -import { useIsOEMMode } from './resources/robot-settings/hooks' -import { i18n, i18nCb, i18nConfig } from './i18n' +import { resources } from '/app/assets/localization' +import { i18n, i18nCb, i18nConfig } from '/app/i18n' +import { getAppLanguage, getStoredSystemLanguage } from '/app/redux/config' +import { getSystemLanguage } from '/app/redux/shell' +import { useIsOEMMode } from '/app/resources/robot-settings/hooks' -export interface OnDeviceLocalizationProviderProps { +export interface LocalizationProviderProps { children?: React.ReactNode } export const BRANDED_RESOURCE = 'branded' export const ANONYMOUS_RESOURCE = 'anonymous' -// TODO(bh, 2024-03-26): anonymization limited to ODD for now, may change in future OEM phases -export function OnDeviceLocalizationProvider( - props: OnDeviceLocalizationProviderProps +export function LocalizationProvider( + props: LocalizationProviderProps ): JSX.Element | null { const isOEMMode = useIsOEMMode() + const language = useSelector(getAppLanguage) + const systemLanguage = useSelector(getSystemLanguage) + const storedSystemLanguage = useSelector(getStoredSystemLanguage) + + // TODO(bh, 2024-10-09): desktop app, check for current system language vs stored config system language value, launch modal + console.log( + 'redux systemLanguage', + systemLanguage, + 'storedSystemLanguage', + storedSystemLanguage + ) + // iterate through language resources, nested files, substitute anonymous file for branded file for OEM mode const anonResources = reduce( resources, @@ -44,6 +58,7 @@ export function OnDeviceLocalizationProvider( const anonI18n = i18n.createInstance( { ...i18nConfig, + lng: language ?? 'en', resources: anonResources, }, i18nCb diff --git a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx index 59439a6a088..4eda66f68e1 100644 --- a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx @@ -1,3 +1,6 @@ +import { useContext } from 'react' +import { I18nContext } from 'react-i18next' +import { useDispatch } from 'react-redux' import { css } from 'styled-components' import { @@ -10,7 +13,6 @@ import { } from '@opentrons/components' import { Divider } from '/app/atoms/structure' -import { i18n } from '/app/i18n' import { ClearUnavailableRobots, EnableDevTools, @@ -23,7 +25,9 @@ import { UpdatedChannel, AdditionalCustomLabwareSourceFolder, } from '/app/organisms/Desktop/AdvancedSettings' -import { useFeatureFlag } from '/app/redux/config' +import { updateConfigValue, useFeatureFlag } from '/app/redux/config' + +import type { Dispatch } from '/app/redux/types' export function AdvancedSettings(): JSX.Element { return ( @@ -57,6 +61,9 @@ export function AdvancedSettings(): JSX.Element { function LocalizationSetting(): JSX.Element | null { const enableLocalization = useFeatureFlag('enableLocalization') + const dispatch = useDispatch() + + const { i18n } = useContext(I18nContext) return enableLocalization ? ( <> @@ -70,7 +77,12 @@ function LocalizationSetting(): JSX.Element | null { `} value={i18n.language} onChange={(event: React.ChangeEvent) => { - void i18n.changeLanguage(event.currentTarget.value) + dispatch( + updateConfigValue( + 'language.appLanguage', + event.currentTarget.value + ) + ) }} options={[ { name: 'EN', value: 'en' }, diff --git a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx index 5983e957419..61e1d9d3314 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx @@ -31,6 +31,7 @@ import { toggleDevInternalFlag, toggleDevtools, toggleHistoricOffsets, + updateConfigValue, useFeatureFlag, } from '/app/redux/config' import { InlineNotification } from '/app/atoms/InlineNotification' @@ -273,6 +274,7 @@ function FeatureFlags(): JSX.Element { function LanguageToggle(): JSX.Element | null { const enableLocalization = useFeatureFlag('enableLocalization') + const dispatch = useDispatch() const { i18n } = useContext(I18nContext) @@ -280,9 +282,9 @@ function LanguageToggle(): JSX.Element | null { { - void (i18n.language === 'en' - ? i18n.changeLanguage('zh') - : i18n.changeLanguage('en')) + i18n.language === 'en' + ? dispatch(updateConfigValue('language.appLanguage', 'zh')) + : dispatch(updateConfigValue('language.appLanguage', 'en')) }} rightElement={<>} /> diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index 842fb8c3b80..17d4a1ca211 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -31,6 +31,8 @@ export type QuickTransfersOnDeviceSortKey = | 'recentCreated' | 'oldCreated' +export type Language = 'en' | 'zh' + export interface OnDeviceDisplaySettings { sleepMs: number brightness: number @@ -274,4 +276,12 @@ export type ConfigV24 = Omit & { } } -export type Config = ConfigV24 +export type ConfigV25 = Omit & { + version: 25 + language: { + appLanguage: Language | null + systemLanguage: string | null + } +} + +export type Config = ConfigV25 diff --git a/app/src/redux/config/selectors.ts b/app/src/redux/config/selectors.ts index 53ba2c9d10f..59647b61410 100644 --- a/app/src/redux/config/selectors.ts +++ b/app/src/redux/config/selectors.ts @@ -8,6 +8,7 @@ import type { ProtocolsOnDeviceSortKey, QuickTransfersOnDeviceSortKey, OnDeviceDisplaySettings, + Language, } from './types' import type { ProtocolSort } from '/app/redux/protocol-storage' @@ -155,3 +156,15 @@ export const getUserId: (state: State) => string = createSelector( getConfig, config => config?.userInfo.userId ?? '' ) + +export const getAppLanguage: (state: State) => Language | null = createSelector( + getConfig, + config => config?.language.appLanguage ?? 'en' +) + +export const getStoredSystemLanguage: ( + state: State +) => string | null = createSelector( + getConfig, + config => config?.language.systemLanguage ?? null +) diff --git a/app/src/redux/shell/index.ts b/app/src/redux/shell/index.ts index 5a918f75eb3..d4f88d9a8c9 100644 --- a/app/src/redux/shell/index.ts +++ b/app/src/redux/shell/index.ts @@ -1,6 +1,7 @@ // desktop shell module export * from './actions' +export * from './selectors' export * from './update' export * from './is-ready/actions' export * from './is-ready/selectors' diff --git a/app/src/redux/shell/reducer.ts b/app/src/redux/shell/reducer.ts index 5d30a960236..8a325ca8c3b 100644 --- a/app/src/redux/shell/reducer.ts +++ b/app/src/redux/shell/reducer.ts @@ -63,9 +63,21 @@ export function massStorageReducer( return state } +export function systemLanguageReducer( + state: string[] | null = null, + action: Action +): string[] | null { + switch (action.type) { + case 'shell:SYSTEM_LANGUAGE': + return action.payload.systemLanguage + } + return state +} + // TODO: (sa 2021-15-18: remove any typed state in combineReducers) export const shellReducer = combineReducers({ update: shellUpdateReducer, isReady: robotSystemReducer, filePaths: massStorageReducer, + systemLanguage: systemLanguageReducer, }) diff --git a/app/src/redux/shell/selectors.ts b/app/src/redux/shell/selectors.ts new file mode 100644 index 00000000000..6b16e84a4be --- /dev/null +++ b/app/src/redux/shell/selectors.ts @@ -0,0 +1,5 @@ +import type { State } from '../types' + +export function getSystemLanguage(state: State): string | null { + return state.shell.systemLanguage?.[0] ?? null +} diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index aeee1fe72c6..a3f887b4108 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -68,6 +68,7 @@ export interface ShellState { update: ShellUpdateState isReady: boolean filePaths: string[] + systemLanguage: string[] | null } export interface UiInitializedAction { @@ -95,6 +96,14 @@ export interface ReloadUiAction { meta: { shell: true } } +export interface SystemLanguageAction { + type: 'shell:SYSTEM_LANGUAGE' + payload: { + systemLanguage: string[] + } + meta: { shell: true } +} + export interface SendLogAction { type: 'shell:SEND_LOG' payload: { @@ -177,6 +186,7 @@ export type ShellAction = | RobotMassStorageDeviceRemoved | NotifySubscribeAction | SendFilePathsAction + | SystemLanguageAction export type IPCSafeFormDataEntry = | { From 3254494c007d383bc2ea582ea279e89ddacd4630 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Thu, 10 Oct 2024 14:30:12 -0400 Subject: [PATCH 033/101] fix(app): fix WellSelection over-render (#16457) --- .../ErrorRecoveryFlows/__fixtures__/index.ts | 5 ++- .../__tests__/useRecoveryCommands.test.ts | 4 +- .../hooks/useFailedLabwareUtils.ts | 4 +- .../hooks/useRecoveryCommands.ts | 4 +- .../shared/TipSelection.tsx | 4 +- .../shared/__tests__/TipSelection.test.tsx | 2 +- .../ODD/QuickTransferFlow/SelectDestWells.tsx | 2 +- .../QuickTransferFlow/SelectSourceWells.tsx | 2 +- app/src/organisms/WellSelection/index.tsx | 43 ++++++++++--------- 9 files changed, 38 insertions(+), 32 deletions(-) diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index cd8f2c02515..b2efd1947e2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -72,7 +72,10 @@ export const mockRecoveryContentProps: RecoveryContentProps = { recoveryCommands: {} as any, tipStatusUtils: {} as any, currentRecoveryOptionUtils: {} as any, - failedLabwareUtils: { pickUpTipLabware: mockPickUpTipLabware } as any, + failedLabwareUtils: { + pickUpTipLabware: mockPickUpTipLabware, + selectedTipLocation: { A1: null }, + } as any, failedPipetteInfo: {} as any, deckMapUtils: { setSelectedLocation: () => {} } as any, stepCounts: {} as any, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index 8df2c3ec86b..a12430f72d9 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -27,7 +27,7 @@ describe('useRecoveryCommands', () => { } as any const mockRunId = '123' const mockFailedLabwareUtils = { - selectedTipLocations: { A1: null }, + selectedTipLocation: { A1: null }, pickUpTipLabware: { id: 'MOCK_LW_ID' }, } as any const mockProceedToRouteAndStep = vi.fn() @@ -223,7 +223,7 @@ describe('useRecoveryCommands', () => { } as any const buildPickUpTipsCmd = buildPickUpTips( - mockFailedLabwareUtils.selectedTipLocations, + mockFailedLabwareUtils.selectedTipLocation, mockFailedCmdWithPipetteId, mockFailedLabware ) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index e1c15a9e264..253521fd19b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -170,7 +170,7 @@ function getRelevantPickUpTipCommand( interface UseTipSelectionUtilsResult { /* Always returns null if the relevant labware is not relevant to tip pick up. */ - selectedTipLocations: WellGroup | null + selectedTipLocation: WellGroup | null tipSelectorDef: LabwareDefinition2 selectTips: (tipGroup: WellGroup) => void deselectTips: (locations: string[]) => void @@ -220,7 +220,7 @@ function useTipSelectionUtils( selectedLocs != null && Object.keys(selectedLocs).length > 0 return { - selectedTipLocations: selectedLocs, + selectedTipLocation: selectedLocs, tipSelectorDef, selectTips, deselectTips, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index f463d4dd107..e6b5eefbca3 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -162,10 +162,10 @@ export function useRecoveryCommands({ // Pick up the user-selected tips const pickUpTips = useCallback((): Promise => { - const { selectedTipLocations, failedLabware } = failedLabwareUtils + const { selectedTipLocation, failedLabware } = failedLabwareUtils const pickUpTipCmd = buildPickUpTips( - selectedTipLocations, + selectedTipLocation, failedCommandByRunRecord, failedLabware ) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TipSelection.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TipSelection.tsx index cde82286c2f..8cbb8d00755 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TipSelection.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TipSelection.tsx @@ -12,7 +12,7 @@ export function TipSelection(props: TipSelectionProps): JSX.Element { const { tipSelectorDef, - selectedTipLocations, + selectedTipLocation, selectTips, deselectTips, } = failedLabwareUtils @@ -33,7 +33,7 @@ export function TipSelection(props: TipSelectionProps): JSX.Element { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx index d8f48776a9c..9ac8c8dbc99 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx @@ -35,7 +35,7 @@ describe('TipSelection', () => { expect(vi.mocked(WellSelection)).toHaveBeenCalledWith( expect.objectContaining({ definition: props.failedLabwareUtils.tipSelectorDef, - selectedPrimaryWells: props.failedLabwareUtils.selectedTipLocations, + selectedPrimaryWell: 'A1', channels: props.failedPipetteInfo?.data.channels ?? 1, }), {} diff --git a/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx b/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx index 0cb402f6ee8..602098f6418 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx @@ -189,7 +189,7 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { ) ) }} - selectedPrimaryWells={selectedWells} + selectedPrimaryWell={Object.keys(selectedWells)[0]} selectWells={wellGroup => { if (Object.keys(wellGroup).length > 0) { setIsNumberWellsSelectedError(false) diff --git a/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx b/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx index a78ec884560..e095654bc5a 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx @@ -117,7 +117,7 @@ export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { ) ) }} - selectedPrimaryWells={selectedWells} + selectedPrimaryWell={Object.keys(selectedWells)[0]} selectWells={wellGroup => { setSelectedWells(prevWells => ({ ...prevWells, ...wellGroup })) }} diff --git a/app/src/organisms/WellSelection/index.tsx b/app/src/organisms/WellSelection/index.tsx index eeca145497b..dbaa8e800d6 100644 --- a/app/src/organisms/WellSelection/index.tsx +++ b/app/src/organisms/WellSelection/index.tsx @@ -20,7 +20,9 @@ import type { GenericRect } from './types' interface WellSelectionProps { definition: LabwareDefinition2 deselectWells: (wells: string[]) => void - selectedPrimaryWells: WellGroup + /* A well from which to derive the well set. + * If utilizing this component specifically in the context of a command, this should be the 'wellName'. */ + selectedPrimaryWell: string selectWells: (wellGroup: WellGroup) => unknown channels: PipetteChannels } @@ -29,7 +31,7 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { const { definition, deselectWells, - selectedPrimaryWells, + selectedPrimaryWell, selectWells, channels, } = props @@ -50,7 +52,9 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { wellName, channels, }) - if (!wellSet) return acc + if (!wellSet) { + return acc + } return { ...acc, [wellSet[0]]: null } }, {} @@ -100,23 +104,22 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { setHighlightedWells({}) } - // For rendering, show all wells not just primary wells - const allSelectedWells = - channels === 8 || channels === 96 - ? reduce( - selectedPrimaryWells, - (acc, _, wellName): WellGroup => { - const wellSet = getWellSetForMultichannel({ - labwareDef: definition, - wellName, - channels, - }) - if (!wellSet) return acc - return { ...acc, ...arrayToWellGroup(wellSet) } - }, - {} - ) - : selectedPrimaryWells + // For rendering, show all valid wells, not just primary wells + const buildAllSelectedWells = (): WellGroup => { + if (channels === 8 || channels === 96) { + const wellSet = getWellSetForMultichannel({ + labwareDef: definition, + wellName: selectedPrimaryWell, + channels, + }) + + return wellSet != null ? arrayToWellGroup(wellSet) : {} + } else { + return { [selectedPrimaryWell]: null } + } + } + + const allSelectedWells = buildAllSelectedWells() const wellFill: WellFill = {} const wellStroke: WellStroke = {} From ee8ba2087cef3922bbec463e9b7a5fa7f105494d Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Thu, 10 Oct 2024 17:28:02 -0400 Subject: [PATCH 034/101] feat(app, components): add assets for absorbance reader and display them in app (#16448) fix PLAT-476, PLAT-477, PLAT-478, PLAT-479, PLAT-481, PLAT-484, PLAT-486 --- api-client/src/modules/api-types.ts | 2 +- .../assets/images/opentrons_plate_reader.png | Bin 0 -> 25576 bytes .../localization/en/device_details.json | 1 + .../local-resources/modules/getModuleImage.ts | 3 + .../SetupModuleAndDeck/SetupModulesList.tsx | 2 + .../ModuleCard/AbsorbanceReaderData.tsx | 39 +++- .../ModuleCard/ModuleOverflowMenu.tsx | 2 + app/src/organisms/ModuleCard/hooks.tsx | 9 +- app/src/organisms/ModuleCard/index.tsx | 11 +- app/src/organisms/ModuleCard/utils.ts | 3 + .../ModuleTable.tsx | 8 +- app/src/redux/modules/api-types.ts | 2 +- app/src/redux/modules/constants.ts | 1 + .../src/hardware-sim/Module/PlateReader.tsx | 208 ++++++++++++++++++ components/src/hardware-sim/Module/index.tsx | 6 +- components/src/icons/icon-data.ts | 5 +- 16 files changed, 282 insertions(+), 20 deletions(-) create mode 100644 app/src/assets/images/opentrons_plate_reader.png create mode 100644 components/src/hardware-sim/Module/PlateReader.tsx diff --git a/api-client/src/modules/api-types.ts b/api-client/src/modules/api-types.ts index c8560ae3980..298b5189ac4 100644 --- a/api-client/src/modules/api-types.ts +++ b/api-client/src/modules/api-types.ts @@ -81,7 +81,7 @@ export interface HeaterShakerData { status: HeaterShakerStatus } export interface AbsorbanceReaderData { - lidStatus: 'open' | 'closed' | 'unknown' + lidStatus: 'on' | 'off' | 'unknown' platePresence: 'present' | 'absent' | 'unknown' sampleWavelength: number | null status: AbsorbanceReaderStatus diff --git a/app/src/assets/images/opentrons_plate_reader.png b/app/src/assets/images/opentrons_plate_reader.png new file mode 100644 index 0000000000000000000000000000000000000000..68a3b406984e3a0accc6d57539300eebc7bebef8 GIT binary patch literal 25576 zcmV*dKvKVnP)@~0drDELIAGL9O(c600d`2O+f$vv5yP{r1xYO2#8na{YqVR^cqENw`PnGC6N+0fvYHjq(}lJNCLzT0>su0G=K&gK<`zR6F1(AmoKxjs;fbO z1i%V1QQ28pxxV`%;zqo9L{+IuRjN{zs#N9emw>8LmGe}>Fx2*${B*mlS+k~P`}XaZ zo<4ngNe~3hysc1MTiZQh!h~19_{A^2iGA$vLjQOA$-fEd0+uR(tMZPD2U18t@itl1 zJ~waP-0=A0k6+7xy@mgL@5so=HJNO-E}O{^gHeu$&qARPX0zFSH8nN&GMMjUFu%k^)$NJ^8l)>7IM;S+Z~6zB?F{xAJlmFC7d{AXWAYdAA_Q z#D9$Mc)_-Op+Gg+>;cMTe%aC1cHf6T{NZ)er%%uMm*su>%XtsxDuAnUPGuCp#@0JK zJL`V-v!Au`>a^b8-m4irH}daqV=X(AH7QxZMnAHDCqGI5w(EiYj*lg%%ml$d-*Lws{S-H~V_-gCfm{V}RnDrE0Bo|Ixxc^jo$s`+;GE?f4h_d=ywv#RRC9IOfpst``h3Cw&D5bpP!Y><*w-O@4uEC`*H^Daz0K+ zO$@MM99TYB-BE)WU`I2lW!*bAdD}A<=(>;E&Vkj;ykRJt$-Z1)U;juZlUX-q%9I_y z|NFmxkRmhN2XcYlAs|-)T$QpiR=~db=9@J?|M|~no*o!jGBP*_|MeyYzb8_m>zmQ$?-F2D(>XT=qiA#5|au5ThwO0_r32;>gwuRI5aeL<8Ur_3m;?&FVo=1W}pG7 z7-({sz-YplSl|SyzoD2}tlNEnT9V3{(axmyHF5Gd-gfMV{WGeu{ofeL^Y?Es3AV1b zq2bX9?d>n!eDlrwnAJvzDhZvY}&Nx8rH5W*>}8( zmu1{YXY*30=4cXpMPN+Pj8sN*mfa`8G^d&23T#R_e(W0bZwvU01$5Gsx4<6Z{r2;g zEj4v@&vlF&_nY^>|NWcU+}044PkfydFjoQmE|fCFll~ps_UNOJj(hgmXRjC-7+A@s z@a?&gk;|Fv8P8wU2Pvj6eqLnemH>?ZnWk||bp%O3#fw>CccY#y@*w{Mzu(dmP?p%EA z*s-hm&)Yb>vyvOzMAkNKuzF?3RG-xz!T}==s~)8OD>u{Nf-sZJ@| zQvsd49&1|19%zZ{mU}kGL6XRu{9Nw;>}NmwcM~U01d!n~=ie%TYp5z0m2iVm^pxwI zL-u1QtzWZNH7(U#Of=9U(ullc=(=h^16~5cC?o5Y(+=dP{y1lrg@vkS0ojY_|H}pCw?HLjvd}A_e*VVnfy|?oA>v_4180kEp=GfL{92g9ef@oOd`FMH9P?W> z)71}7y>?0iD-|)-3d-VpjCWAZa`29+N6zQ-G|)dlM~@z%<0nqg$=;Kc%Z(5QSx5)L z)<#WjZC_hk+n{%Gfp>^Ak}7~NLh0=6tlz(X{{!5xuVu4Xwk7>iNSMriQn8wdY=af8 z6hyy`G^P8;X(6w<8%>y^1<`rvf`&hS{CK%O4cVMxEjz@(#dq$yJ)q%u1(RSAyXGQO z*%i~Ud}0niWGm;CJ!FQcnC(9k zsRH;S6gJuFjvhHOt-Za&X6{?acMjD0`^0Bbqp=w&0E91# z4(2$U#8`$$hS|jS5-p;`$I2wMs=scoGjU$etOikb9HudAT4>qf z;UULVmxiPHlG(u1-d?!TY-k2B7w8P73gC-SSQ7+;%*LXTWiwf4mNqohlWJsXm{z+1 zOqrB4$0C2$3(LfarV=EMLL(V3WeY*fW1JIXb#u9#Nty@}SzGFp+G7=+Lr8+hlGEUq&F zauvWAv7m+I3wa?>KmgU%)k?^vg~1F^`@rx?n39B#$I=%0hI$O5nCA1rNFIO??nx!P z^qAzgau0@yYfdwo^%rq-KTKtmeeJBKIi6MTiC$vDk2}LtqILai3@l>JEm=Mw^zR`o zw&r=m(xpomjuZ-iJe<$0ZLXy~ymf?^ti8m7y@oM?xC-ElP}pn^d<~=mZ8AJEBEDK( zZ5_3=wn*Xww6bZFL219ZgjV)7qXLDz-xq;Ik5xkZrJs+|MNMZJJ!Hu(&r}Z6IvS7g z(#|T+7C?W0zW^2tcRvF-HS2t^7906?RQkt6b*{ByZg=_L&5I$*A^e4ECQl;uA-c0d!9mC2jd<|;QZ&)jGE&; zJ=DWXUthmiDtvoluDu6FO(KZ$sf(Z@Dke)G|S>i>&JjBEdtArD5HiB4c9oNdS*QO%S z#3TWx=&%*|$3#UWk=sr#ic7Y)@xId=3*y&7p6P63wV`M3wug?}w!0wB^B(I!TCR-ndh3R2F z^9Pg-I(g$$GzO^x_#zP-1D0lio*o=$Hk*l9S8aJdmb4|>uK+5Tk|1ibHCX|1Yiq0c z%OWMrXtzy6K1HmsIgdcXGj+`&;~@2qr{DVo7>F)k`xk;Zn;vR-iN6DphcFK`Ex-%+ zA@sseS_sdP=J6{aeK5{?5nngbu6ljiNjkC$gkUC+Yt_$M<{#?o>VrZ7*^_J|hip|- zm|>G4Gcr6}!$e+fUq0W^(a}L;kt%>MU*vE8&p3cWFxI1xsA7^#>M zIR@~Cj(qCrIVR+jmgi918yo ztv$n4N9M?%fBLHn+BZpuN4l z6^RBus~nO|S_mT;LnCSX4t=vVut|v_!c2Jyp-~#Vimv_vsmzoQ_H?w%f7Cz8TFaWRoobE;y3@kLLr=uHrPJCgL;pja6VOgvb zJ1;plgQ2-#86cqo0~*v0R<=7*Vv!jv=kiQp(&s=yO!FEXGB7DJl>_UB7@%fkXrXit zVg&HtYzzamY$EeBX%>w;dGchMH*Y>ISTLXeZVfqX7Bc%UV}Pwy5`_5>sjUbBWcal< z@_HYQQK|sGfJFV=9sBmpE({L;6@%iIx=eN*1B29Stjf~cvjskMGB7VGzA3@d5? zYwILiP%8lgGZZ=@2(_7$#|(#LT|n6mzY+_~bu?O-+3=eoNs~B6RE5Q(oG8gm+fRuZ zvrpd3+P}B=1fTD;N&E;rlh=#m1SOtbbo!8r$ttpDZ#3LdeOd*p^J#4-febe!#?%3{ z%-%OQH_`n03q;#aoiijHV7xM zWbqQ3J$p7ynL0&4J(4rbHdyb-eFy^UkpQw8YoXV2XKDK>b5&IUU&P`QR;*zs&z?Pt zhK7fN(}M$IR-&;XECLM$Vx*26+Jx~FsIj3@Ox{5T#1Myz0CEE)^(F179;*r*SOaTP zJM%N}fE+qCtbkEaL)#4U0-v;rwbHoOan#V%$Qr3OHlV_1nPD{`GYi_hU&i?M$#EiG zujf}y8QJu{!Az6@Z=h=d#X~%HYbF6&__LmA3_yZCu3>8ipq)5rBAdojXy%L=G;`KW znlNFabY2oe=8G7S0)3A#GBC$&C{xbz@UbzLR|z%xHMd19SutIYeZ_tmiiyv>MY|*`MU4 zdHKf-eq?-M!L?vaF8~c7pkqL$6U=ph9c(v3J_Sp<`NyLKE9|Z#h}i5PNf+CnIdUxs zBGSnV_b6!H-xLUIjLr`UEx1;6g0r}@T)cQOjpudT1D+uSgD*7o#3u^PO5~RK&nD`7 z!dM5x=kE>9pgKntz!!iNv(CbV1d@or4RF$=$KRy07ys!(wJvhTWa)rS79g zWZh&x_0a^5dBWT#kNk>$>Jw4cKPTUGPH1N511tGt{t_QnG^>6Z(>Nlikcn0!rawSi z&weckD44|y7cP|NTEj7M)vhDMBlf)R-H_VH=CZsS56L}8q9A7R`!2b?k6taS_U#0WNgEC0Cdj! zZVoVFQW}3bu>P^K)Z#CPYI=JbGl;@bZU`TFW;liJ+M_U6(t(YddlYseL$Fa0)6~W? zkIdsF=@e;M^BsIy4D7)D6yI2k27jSJ!)7V~$yS^~G zT~b!cw;)tI!<9kji6j~G8qE=KootnypfivvfG4I`P$klCtKgY}9z@V7ftw=rD-< zYcJJm&#^p7876+_PKG2Mw>L zzvBWzI!0$8RRCXr5@NK9>-9DM88_Ed^L4NZc*9LM(ux%;=#AZP(Dv;+BulBTP$!Hj zzzh)Uf}{=j%LoUVDvyIEY-2^wrDnAPTFh(m8l_+`P^Ve;qol8@rf?W|U=O43oiFPV zPkR0!o~kH+XEV-(&d4B*I$@2`L4lB(F>?mZnKMUB<8j={Y1PUo4jwu~z-Q=OT%00H zfQVutHJ8vr#>^h%XNPNOm6-4ps^#-fpEec5V|06=>$R{{P3U)v*l4X;_e{H3N$iH(L2pAdfo%oqTW&s$9zC@9Sz z#=N%Yv!HcxX5{QsCRS3`PxOeyYI(g+O-T%VO&giSE}_B-01GXP4hUu{IDlnKm(c>Y zL?%w0B=>^vK;8*poqtWv6)>9u1(h?~T?ZLGVf%O^^BArOMs&4~Yol51ZA=<6i75U* z$FNWK>7WMN;gNfPrXLHd0KUk@xm{rxYIXl0o-84rYG^B3d2CL+q|KBmQ#o!qm9AZJ zE$v}HdCTT4)Y;WZbzz;Dj$&d%Gv_@M8|*P_WoQ>@WrU>UcocOQ`l+CG+S!`f7@6be zPDf_3uVM8wh?&L88fR@bl!|0Pv6htr1^hi?-d9|HIW1bWSTwHq^@z=8%=z+;={6ve zXJ>ChS>nL@{LW2@JPA)bW8jCePQdm<%TAg&UWl-vK@$=K4>2s7*gd7_O?o7leP&f3 z_+k~Ce&^Yj)@rj2%o|H<4A&r22*@T4P;y{Ep!S#Wk4F%at8;_0I8jr6+$`;?|Suk(YqqcR!5c-Kf|g9zL>=WF&N*1 zAyI;;FNfL)96YUZ3#?uyqw!oL4Pz`W0631#zLl%5qt(}4PX`YkWFT*$F4oHZ{R0-f z2Fx(C^EpFy>5xOjLoucp+6GA(O7H}p1z?D!tN|w(ux6~^>Hw>zu?4OYP#HT5gJeK4 zYmT(VX$&4@K~2Bp5=rAkJQ@3=1Wrubk-F1SI7-EV${AR1mu1LpP3I; ztvb^<@?!&v5-vy)E*}6bIy4*`<}k)q&t@$v1rB&uX!ou|T~fuvFKz3041xdbJy9Pz zOxd6E;h3|qAXNZgK%$vC4?X;_tBc5zud{0{5bx4pfcF5&YmS+ayCPnj&~m(npX zXwYdm4Q4YZVgoGPb%zmk^Qu2@gffOOcd>{xyHy2^M@T;?v0XJQOWP4+m37I;SrN*hHe0+F^2uM z^Cw&Yka5R3K8_Sm?B|0vI=*);5qh0wjlB#FKbc>UPd3Vyz+`YalRlfRYT)xol3npx zm5}_T_rL+y{zaOi=o42C;1tKJo3JbJF4_5o9!M-8v}`K~smW~CPGx_2MehmPvzxk zxJQAJ3A~g>)zqdF7y(O8CPNMBrT{N=A~fxhqen3~#QN;sb5VcUp)-uR7!jY~cPbP; zKk}Zt{k2ZX;+yZ9;1Zs}NF+!Vz-LcUaYm;n%&21 zcjn>)+squkB}f!E|yn{O3l#-BCC?641*>xq*miOam96Js^Kb zoX_UQHE(8=Lq+;R{tm72=KviwTo+5Vd+W`D0758gXmEguuYrwm;(JARL7E00to9J>g6nQaGq6ll|0p31HXCPGopSiqazzvnA zv4G9^pI-b_^M=W3nfN8Ej~n>fmnh2D4=C;nhx#iEGH;}jfLPImBoT{h#8C|ql^13^ zmcVaYzKw3%<5Da~*oMf|%oIUV2-t7QH|Fh)%ofj@HA_H?GB-*>fjNdhi}02nONahN z*o(mHt|Cm%=%5cQ3JvLVi&+RR9OdcrOq@*g--~9L)nu~+OrFJU_zI;8;IT@bwxIf@C$PMsoG1B`?(G4tf8xM225lIc&6G?#jLgj-BCjl-BfpqD%@XaR5;HH0G?(w|`8A-6 zQ#d2tMTWc~iV;r{AfiD7$by1Kg+D7pBo{5B=B7qD_NkMnXy3m5qHPmTCpu1;*j8kc zTa(%Aq@3@Y#zIF1fdi^OYt^a_(Av3b)|sst9UDo-0A}K@DVquY1LOE_dU|@E<8%Fx z{q=vDI(6!(#MZkFz`n&($-ZNew-dyn_UbbJH89#Ylkv4LcJs_#Pol4I$r;yR)Ao%T zvMG9}rIsBbqwb+Qa^TLZ2^?CYb(cx+Xc9FwKWpAMTHTR zl{G~M{Cf$V6%Zw)dElkE|7w(Pknp>GMr0Xao^+d)FJ1{qhXR`1;u z?;EtW!uq6C53i>!?3nAV)2AYm5ko{2k+KM(tlUY9e4wNum?84}=) z#wTZDey>;*p~)h{@IvI11GEUKO_nMkffR!WGk)?|uY8XI5;zWbRuzp-dba@hTma2) z{V|u3&Y^Il$Ugq~leJM}@h&I`Qhc|#B6ciR>^J7K5JriW4#bWK^Nr4g;So~uBxFDv zWgBHOk!IFM6k4!hX<%XsBos2C6tr>eZ##;RDE`>P1PP~?;DI-Yf0ZT*+Q4%=f&_?* z{8#VOe`Y8j)PK5PX0eb`zN}fnj!$J*0G$9jDNQnw)YPud*!$r9dw5wx709~*z~@R! zhKbMQ`dO3LUw^%>x3_l@uipCXv(LW2uDzR>p8*wYmA;w+zgeu?{Hkt}#sBw!$`{VZH0mWQu2M@MF56 z?O+B5`&}`5n8%pV3>8ms7`&&NY(_G`Fl&Vld-PnC0S4_hULNFa4=`{y-+JqSLRayhe>8pi^c6B|f%T6t8($dC71KM_i?lbc=`?FtXxh*wl%Uquq)8LaP|7-6 zLyXPizSE}!w0W;$3IF~iF9;RA$ooCX?{&k6Km6f-IzzeG0enui})M zZEfvsFTC)=2kILd=GGI9=NDaLrt};qrY4G_BfAKdC9&NUY4egL@<#J!auJ9#vBKt7 z>u6{rhyb8r-nc1j#e0&G1|z5GVp>>#5{5k9PvL8Lj!-Y@>kT7YJ}aM&SWiLr63^di03w zgO1e=ULYD|5WmFR9^>T!j`!|awQAL=;0$JBNJ`a&eg|da#*H0aU0ti$U;02@eg7SG zb>pYk)z^;;jF2|uUS?y_*cy?XKXIbOMbYpOj_Bo(gA^*~??had0XNXRHp-06`pG!I zE)x{urnUt$B*sFkq&f^i>@!Y0X%<#gVkdS0$7*|r42oH93Mj!~JO0|L^Jvxg&(*%p zc%YR51sOXfXeMyr&Dv*Vei3O}fEFPgjAa(kwze2wmODmhTHG7JdhF;?Hx^o@rlzLA ze{baFQQqfX*0ei6`q7W}o(<6YQU&naD5z=m%rnm{<%V%*U0uVi{rv;WCrz0;f&HY0 zz^mA(n#%yd{Eav%G%x%tG#(E5OJTR>NzFotzz0KR3=D!d!}pAN?KCeYKMQmg`5*@D zj1-*9npi3{bBukq2=YBlUkQ>2_=Gv^VzgFM`my7HOun?vn5aNad1xM>wWBcW&T_DR zJ~{K)UyrlL&Ye*9I0$UHA(L=|y3H7Tf%Afh!hndjac26LmS$660OfR$1(vr}N%Z;v zS_bPYe2)7W_)oBfwDp!-ZaM3q^`#2nb1YzxIViu9U-a)i_0&@z9T*&3%p^yE@l&}fewJ1WT!C=G+0VVKus zeJ~lq7vvmpF@1;t7@tAGHtvGe$dSKDWC2HZo38=MW4Pn0)?Ksis(vxqUm2|#xD8Gt z`=Cu|W9P}*b7#EdmE>#;MZirk$+%u*eho1Q4|g9He-<6�e8f8F7$OI6Ou>v9;u; z4OL;#hWu`KacB99g9i`(^JhNunQau&W)V3N-S%0PDuB;{+<*W5t$F1=ch+o?R#)MImw2azgQN zd_TUULp1X-_LWaF@dYrVYNHTd+&~LzPV)AGt(KADMjZ2(Jx1AQv7>XF7U^qb)zNYP z*Ojj(qM(Sda{0;>_m6$>I^Aovs`FvxJ560wZCgC^V8#mrDQ%}2Plpa4l27~|u8I1r zC^~@Wf*3A_A%c7GuRUYNGI#R({pIf6yMOwPZ+zqB0|yTHH)}7}ch-yKtbn-+;I}CJ z!ZW}8u+>XX@xcdn0+kcQIOc+ls zO^q~t+BBLnd9tJn9%T)RGNlg0sV6nx0k~LiVt`^u9qK#{^!LehoyhjVxd)}l_(>_) zjsoOLHeoSC{QzEW#QDfi&RESH1VM33G)RV?k{M5t8(_|5E;wrcVO;ByPc%ipH@k!} zjp`Fnkr`}(>&@@09h7%Q=_sY6>+KcO7X|cCPda1oQ71aXI|5NCg$RO}Z-1Ka>CL{r zzK8bh+xPI-zy9@2$B!RZ&}JzDu5Qy)hbe!jywNk0DuB;Ge*NoT*K-d%ksHC~Yu2p! zJ@%DupRoT#DO=hoS{Z}hF*NST zk)v#0pN@ryT)T&F%x3iNJ-cZ8>(B8qTIsSYucsNa7tpx&4$&$AG8$roo#zO#z;5I<(hC+Mm{x&SSe$xAQ2RLd`T62HJ(7$A4@ zrq$ed<}-*hNWe$d4M35%jaI-Ani^7hOD4(NDwt^i(0J5j!vZsRd`CMq3khW!5gOK^ z!-wq{*U*6q=ABe>tf`TK^4qt*PCK`~B%zIES6ol?7A}>>jNc4!Co$O9ClgmA+>y=H z$XvacwE&DNYA*X$9{}TuuxA5TjNO@|E~&w1wXN~Y>kWK7?04)~kIcrmW5*8BdSK3H z&6@2@UOk_F#Qg0*&Gy9j=7g3_CRh|~$2KG`MXz+8(6lJThmf0+ceS=-McO77|GfOn zFk+&ULH~SrclWQq^PTU!xM9PFK8j4@w53qtcSPxcE@f(u3g|Hb_)A~<(j}``ufByJ z!h~(Rc5QBJZeI7-fBn~IRLkc=5GI|bvc_7*fc+r9*!S|L1^n`x!1PK;fz2+oG?Ysv zpSiVCu%HtO<1tHr+^(9Lp=Cayg~5D6JK#H{-}M|nPCX}jg`h&%NNXxf707H>em^vD znj3QswT^2~HcDZr0px<67Z~S3?I;v~eAB`Pep$lgT&}UA1k-p38yS%ZMu2Fcm^-xEI_L zIx-Y~#|R$)9Gw@gbAU-hKiArm9Bb>H$BrF)dh_PZkKA|PeY;+J?X_~CtrW1M?OO`O z6$80q0Dt_`pI-6aTW`Jllb`s+V)l`OJ$v@_zV_OtXF3n=|NF_4CqMnCfBL5*bb$-} z*(aWOVg{2;SMv+~C@-rS9CNub)U~uUi1b==N|!MzzMp ziU;2>@$>p*~L67HaApgN?O z?sxNVvYWAP`}XY*|MAs=gxZ86bqSAT+M%e zi0^A9zuc*;W&@OM(YWe}oq{uU7`XitjSKNwj4;`?b0;_6H>k6-OGqX7r88&F5b(~Q zzd-6L!{-ePam@@-jf?}E={;J6kZNLZK%ns@iG#sBWapuEW^XKgQq53u$q?FraXlzJ zH^e4(M#{Z5%6*`*!@NeJb(rbCPv~o81#wnEZO`4%Xf!YYj7h~YH4D-}r%s(F0M?oM zoSE!QVzSycV`m{Wv)H>To1tlQ{h~!r3(15+d6*{&osM}{#v&QAHL;SQRpEQwyLIc< z|C`Cj&CfmeTo1+lR-*FYD$v*gT%gfGT#o!ADq&_<1mOSlt#5tsipws)pC3-mqD2d7 z&g?nzuvE~9*yPB){PKoJjvndxuZ6kT2}rca+nGiJ<8BvJ$sXoGkzvfelTg9e8$5BCAgKAvHX1*^%3GQR#v z+fgP#n|!9+vHbHFzVL;o15Xbzvb73ui+c6d*N(sb`ldCmLd*^@ckWyQ#i2x=I#A*V|B+=Yx5rS%GAZP9I;m7v! z`A#zs&1?t}kbRBUb?_i<+wwAXAKJs`o6F4gWi*dF66^y)Y=E_!GSx7L0S3JHVK&*Z zt}zOy&tCwe}n!^C_5rBVq_uc;w`(b~HywHGq+i6p$_3z!gr)U2B`7^G)<{FyB8pTfo1mUvb zl~;~#+_>?_g=Z-2BvYNhu^n%AMM(;Q&?7n9WHlp(Lg%0AGxC8wp2Ixz z8ANmD%$9j>VQS_Jq0`DSCRVE_1|*ikcn%wlAKxxnRTA4pc2@rYccy((cPX0H-U6A7 zS&F%AH@>=_A4o_I@`ejRFGf|4A7a15q+`_-#Io4%9^?`YmpfS zjd<|DK_;@^ka4n@?QrJIS)O?xg^0*JFf6M&FuJ!BJ`-S4lX}NORSi!05NiCaCe5L_C3Gh;#MyczR z^@;%eqaXd~@7g=YfBnrpdnm&XlpENgx|;0DO`A5~maWbH#|85j%)9p5Ya|)L*H~Z# zpMCbZ!mF>o^1m9}+yAex{P7>}rE@4Ryzs&}CMDj-FYjaA_}|ORB#;^tCQPIb_BX*y z_VxE0-w4dH&l<l%1f6nlh72jt5&qoA#PFl#0=R(Z6(YQ-z3QzMnFm-1l7-0 zQ>JLfhG;;#uK^>{%X~ru!VMVVdv0b;i06q8@95EE0wRRaWN4us`-|)_Xh?t%^9+ND zMr8_E8vwEE=nKX?hL&Y(O`0V%Ejm|CU_tINj;qNPns=Pkus&!3cz(Z~90GdiH^2GK zvf@2|B01Xi5g!!D-*wja>l@L)_bQ}nNkC^HF z1@oog+0tdpBs?QiYi9frMNv`%q+tEki40_P2pVq%;7+g~+2^j?*XWc0c049*ku1mV zKYdC%twv^hVP4~=kI3-3oM>bo>Ci_XqY+QYG6Jp0*m=19gFIxBRbM~@tpvw%4U zL8oLqOmK8g(BLrLgT6<`No6yT#PVQZv@lC6Y_h#q+z)sR(dz13dzv^hZKLp$ z>-40iEMK<`%ZPc+Rt?UNEH7l1fmFhEBktVE8W>D8#>v7h!28MN%y`-f6DP>?F}|}Q z#BhDbZ5pFDmNr%Rx%ct+hq?29m1DP0ef!(rev#Xk>yyVQrLKEtEo;~T(WlKdX`AG``K*_{V)IWFH36V2OfB!iGO zdk??d=?vlkJ@+_1HcVrHb%0GZ__Rrol3hbXlSFpbWEh-{3Ao_TvbklnEHCi$(11V= z>9io`x{C{aqd`KurD+L>n3%ke$*Ss$`Mv&eu%OY)d2MIlhUbOGBL|RC)O-$WvSqB1 z5thl?+3f?LOtRY4tuyOkR>QmoIpv3!ir4M4bKC4w<^TIn8I6qR z+GsT}^0+%Y4@#}$6WlSLV$F~H27sZ3<$f|IJcehivD{MbMdI1#F^GVzz-Kr__Z+)+ z8+Yv3f!J;NNuRUHNRJZ=?c@-2Ut>cgp0;V%_9!o zy~^mHLcB(libQ->&MlVSj3=GeVz`Eq7-XNX@pYxZoXVFNGCkUVQ4XRiwxN*H$7 zU9$GZ@;xvSka1D18xUqH%pmGoed?H>pk2 z0GeF#C4Qeg4^e4tOQp6~bo1Z+?sxBCmh^s10rB$7FVlN&xplZ>T>Gb%FTdjc(SG~n z+TX40=|1$Q^^Nr(nt913ljm@Q^EEOWHR6Svw`|?od8q4$lP67@TvtE!7X(1-=(vE}K5tY;A3GCBMD>J=d?> zxpU`hJ9h8>UvKW)_s4tp?)z7>XU}e5z)UOrJTzi3uB=Ix9@xMCziedk2-;u?`_M@2 z2dM<0nUOi_k_BqZIsX=a!tX--@W6osG6!rIYxvV*7WcZ)gN)R%K2{nzf{;lwH$EgW zs3gm!U>%Jgjb0ldh^G_YZuO@XQBh_H8H|e;|S&cQJ4)WnlPpvzOVq> znz$o24|^P2K#1#tUH{EfPqS5XfST$N*Pd-%l)Py67QPpBSOBkI+${~XyqzPCxkLP( zUSzZQF?O9cuUoecv04SKYTUSaRJo*?&|#S+sclP{#6ED-$g5J|B`P+T{p+eoDgy8@ z(MdL&hT*fA1buD_K`@Rg6ehKxlGbvVXM4|y-hJFF{}uPjjq|y&!KZ`Sg=3=u;AQW5 zbFZW!;yoi_0hv7zSNepaH;6_BGYvicL2gJ#G2?n)zxcyPtZ$ptmVq6qgL^?fur@4d zuo(X5CA+JZ^EEPnDD&KUjWKx+02%|sVHW%Sguf5YtE@Gw13ia1r@2cVw~OgUu#cGA zLBwX`Xqvx_j%ov2NC3^edGl!sYeaW#=Hv#T6YZ~J?Y7Q4?Q`y)lH%b7^ z*RIKZC7FPa1q;A8`;-1GeB`802Q8#xw05%)OPF+8G$J2YXV%MFQ&zM!A0jIW4CdH) zE;u$~!sJprEx^r#mUSE-8WZ)g;Of9sMyA({S+h(!Bw712Fu%*%j3|VN@GQYe0BkVb z>tqVgalEw4bHe_oIgEtifyQd!duH-*8Rqq2-yFms2rRrC+!M6CpF^#J^RXM$$>({2 z-`mSiKmGJe4?XnIA>1EDn$|f)^wuS*c`MPlrNTp%`o76y`sXD&3&2&W2*9i*`U+@y z6vY+m8X8goj0`A#F_-hpyMw{|QGQtq*)Qwh#+53sq%z}3x?sjv{AS{7nK=Woi|IgQ|1)C_*@5OI3 zmI3wmud}0mhV1|Wi2BL;x2Elx1p3I=Op6Uip{v08@A@0JN8&d!Np)j`F!idsM@HfU zy>!?ih`B--xoiM0#Edl#B&AXK0%-KmpJ}&1>UIG12iZqG-QO=JHQo_;isqJP>E!T? zw=-zb=^#B5BqBmZGW-yJI-Y&kQ7PY}&&Fr5l}|vNmY?Nw>`p!LEl}js zo~c)ahNj(6E|Zpz_xAMIL>?p z&75(G8||`)!zc6DOxw!eVQlG}Y+`TQzD-CW1qrkyOk#lC2N*;~s8~>ziY-Rqr=t92 z6%8GM1xG@Qe&-kN*8R`{Ap=PiwU?EpqVZ$IQQ?oO`HWa9mca9o0YI3@8OwlcCzBMg z?FdI9HFFYMEVxdXv`%Z|*6p|(!&-C4!YlCmXL3i`Dz&ph$z9&IZM*Sp=z7}Xf0w2;^He?zl;-Qc?Z8#3sMo_d*`NR7-tDZ37rgMo^Gq-;qw8xVjD%$+$pJ$ztfOo1)NZzeJ!vt-F) znl*D~EQc6O>3-JOFy$V3LC~&b@lPuNuf*_=U>q^U~g($fMO@{{SYjd*83d zHXI8LU%_g9L3gguS`U(j$lK8Y$n|4XlV?I}F+1#s&=C?m@a#3?%f+$7xFdjk@?llsPe?DFg~)hl_YY15_)sVBn%n;OMDMrITSZTNuoi91L~N5=?vy3g;~v**Dd z{NM*q{O)(ZLzNa~k24h4ij{z{9FX0kDDihG6WIr3(%dZv(zM)WqVmjg+V!0iDGOj^ zL2*O5nP2FKMsoSN&~(Up8exAZSICJuDdyMAS+s{);G=9x!OTN151$hvBYxOVMbIB6 z7|4+j?lco9FaGX#w3U5Z)JBHZRn{1?apOiA*>dAeH!;AEFgrb6fDCgE4GF&5&wud? zy6djHgv8=TBfs<05&;;nywG4Uc{5%TMyzTf{|3G%d}N<>_O&?fV?+w8*Tlj-Aa1bu zqWh^}KbV+)=qfFo0u83K)+N8gb)l-o@@uZ4J8pk3jc0Z~0+K2?A}{PL9VT*=ac}5A z3O4?{b?a7{Yzu5W=Je^n00J`cVO%>hwD#_OGu1Wl*QMMl-xG)2p8M<{{K02=b0Zap zeEfc-oG94S_VqgeN~|YAn&#&wwPs^lZrmDq#&<~P5YS+Qcp6#y(Z;G1~U zDt>`7GGYA0i9%k$ghrOlO*h|6&por2Zo26`G{`S~9{WFgEr<~(M9nBL#(8CI)r1ao zA}TZf_T>#P)31JcpX4H^B{rbpPGT){)6KVtuYBocm(t2>R|uhU?|t_%gFRg|(dR$+ zIq^BoF@l)3@Y6W`++@FT77YTYh_O|H$q7)vj4x;M)}zv)CCI#tNYZzUh98ktJw^Ae znOm@45N}<5-Su?&<(G?@T(EiBQQ}#g;l%=z8qXhNXmMW{-U%xQod`0apoI~-3v9h8 zO?pwER*#zmtYOKG`E0|9VuG^|{Mf(#%xC@&-mR93NvWh3F7;hGli3G~ye=v>mD9%C zrU5X`rwvE>^v_cYxXJamMVv|kc(1$ey1!!J{)ijF`*_oQUK-SI>R|0KpNXX1yZ4B{ z1d?Q!&B4PQevlz#yr6-h(LkdP+t~rcd{go!aA_CvZ`S6>UU&TsvhBcu{f=?<*+QO_`CMlekjQw&K6ott=sjT3MlQdEGrwk1_cOw)46l!(H-x-gFgAGPt%9~)rTa9 z7}kqspw-xFD29kKL?1vqf#1<&)~K+GplSE*+ebT@D>%v8wq9y%wMzm@qxHQJ>qSul zt%K~7Y%17>cR6hDT{^ZP<6`NsH2V7ociwyNFCL;|aw;gZWGEfHNnbY!)HK2=N0GU! zf9Fc9C%5TRifvBQzW#M3*WXrgb{?Cg_3X>s&JCpTq^zxd|Ztme{uqi!*HO8gOE@iXoxC|KsAyOwHj}cS7(U-t9Txf$&eDafY-3>R;laD_p zF-$+EDvYfpI~=2jAAC>%Hh1nEnS}sM>ej7Wq`^P%z^`J10n`swhF3#Jl5kMt4H_Uy zBw=KwQ7aLp`z5t5Km))bktB8ut9H#f4Y<_*wL3)-;y*FwA3))yniA8IxSGd(WHVieS_bz6YQIasU|jk5aYpYx~Ps5ytJw zN$P09&YNUF8%^3BSrn3_iHxaI;PpY8wj}r014k!FH;vPLQ-512(>bjpD5qV^naJ-L za8(?DSu^Fh(VjxK4RTr~8;8RQYFdJo1PEtvcn3rWOxHeJ%5yTCxG>FnkM{@>g<4CC z7A~L})21@n)IxdNsgHzJsij^Hufxvat@Ly4Zx|Jy&L+wXWUJt08eAf%T1oe(s_ zm)5-~KJtwW_~lnErw19-U;5IQ*(#VKGsUSUg{gSz)alp_C(UMn3uYjuHc@RT>7q8V z0y1oKn9-pQa(qRXueJy8=W0$Vga0BkI~V9l}G0$orMK8-jRf!G8s9454$%06d_69RtIC? z{z8hz24@13SK+(lX3pPGLW3Z>4tI6oA*X54H1PW7w7 z>v;uQUmSqDy1Mcl20Dob1anHX3p0@*Gyq(wT*%v?b%&+l4oY|dey5m!fk{F@7SGY{ zLjvZxY;q%>i-P5cnM8tSM`MH*M~*Y%!SXyTB`r^#8xlH@fAiVT(W=$gF*)@dz4*cl zvGOt?q&97OomQ_}#ccHgy8nUuMbm=>0)WXL2V+!yaM?L+oR*ea!F;cp8?E~(fc3Wk zOR20b6?_NtoUEUy*Q&^s@3|91k_21^#J1rIXu&%SV28$)mtw((PES%YEhtfN4$lgu zYK3pGgW{3Sh$0G-A8li`r|r;4f51i(wBplR)*hhLj!6jsbLpPeCF3F>F0J*Ohlg`` zHpzsHqD$(d*J+FYP;sDG(aDiiX$MtWRST#0r9p;l{ z-3$&HEx=(2teZrO8W~Ckas|y6J%1yEToN;0-y{PI7A%-g)2B`m1{wLWsM&;2k||6! zVk>3n^NBPqh9J(JHYTU;)OuS(nFF>or9<(>Cn2d~e zs*!6n)fUY_1zZO@&8#rJ$0h9)<}pV8z%2n$g_y6#zNL~RTR^E5L(~d)B;*xdEaGTo#Q}w5CiT%?)J8A2 z8?5ow9P+`KU1(k%ybh+fV10TSqubUd;6Zo|7LSxo12wuCnaXz1wqk%@!GiS$3D z`$0^##lZF4`eM-lb2c1u3H!@vWG}t+5=~}5cj@9qv}pc3I>Fnzx(-Rhf~J9KhLlHr zyW)qT>54g5U{G8kb(A-3*dXb9u-s%IInHl84PyeDj4yxiz4QI--vAJpY*UE+WNO$+ z9*|67V#pr_dNNUC#MUdm@~SK8Iu7|@lvQJs8Epx718&AJcUrhdf93;d+h&=jrBw(j z5Jmtvs)U^EJt-9!0aVNi0ZpsJFQI9*$eUUu$&innoA;BBO7^i+mU!K`+r-Pp;GXsna7m?H%;r7(zHaSwJeRY{eqIBL=Ib!{b%>zfBzab;r@bO zh-<|dQwDD|TF9$+5(p!J`(ib?G>)`eEqNG7c)p$5GPH^xlof_(C5 zej@+5q=D97Ptm|hmN`x1mI7{$iUI5ACs$%3$1ayHVkw!4^^uQ!1nF>pd*6Nc{SR)a zf5Q#<%lw`iB_G+&>ISf3@HpbH@tj{H(251yQbL;_jkLq@Z0a7^zfXvmxpU_b2KS@3 zH2ls(tm)xLH?tWm<#G};qHE|0p&l`TZ5bJ8uX-IJyvqW}*ogsf(A?WP-V6p~JBU7d8bij6iF7P!t#PKIlgK@j82EQZ52abnAsE#d>? zxQO94@bx1+*VfkNSZ)w@X#+6)ZKkYH-j?(+tTD#?k~4GNujF5~Tr$FKFj0m<9zmFT z?9tZk^T|;<>#QeDV~q1o;km z*1AT}#%L7BjvYwY4isMe2G+E4CkT`0lLVGE2{UL&A>^fzLey3G9e9a@8map}CHjP)VT1oR4 zEVMPH$=!=+?rg@T9xA~l6QZ@6sVzYq!3W;Fd5cUt0!=Fe&9hd(+ ztu+J!FlT`nxWe&P!;WZQr&{GKUs2K;WBB=EmK1_^=!gKm%yt(AWHJOnhmy>HdWamqgN*tEFGJ`=H z%mmYX4>~nwIie_M!(MsYfoQnLzo2M;540hvN&mL0ZQr6KHE>YLk3RLOPeH~0f4}_YFYjlt{&yR85VyMc zd6}H`Gly(gO2bH+D1reIFTVJq%-V-+B@kV`Cr?x7p)N6-0SFLXJq7b##Rs;@7Z8v# zli8g9^MC&rlBM)7|MFAXwsmWfR>KsUlO|7+DvyTYUMMkH32TO>+Kd53gBwrVARtBO zgK0!C&>a9mcnq2r=S1g$=P+zRs}(6rX+z0B27_$tUYYv}B&oveBma_lT?G%c27uX2 zhDr_n^UsrZZXcxnwo>bsP(!vzGPk%At?Lt8NiACiZ3O}VR|ML${XX`wkL_U)uj5Ai z!5TYjyfz$-bE@W36B2h#-Q0 z5T^xLF@gx`jQ}lN2LKj>0?=_m8@Jf`YW2P~VMFU9C$*fOQ*)0cX#@q?CDSQzGdxhj zr`1^uEllks-DaqeC9}t?*?%0Cylr#p(6QR4 z47>qEXadkwS7UOJVWxxhff);5^~jMoB)1s*Y8F(Z9gzj#4%*}jzdp1(pSPaRn@qHv zP8=R2!#3iV2xcbR2^OQCsO~%)(ClEoJcBiH$Ok{xBpGw03$?6g> zF(J$tP$K;hU_gQceB0TxW(#|(N%eYuiSwcwr8j(0kBy^ayJ+b!bQ4M2XtgD`P3`0+ zJ5!)sJO2}Jd9Ne1n&nFHCzOgZvVec)D-a8U*BS(dSw&_u;q zHPF2M$^7HIE!U!@U1h8)6H)*!z-?yGo9Je-7jca$%E|RZPdL^TW~}>|N%2>NAW{8BL6n{4zOx|fuXiXi{EOnw8Feor!%;P@syK$~&YaZ+1 z1_(MVn9`*r49$Rnf@P3%uF6H=WkrRtM^Gr_T!$nXWni4qnb@5wF@clUpr7STV}Idu z=lR}t@w56}XJ_X#%t^e)WMPlhwn|8i1-L|2Gx@AbDHA%Wm5YF@e{(|~C~*u|@J(`_ zQM}y}Rsb!40W%xGhPf%B5qoNAplB3~5n*8k{3f*!*-Kg{8Cq2a^5gr)aZ{+JjH`v1Ms$BqF z0TbpE27o|L1(7%EC)yjnmH(E>u1!TpSkM@mgZOJ;>mV+5}(f$zU*QTU0js>*(nMOh-wsEv>_ysnLA6UPB{qKJJ+u!yuSAhiQNt{re5a|5>XVxhJS3V`SoR=#9`Ipx3@}XN+L~<9as5&~ z$!A(>dnLe4F_B;3PV?8BHf>tZpgqvl)ip?_J*0(%lIf7;v~3l%Z<8qKN`W{nDLuB0 zK{?C~ZioR?Q;C_U{xM8MndRMzR<{5_gaQ4~c&_XV{tttq{A7R)Ch(TmH`5@;l;QjC z+`e6k!=o4+0FDt(NOL@Ruv6v(Mm7?ND$KbA%K+y@?lF8@O=I*Y;Yv&B*U-?g)yP>_ zaNRs2&#fgxM433P0cRj1i~L$xX`jjU2w zdO@W145-Z~rpc6<7lwwVdS&m4;}ZIT2Hm@7w-g>g*afj)q%n?Ijf##!W|HUg!Nm5{ z82#(@Kh4yprX*0tj@&7Zy{f5f(;stYRFKwN)?+1~Rk>$Y$!Dp>$_WPTgIl(2dEkdX z{NZcd`QSp*vZO{MrF;Op%cQEjV^V1@v@dx1S^L|%!-o$SmMvR0oHiN>Qm#QlbUs~x z$xj4<2F8N06Tl|pf9*K}`};hD50i4C*Z>kJKmyg))=ADWs#?h0!Yu_8KB_T=*_v&| zMw!L1e?WetmO%1avEbSa=sn*zMnpyZo>`A3`W6$pp}wq^9qk#VXIi=3qj{ z4icun-%<@%#>8UwJ*yS{@wgzi00x-DIh$t;!M-4*P^Jgzme8UosRI*vcz8&L^<{0j z85|SlGK!gNxfz^$^XAP$S}k6@$XyQ>Gty_+#glb@-88_$$3+E5soG&#W-#F8fLLk( zzJCqLb^q@)Ez6&LxrJkVpD%OF`mr@@*8J|JmtN{68Z|#T31ArxO9Y4B%*N6$9fjY!8Lupv4i> zHFNV0J8hd5?!lb4h)KIqKD_^kEi4!9ZYqLMXc%O3`KDbPt!#3!>peFh%{)%}(8=%K zf_Z$3JDeTdfj#=lE3Z6r|NZyBX={G@pi~AqSwDHSFp#@Ms>*pO!6@6+?^V!dDKejR z$=rVX?QNg>)Tcg%5lRe{IboDaG-lC;se=G?%1@t6!r*xLv*TGSV}Shr1N%j@VvI2~ zwvO^C)mcDvsuYVE=%suw@9pTSi+(A9_`s=Sh^1uTR>}Gdjm?FQn67$#xE>U$9&;>7* z&XPYzWV zy4ekQVgLU94?gzTW1BZ_+}LY>o<=^E0CIpYhw!)IetPz2CUK-*4Qy>X_+GJH8O9|f&~kNUB>K! zV6P8$c1poL0C>WLiEfmSmU)c^8H6+z;Ev|gjsjS0F0kppnHl8An2dVj<(FSR!uO)- zjH78-C8)}o$Vv0K3fzlK&V(&4Wm2bsH>Qml$l>4q?cd%ud-m-A$bU~y^N&ku;YnfCzS4i^N|t{Sq1E&~vOuYHIZ`fSYW|9%Z&iBcdwNu$5?A-%PeS+dG(my6@*d z|M|lV+T-^5rLo99XiL>gE(KhoDqt^qIn!{@XbiOY1-J|CZ%oIY~H$cYf-#d0Ss_~g+6A7ET(DTu>!WtQsC>uzyXj^zv5W!D{LJo zgY5gYTs=(5TXZoMiaA05<@v8W2kCkMpeS)i`CbmoXm9Knd{>qgrzrg11oeYfD*a^Ip zN(ev;jj90FB6ASczyKMTXe zY&6Z9mKjwJxGGi9RwX896Ttp4OPSP3wmJ>S!IC9Q>OTMZ&##<2ckUM$l&cvyZg`Rp zVgMEz6_bR?jP|@;&1MBK1lku`7$8>-45`2*j)53eEZC=prhQ`P&Yf!?dg!4yxML_4 zi_MqWmejUYLaHifB0T?W_mav71 ztN;d>v5x{+_lJ}MteVIitLaeEIUbtXZ9r*$x@7+qZ9*6Cq?I6OUUH8X6d2 z#9)5_SW56%7Ft0IuzNs0H8eEzJTu6TKJmm8FTMKetGx{5awc)wXQk$G6|_}(%kp*r z*guaC;8F=7NdU(*^6J&An?L*6&%S^1c&)Omg1IBmG1{~2Sd z^^?O!jLWc=OeCFS<7JhG2Q_~Cvcfh{v2=B9(ed;G)eDTHm@4ox) zo%T3MASSb(lIx|uBdVImRe77`odU3b?lk{6LuEiqaWgzf9FOexkP-HY>n2W|Saa~; z!2xEHG18|laUE&kOVgxPpSCLJoxGC(ro=S_RGRQgt`nsLx0LA`q@1r**v8*pD0AE@ zXshy$%lQCcm2yBX2il~BH2QsoWws?jT54OV<5Z<8Z=0M?0EQ$rWjWvmWq#{}xD;4N z)5N8|D|O7OROM}#^9{hhq=9v;0In2h - + - {t('abs_reader_status', { - status: moduleData.status, + {t('abs_reader_lid_status', { + status: moduleData.lidStatus === 'on' ? 'open' : 'closed', })} - + ) } diff --git a/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx b/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx index d37be183b8d..a22c7591360 100644 --- a/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx +++ b/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx @@ -12,6 +12,7 @@ import { } from '@opentrons/components' import { + ABSORBANCE_READER_TYPE, HEATERSHAKER_MODULE_TYPE, MODULE_MODELS_OT2_ONLY, TEMPERATURE_MODULE_TYPE, @@ -119,6 +120,7 @@ export const ModuleOverflowMenu = ( {isFlex && + module.moduleType !== ABSORBANCE_READER_TYPE && !MODULE_MODELS_OT2_ONLY.some( modModel => modModel === module.moduleModel ) ? ( diff --git a/app/src/organisms/ModuleCard/hooks.tsx b/app/src/organisms/ModuleCard/hooks.tsx index b0322e9ebc1..10b0e9fd30b 100644 --- a/app/src/organisms/ModuleCard/hooks.tsx +++ b/app/src/organisms/ModuleCard/hooks.tsx @@ -354,7 +354,14 @@ export function useModuleOverflowMenu( }, }, ], - absorbanceReaderType: [], + absorbanceReaderType: [ + { + setSetting: t('overflow_menu_about'), + isSecondary: false, + menuButtons: [], + onClick: handleAboutClick, + }, + ], } return { diff --git a/app/src/organisms/ModuleCard/index.tsx b/app/src/organisms/ModuleCard/index.tsx index 7b9cce9ea1c..7a4a34bf8ec 100644 --- a/app/src/organisms/ModuleCard/index.tsx +++ b/app/src/organisms/ModuleCard/index.tsx @@ -131,6 +131,7 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { const requireModuleCalibration = isFlex && !MODULE_MODELS_OT2_ONLY.some(modModel => modModel === module.moduleModel) && + module.moduleType !== ABSORBANCE_READER_TYPE && module.moduleOffset?.last_modified == null const isPipetteReady = !Boolean(attachPipetteRequired) && @@ -172,7 +173,7 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { let moduleData: JSX.Element =
    switch (module.moduleType) { - case 'magneticModuleType': { + case MAGNETIC_MODULE_TYPE: { moduleData = ( { break } - case 'temperatureModuleType': { + case TEMPERATURE_MODULE_TYPE: { moduleData = ( { break } - case 'thermocyclerModuleType': { + case THERMOCYCLER_MODULE_TYPE: { moduleData = break } - case 'heaterShakerModuleType': { + case HEATERSHAKER_MODULE_TYPE: { moduleData = ( { break } - case 'absorbanceReaderType': { + case ABSORBANCE_READER_TYPE: { moduleData = break } diff --git a/app/src/organisms/ModuleCard/utils.ts b/app/src/organisms/ModuleCard/utils.ts index f581b379ca6..554ea60ef4b 100644 --- a/app/src/organisms/ModuleCard/utils.ts +++ b/app/src/organisms/ModuleCard/utils.ts @@ -11,6 +11,7 @@ import thermoModuleGen1Opened from '/app/assets/images/thermocycler_open_transpa import heaterShakerModule from '/app/assets/images/heater_shaker_module_transparent.png' import thermoModuleGen2Closed from '/app/assets/images/thermocycler_gen_2_closed.png' import thermoModuleGen2Opened from '/app/assets/images/thermocycler_gen_2_opened.png' +import absorbanceReader from '/app/assets/images/opentrons_plate_reader.png' import type { AttachedModule } from '/app/redux/modules/types' @@ -37,6 +38,8 @@ export function getModuleCardImage(attachedModule: AttachedModule): string { } else { return thermoModuleGen2Opened } + case 'absorbanceReaderV1': + return absorbanceReader // this should never be reached default: return 'unknown module model, this is an error' diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ModuleTable.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ModuleTable.tsx index 5c402706ba7..1a0b93c6f57 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ModuleTable.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupModulesAndDeck/ModuleTable.tsx @@ -15,6 +15,7 @@ import { TYPOGRAPHY, } from '@opentrons/components' import { + ABSORBANCE_READER_TYPE, getCutoutFixturesForModuleModel, getCutoutIdsFromModuleSlotName, getModuleDisplayName, @@ -203,7 +204,8 @@ function ModuleTableItem({ ) } else if ( isModuleReady && - module.attachedModuleMatch?.moduleOffset?.last_modified != null + (module.attachedModuleMatch?.moduleOffset?.last_modified != null || + module.attachedModuleMatch?.moduleType === ABSORBANCE_READER_TYPE) ) { moduleStatus = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/src/hardware-sim/Module/index.tsx b/components/src/hardware-sim/Module/index.tsx index 1c8cc5c1afb..eb1b5b038a1 100644 --- a/components/src/hardware-sim/Module/index.tsx +++ b/components/src/hardware-sim/Module/index.tsx @@ -1,5 +1,6 @@ import type * as React from 'react' import { + ABSORBANCE_READER_TYPE, getModuleType, HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, @@ -25,6 +26,7 @@ import { HeaterShaker } from './HeaterShaker' import { Temperature } from './Temperature' import { MagneticBlock } from './MagneticBlock' import { MagneticModule } from './MagneticModule' +import { PlateReader } from './PlateReader' import type { ModuleDefinition, @@ -144,7 +146,7 @@ export const Module = (props: Props): JSX.Element => { const rotationCenterY = (footprintYDimension ?? yDimension) / 2 const orientationTransform = - orientation === 'left' + orientation === 'left' || moduleType === ABSORBANCE_READER_TYPE ? 'rotate(0, 0, 0)' : `rotate(180, ${rotationCenterX}, ${rotationCenterY})` @@ -206,6 +208,8 @@ export const Module = (props: Props): JSX.Element => { {...(innerProps as React.ComponentProps)} /> ) + } else if (moduleType === ABSORBANCE_READER_TYPE) { + moduleViz = } return ( diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index 502e75cabfe..7b97f52c13c 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -493,11 +493,10 @@ export const ICON_DATA_BY_NAME = { 'M5.78653 2.52836C6.4972 2.18953 7.27458 2.01367 8.06192 2.01367C8.84925 2.01367 9.62664 2.18953 10.3373 2.52836C11.048 2.86719 11.674 3.36046 12.1697 3.97217L10.8101 5.07392C10.4785 4.6647 10.0596 4.33469 9.58417 4.108C9.10871 3.88132 8.58863 3.76367 8.06192 3.76367C7.5352 3.76367 7.01513 3.88132 6.53966 4.108C6.23781 4.25192 5.9588 4.43748 5.71042 4.65852L6.53539 5.48349C6.54967 5.49933 6.55921 5.51885 6.56296 5.53984C6.5667 5.56084 6.56449 5.58246 6.55657 5.60226C6.54865 5.62206 6.53534 5.63923 6.51815 5.65185C6.50096 5.66447 6.48057 5.67202 6.4593 5.67365H3.60663C3.57804 5.67126 3.5514 5.6582 3.53202 5.63705C3.51264 5.61591 3.50195 5.58823 3.50204 5.55954V2.70687C3.50115 2.68556 3.50678 2.6645 3.51821 2.64648C3.52963 2.62847 3.54629 2.61437 3.56594 2.60609C3.5856 2.59781 3.60732 2.59575 3.62819 2.60017C3.64906 2.60459 3.6681 2.61528 3.68272 2.63081L4.47158 3.41968C4.86145 3.05902 5.30451 2.75817 5.78653 2.52836ZM2.07251 8.88416C2.0115 8.09915 2.12692 7.31045 2.41028 6.57585L4.04302 7.20566C3.85361 7.69669 3.77647 8.22387 3.81725 8.74856C3.85803 9.27325 4.0157 9.78218 4.2787 10.2381C4.5417 10.6939 4.90336 11.0852 5.33718 11.3831C5.6143 11.5734 5.91638 11.7226 6.23397 11.8269L6.53545 10.7039C6.54294 10.69 6.55347 10.678 6.56627 10.6687C6.57907 10.6595 6.59381 10.6533 6.60936 10.6505C6.62492 10.6478 6.64089 10.6486 6.65608 10.6529C6.67128 10.6572 6.68529 10.6649 6.69709 10.6754L8.11393 13.1287C8.13198 13.1481 8.14199 13.1735 8.14199 13.2C8.14199 13.2265 8.13198 13.252 8.11393 13.2713L5.66062 14.6881C5.64163 14.6973 5.62047 14.701 5.5995 14.6988C5.57854 14.6967 5.55858 14.6888 5.54184 14.676C5.5251 14.6632 5.51224 14.646 5.50467 14.6263C5.4971 14.6067 5.49515 14.5852 5.49898 14.5645L5.77975 13.5187C5.27106 13.3619 4.78761 13.1286 4.34645 12.8256C3.69741 12.3799 3.15634 11.7946 2.76287 11.1126C2.36941 10.4306 2.13352 9.66917 2.07251 8.88416ZM13.8744 7.44961C13.983 7.95513 14.0175 8.47523 13.9755 8.99344C13.912 9.77747 13.6749 10.5375 13.2815 11.2186C12.8881 11.8997 12.3482 12.4849 11.7008 12.9318C11.0534 13.3786 10.3147 13.6758 9.53836 13.8021L9.25748 12.0748C9.77783 11.9902 10.2729 11.7909 10.7067 11.4915C11.1405 11.1921 11.5024 10.7999 11.7661 10.3434C12.0298 9.88685 12.1886 9.37748 12.2312 8.85205C12.2571 8.53264 12.2396 8.21216 12.18 7.89921L11.0709 8.19352C11.0584 8.20351 11.0441 8.21095 11.0287 8.2154C11.0133 8.21985 10.9973 8.22123 10.9814 8.21946C10.9655 8.2177 10.9501 8.21281 10.9361 8.2051C10.9221 8.19738 10.9097 8.187 10.8997 8.17451C10.8897 8.16202 10.8823 8.14768 10.8779 8.13232C10.8734 8.11696 10.872 8.10088 10.8738 8.08499C10.8756 8.06909 10.8804 8.0537 10.8882 8.03969C10.8959 8.02568 10.9063 8.01335 10.9187 8.00336L12.3356 5.55004C12.3565 5.53133 12.3836 5.521 12.4116 5.521C12.4397 5.521 12.4668 5.53133 12.4877 5.55004L14.941 6.96689C14.9589 6.97676 14.9737 6.99121 14.9841 7.00877C14.9944 7.02632 14.9999 7.04633 14.9999 7.06672C14.9999 7.0871 14.9944 7.10711 14.9841 7.12467C14.9737 7.14222 14.9589 7.1567 14.941 7.16657L13.8744 7.44961Z', viewBox: '0 0 16 16', }, - // TODO (aa, 2024-05-28): Use real path for this icon 'ot-absorbance': { path: - 'M5.78653 2.52836C6.4972 2.18953 7.27458 2.01367 8.06192 2.01367C8.84925 2.01367 9.62664 2.18953 10.3373 2.52836C11.048 2.86719 11.674 3.36046 12.1697 3.97217L10.8101 5.07392C10.4785 4.6647 10.0596 4.33469 9.58417 4.108C9.10871 3.88132 8.58863 3.76367 8.06192 3.76367C7.5352 3.76367 7.01513 3.88132 6.53966 4.108C6.23781 4.25192 5.9588 4.43748 5.71042 4.65852L6.53539 5.48349C6.54967 5.49933 6.55921 5.51885 6.56296 5.53984C6.5667 5.56084 6.56449 5.58246 6.55657 5.60226C6.54865 5.62206 6.53534 5.63923 6.51815 5.65185C6.50096 5.66447 6.48057 5.67202 6.4593 5.67365H3.60663C3.57804 5.67126 3.5514 5.6582 3.53202 5.63705C3.51264 5.61591 3.50195 5.58823 3.50204 5.55954V2.70687C3.50115 2.68556 3.50678 2.6645 3.51821 2.64648C3.52963 2.62847 3.54629 2.61437 3.56594 2.60609C3.5856 2.59781 3.60732 2.59575 3.62819 2.60017C3.64906 2.60459 3.6681 2.61528 3.68272 2.63081L4.47158 3.41968C4.86145 3.05902 5.30451 2.75817 5.78653 2.52836ZM2.07251 8.88416C2.0115 8.09915 2.12692 7.31045 2.41028 6.57585L4.04302 7.20566C3.85361 7.69669 3.77647 8.22387 3.81725 8.74856C3.85803 9.27325 4.0157 9.78218 4.2787 10.2381C4.5417 10.6939 4.90336 11.0852 5.33718 11.3831C5.6143 11.5734 5.91638 11.7226 6.23397 11.8269L6.53545 10.7039C6.54294 10.69 6.55347 10.678 6.56627 10.6687C6.57907 10.6595 6.59381 10.6533 6.60936 10.6505C6.62492 10.6478 6.64089 10.6486 6.65608 10.6529C6.67128 10.6572 6.68529 10.6649 6.69709 10.6754L8.11393 13.1287C8.13198 13.1481 8.14199 13.1735 8.14199 13.2C8.14199 13.2265 8.13198 13.252 8.11393 13.2713L5.66062 14.6881C5.64163 14.6973 5.62047 14.701 5.5995 14.6988C5.57854 14.6967 5.55858 14.6888 5.54184 14.676C5.5251 14.6632 5.51224 14.646 5.50467 14.6263C5.4971 14.6067 5.49515 14.5852 5.49898 14.5645L5.77975 13.5187C5.27106 13.3619 4.78761 13.1286 4.34645 12.8256C3.69741 12.3799 3.15634 11.7946 2.76287 11.1126C2.36941 10.4306 2.13352 9.66917 2.07251 8.88416ZM13.8744 7.44961C13.983 7.95513 14.0175 8.47523 13.9755 8.99344C13.912 9.77747 13.6749 10.5375 13.2815 11.2186C12.8881 11.8997 12.3482 12.4849 11.7008 12.9318C11.0534 13.3786 10.3147 13.6758 9.53836 13.8021L9.25748 12.0748C9.77783 11.9902 10.2729 11.7909 10.7067 11.4915C11.1405 11.1921 11.5024 10.7999 11.7661 10.3434C12.0298 9.88685 12.1886 9.37748 12.2312 8.85205C12.2571 8.53264 12.2396 8.21216 12.18 7.89921L11.0709 8.19352C11.0584 8.20351 11.0441 8.21095 11.0287 8.2154C11.0133 8.21985 10.9973 8.22123 10.9814 8.21946C10.9655 8.2177 10.9501 8.21281 10.9361 8.2051C10.9221 8.19738 10.9097 8.187 10.8997 8.17451C10.8897 8.16202 10.8823 8.14768 10.8779 8.13232C10.8734 8.11696 10.872 8.10088 10.8738 8.08499C10.8756 8.06909 10.8804 8.0537 10.8882 8.03969C10.8959 8.02568 10.9063 8.01335 10.9187 8.00336L12.3356 5.55004C12.3565 5.53133 12.3836 5.521 12.4116 5.521C12.4397 5.521 12.4668 5.53133 12.4877 5.55004L14.941 6.96689C14.9589 6.97676 14.9737 6.99121 14.9841 7.00877C14.9944 7.02632 14.9999 7.04633 14.9999 7.06672C14.9999 7.0871 14.9944 7.10711 14.9841 7.12467C14.9737 7.14222 14.9589 7.1567 14.941 7.16657L13.8744 7.44961Z', - viewBox: '0 0 16 16', + 'M 14.8641 9.1087 L 13.6288 7.9434 C 13.5424 7.861 13.4271 7.8157 13.3076 7.8157 H 2.6882 C 2.5688 7.8157 2.4535 7.861 2.3671 7.9434 L 1.1318 9.1087 C 1.0453 9.191 1 9.2981 1 9.4134 V 14.8322 C 1 15.0504 1.1853 15.2275 1.4159 15.2275 H 2.5194 V 11.8016 C 2.5194 11.5751 2.7047 11.3898 2.9312 11.3898 H 13.0688 C 13.2953 11.3898 13.4806 11.5751 13.4806 11.8016 V 15.2275 H 14.5841 C 14.8147 15.2275 15 15.0504 15 14.8322 V 9.4134 C 15 9.2981 14.9506 9.191 14.8682 9.1087 H 14.8641 Z M 11.7018 13.111 H 4.29 C 4.0626 13.111 3.8782 13.2954 3.8782 13.5228 V 14.7581 C 3.8782 14.9855 4.0626 15.1698 4.29 15.1698 H 11.7018 C 11.9292 15.1698 12.1135 14.9855 12.1135 14.7581 V 13.5228 C 12.1135 13.2954 11.9292 13.111 11.7018 13.111 Z M 5.9328 6.0451 L 5.9328 6.0451 Z M 5.3153 6.0451 C 5.3153 6.0451 5.2906 4.571 4.2035 3.1298 L 5.1918 2.3887 C 6.5341 4.1757 6.5506 5.9669 6.5506 6.0451 H 5.3153 Z M 8.7699 2.7675 H 7.5347 V 6.0534 H 8.7699 V 2.7675 Z M 10.3387 6.0534 L 10.3387 6.0534 Z M 10.9564 6.0575 H 9.7211 C 9.7211 5.9834 9.7376 4.3198 11.0676 2.4175 L 12.0806 3.1257 C 10.9853 4.6904 10.9564 6.0451 10.9564 6.0616 V 6.0575 Z', + viewBox: '0 0 16 17', }, 'ot-toggle-field-off': { path: From 7fb38c98ad0bc7a054225085c0117908af3fb03c Mon Sep 17 00:00:00 2001 From: TamarZanzouri Date: Thu, 10 Oct 2024 18:10:09 -0400 Subject: [PATCH 035/101] fix(app, shared-data): app crashes when using python apiLevel<2.16 with fixed trash (#16451) --- .../hooks/__tests__/useDeckMapUtils.test.ts | 21 ++++---- .../hooks/useDeckMapUtils.ts | 50 ++++++++++++------- .../getFixedTrashLabwareDefinition.test.ts | 12 +++++ .../helpers/getFixedTrashLabwareDefinition.ts | 7 +++ shared-data/js/helpers/index.ts | 1 + 5 files changed, 64 insertions(+), 27 deletions(-) create mode 100644 shared-data/js/helpers/__tests__/getFixedTrashLabwareDefinition.test.ts create mode 100644 shared-data/js/helpers/getFixedTrashLabwareDefinition.ts diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts index 4e341acda99..7e51669bd9a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useDeckMapUtils.test.ts @@ -171,7 +171,6 @@ describe('getRunCurrentModulesInfo', () => { }, } as any const mockDeckDef = {} as any - const mockProtocolAnalysis = {} as any beforeEach(() => { vi.mocked(getLoadedLabwareDefinitionsByUri).mockReturnValue({ @@ -185,7 +184,7 @@ describe('getRunCurrentModulesInfo', () => { const result = getRunCurrentModulesInfo({ runRecord: null as any, deckDef: mockDeckDef, - protocolAnalysis: mockProtocolAnalysis, + labwareDefinitionsByUri: {}, }) expect(result).toEqual([]) @@ -195,7 +194,7 @@ describe('getRunCurrentModulesInfo', () => { const result = getRunCurrentModulesInfo({ runRecord: mockRunRecord, deckDef: mockDeckDef, - protocolAnalysis: null, + labwareDefinitionsByUri: null, }) expect(result).toEqual([]) @@ -205,7 +204,9 @@ describe('getRunCurrentModulesInfo', () => { const result = getRunCurrentModulesInfo({ runRecord: mockRunRecord, deckDef: mockDeckDef, - protocolAnalysis: mockProtocolAnalysis, + labwareDefinitionsByUri: { + 'opentrons/opentrons_96_pcr_adapter/1': 'MOCK_LW_DEF', + } as any, }) expect(result).toEqual([ @@ -226,7 +227,7 @@ describe('getRunCurrentModulesInfo', () => { data: { modules: [mockModule], labware: [] }, }, deckDef: mockDeckDef, - protocolAnalysis: mockProtocolAnalysis, + labwareDefinitionsByUri: {}, }) expect(result).toEqual([ { @@ -245,7 +246,7 @@ describe('getRunCurrentModulesInfo', () => { const result = getRunCurrentModulesInfo({ runRecord: mockRunRecord, deckDef: mockDeckDef, - protocolAnalysis: mockProtocolAnalysis, + labwareDefinitionsByUri: null, }) expect(result).toEqual([]) }) @@ -270,7 +271,7 @@ describe('getRunCurrentLabwareInfo', () => { it('should return an empty array if runRecord is null', () => { const result = getRunCurrentLabwareInfo({ runRecord: undefined, - protocolAnalysis: {} as any, + labwareDefinitionsByUri: {} as any, }) expect(result).toEqual([]) @@ -279,7 +280,7 @@ describe('getRunCurrentLabwareInfo', () => { it('should return an empty array if protocolAnalysis is null', () => { const result = getRunCurrentLabwareInfo({ runRecord: { data: { labware: [] } } as any, - protocolAnalysis: null, + labwareDefinitionsByUri: null, }) expect(result).toEqual([]) @@ -293,7 +294,9 @@ describe('getRunCurrentLabwareInfo', () => { const result = getRunCurrentLabwareInfo({ runRecord: { data: { labware: [mockPickUpTipLwSlotName] } } as any, - protocolAnalysis: { commands: [] } as any, + labwareDefinitionsByUri: { + [mockPickUpTipLabware.definitionUri]: mockLabwareDef, + }, }) expect(result).toEqual([ diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts index 93caceaf4c2..95dac5abdb7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useDeckMapUtils.ts @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { getDeckDefFromRobotType, getLoadedLabwareDefinitionsByUri, + getFixedTrashLabwareDefinition, getModuleDef2, getPositionFromSlotId, getSimplestDeckConfigForProtocol, @@ -20,6 +21,7 @@ import type { CutoutConfigProtocolSpec, LoadedLabware, RobotType, + LabwareDefinitionsByUri, } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '..' import type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils' @@ -50,14 +52,22 @@ export function useDeckMapUtils({ const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) const deckDef = getDeckDefFromRobotType(robotType) + const labwareDefinitionsByUri = useMemo( + () => + protocolAnalysis != null + ? getLoadedLabwareDefinitionsByUri(protocolAnalysis?.commands) + : null, + [protocolAnalysis] + ) + const currentModulesInfo = useMemo( () => getRunCurrentModulesInfo({ runRecord, deckDef, - protocolAnalysis, + labwareDefinitionsByUri, }), - [runRecord, deckDef, protocolAnalysis] + [runRecord, deckDef, labwareDefinitionsByUri] ) const runCurrentModules = useMemo( @@ -70,8 +80,8 @@ export function useDeckMapUtils({ ) const currentLabwareInfo = useMemo( - () => getRunCurrentLabwareInfo({ runRecord, protocolAnalysis }), - [runRecord, protocolAnalysis] + () => getRunCurrentLabwareInfo({ runRecord, labwareDefinitionsByUri }), + [runRecord, labwareDefinitionsByUri] ) const runCurrentLabware = useMemo( @@ -182,13 +192,13 @@ interface RunCurrentModuleInfo { export const getRunCurrentModulesInfo = ({ runRecord, deckDef, - protocolAnalysis, + labwareDefinitionsByUri, }: { - protocolAnalysis: UseDeckMapUtilsProps['protocolAnalysis'] runRecord: UseDeckMapUtilsProps['runRecord'] deckDef: DeckDefinition + labwareDefinitionsByUri?: LabwareDefinitionsByUri | null }): RunCurrentModuleInfo[] => { - if (runRecord == null || protocolAnalysis == null) { + if (runRecord == null || labwareDefinitionsByUri == null) { return [] } else { return runRecord.data.modules.reduce( @@ -203,10 +213,6 @@ export const getRunCurrentModulesInfo = ({ lw.location.moduleId === module.id ) - const labwareDefinitionsByUri = getLoadedLabwareDefinitionsByUri( - protocolAnalysis.commands - ) - const nestedLabwareDef = nestedLabware != null ? labwareDefinitionsByUri[nestedLabware.definitionUri] @@ -249,21 +255,18 @@ interface RunCurrentLabwareInfo { // Derive the labware info necessary to render labware on the deck. export function getRunCurrentLabwareInfo({ runRecord, - protocolAnalysis, + labwareDefinitionsByUri, }: { runRecord: UseDeckMapUtilsProps['runRecord'] - protocolAnalysis: UseDeckMapUtilsProps['protocolAnalysis'] + labwareDefinitionsByUri?: LabwareDefinitionsByUri | null }): RunCurrentLabwareInfo[] { - if (runRecord == null || protocolAnalysis == null) { + if (runRecord == null || labwareDefinitionsByUri == null) { return [] } else { return runRecord.data.labware.reduce((acc: RunCurrentLabwareInfo[], lw) => { const loc = lw.location const [slotName, labwareLocation] = getSlotNameAndLwLocFrom(loc, true) // Exclude modules since handled separately. - const labwareDefinitionsByUri = getLoadedLabwareDefinitionsByUri( - protocolAnalysis.commands - ) - const labwareDef = labwareDefinitionsByUri[lw.definitionUri] + const labwareDef = getLabwareDefinition(lw, labwareDefinitionsByUri) if (slotName == null || labwareLocation == null) { return acc @@ -281,6 +284,17 @@ export function getRunCurrentLabwareInfo({ } } +const getLabwareDefinition = ( + labware: LoadedLabware, + protocolLabwareDefinitionsByUri: LabwareDefinitionsByUri +): LabwareDefinition2 => { + if (labware.id === 'fixedTrash') { + return getFixedTrashLabwareDefinition() + } else { + return protocolLabwareDefinitionsByUri[labware.definitionUri] + } +} + // Get the slotName for on deck labware. export function getSlotNameAndLwLocFrom( location: LabwareLocation | null, diff --git a/shared-data/js/helpers/__tests__/getFixedTrashLabwareDefinition.test.ts b/shared-data/js/helpers/__tests__/getFixedTrashLabwareDefinition.test.ts new file mode 100644 index 00000000000..06131197e07 --- /dev/null +++ b/shared-data/js/helpers/__tests__/getFixedTrashLabwareDefinition.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest' +import { getFixedTrashLabwareDefinition } from '../index' +import type { LabwareDefinition2 } from '../..' +import fixedTrashUncasted from '../../../labware/definitions/2/opentrons_1_trash_3200ml_fixed/1.json' + +describe('getFixedTrashLabwareDefinition', () => { + it(`should return the fixed trash labware defition`, () => { + expect(getFixedTrashLabwareDefinition()).toEqual( + (fixedTrashUncasted as unknown) as LabwareDefinition2 + ) + }) +}) diff --git a/shared-data/js/helpers/getFixedTrashLabwareDefinition.ts b/shared-data/js/helpers/getFixedTrashLabwareDefinition.ts new file mode 100644 index 00000000000..0818827b1b2 --- /dev/null +++ b/shared-data/js/helpers/getFixedTrashLabwareDefinition.ts @@ -0,0 +1,7 @@ +import type { LabwareDefinition2 } from '..' +import fixedTrashUncasted from '../../labware/definitions/2/opentrons_1_trash_3200ml_fixed/1.json' + +export function getFixedTrashLabwareDefinition(): LabwareDefinition2 { + const LabwareDefinition2 = (fixedTrashUncasted as unknown) as LabwareDefinition2 + return LabwareDefinition2 +} diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index 8bf9c6edb45..93c25c78c1a 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -28,6 +28,7 @@ export * from './getModuleVizDims' export * from './getVectorDifference' export * from './getVectorSum' export * from './getLoadedLabwareDefinitionsByUri' +export * from './getFixedTrashLabwareDefinition' export * from './getOccludedSlotCountForModule' export * from './labwareInference' export * from './getAddressableAreasInProtocol' From 7e423880b6fa749a8f4cfba6220f785b1250a8da Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 11 Oct 2024 09:20:37 -0400 Subject: [PATCH 036/101] fix(components): fix disabled button style in PD (#16445) * fix(components): fix disabled button style in PD --- components/src/atoms/buttons/LargeButton.tsx | 274 +++++++++--------- .../CreateNewProtocolWizard/WizardBody.tsx | 12 +- 2 files changed, 138 insertions(+), 148 deletions(-) diff --git a/components/src/atoms/buttons/LargeButton.tsx b/components/src/atoms/buttons/LargeButton.tsx index 28f3f555373..37be7d8c34e 100644 --- a/components/src/atoms/buttons/LargeButton.tsx +++ b/components/src/atoms/buttons/LargeButton.tsx @@ -1,7 +1,7 @@ import type * as React from 'react' import { css } from 'styled-components' -import { Btn } from '../../primitives' +import { Btn } from '../../primitives' import { BORDERS, COLORS } from '../../helix-design-system' import { RESPONSIVENESS, SPACING, TYPOGRAPHY } from '../../ui-style-constants' import { StyledText } from '../StyledText' @@ -15,6 +15,7 @@ import { JUSTIFY_SPACE_BETWEEN, } from '../..' import { Icon } from '../../icons' + import type { StyleProps } from '../../primitives' import type { IconName } from '../../icons' @@ -25,6 +26,101 @@ type LargeButtonTypes = | 'alertStroke' | 'alertAlt' | 'stroke' + +const LARGE_BUTTON_PROPS_BY_TYPE: Record< + LargeButtonTypes, + { + defaultBackgroundColor: string + activeBackgroundColor: string + disabledBackgroundColor: string + defaultColor: string + disabledColor: string + iconColor: string + disabledIconColor: string + focusVisibleOutlineColor: string + focusVisibleBackgroundColor: string + hoverBackgroundColor?: string + hoverColor?: string + activeIconColor?: string + activeColor?: string + } +> = { + secondary: { + defaultColor: COLORS.black90, + disabledColor: COLORS.grey50, + defaultBackgroundColor: COLORS.blue35, + activeBackgroundColor: COLORS.blue40, + disabledBackgroundColor: COLORS.grey35, + iconColor: COLORS.blue50, + disabledIconColor: COLORS.grey50, + focusVisibleOutlineColor: COLORS.blue50, + focusVisibleBackgroundColor: COLORS.blue40, + }, + alert: { + defaultColor: COLORS.red60, + disabledColor: COLORS.grey50, + defaultBackgroundColor: COLORS.red35, + activeBackgroundColor: COLORS.red40, + disabledBackgroundColor: COLORS.grey35, + iconColor: COLORS.red60, + disabledIconColor: COLORS.grey50, + focusVisibleOutlineColor: COLORS.blue50, + focusVisibleBackgroundColor: COLORS.red40, + }, + primary: { + defaultColor: COLORS.white, + disabledColor: COLORS.grey50, + defaultBackgroundColor: COLORS.blue50, + activeBackgroundColor: COLORS.blue60, + disabledBackgroundColor: COLORS.grey35, + iconColor: COLORS.white, + disabledIconColor: COLORS.grey50, + focusVisibleOutlineColor: COLORS.blue55, + focusVisibleBackgroundColor: COLORS.blue55, + hoverBackgroundColor: COLORS.blue55, + hoverColor: COLORS.white, + }, + alertStroke: { + defaultColor: COLORS.white, + disabledColor: COLORS.grey50, + activeColor: COLORS.red60, + defaultBackgroundColor: COLORS.transparent, + activeBackgroundColor: COLORS.red35, + disabledBackgroundColor: COLORS.grey35, + iconColor: COLORS.white, + disabledIconColor: COLORS.grey50, + activeIconColor: COLORS.red60, + focusVisibleOutlineColor: COLORS.blue50, + focusVisibleBackgroundColor: COLORS.red40, + }, + alertAlt: { + defaultColor: COLORS.red50, + disabledColor: COLORS.grey50, + defaultBackgroundColor: COLORS.white, + activeBackgroundColor: COLORS.red35, + disabledBackgroundColor: COLORS.grey35, + iconColor: COLORS.red50, + disabledIconColor: COLORS.grey50, + activeIconColor: COLORS.red60, + activeColor: COLORS.red60, + focusVisibleOutlineColor: COLORS.blue50, + focusVisibleBackgroundColor: COLORS.red40, + }, + stroke: { + defaultColor: COLORS.blue50, + disabledColor: COLORS.grey50, + defaultBackgroundColor: COLORS.white, + activeBackgroundColor: COLORS.white, + disabledBackgroundColor: COLORS.white, + iconColor: COLORS.blue50, + disabledIconColor: COLORS.grey40, + focusVisibleOutlineColor: COLORS.blue55, + focusVisibleBackgroundColor: COLORS.blue55, + hoverBackgroundColor: COLORS.white, + hoverColor: COLORS.blue55, + }, +} + interface LargeButtonProps extends StyleProps { /** used for form submission */ type?: 'submit' @@ -50,109 +146,16 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { const computedDisabled = disabled || ariaDisabled - const LARGE_BUTTON_PROPS_BY_TYPE: Record< - LargeButtonTypes, - { - defaultBackgroundColor: string - activeBackgroundColor: string - disabledBackgroundColor: string - defaultColor: string - disabledColor: string - iconColor: string - disabledIconColor: string - focusVisibleOutlineColor: string - focusVisibleBackgroundColor: string - hoverBackgroundColor?: string - hoverColor?: string - activeIconColor?: string - activeColor?: string - } - > = { - secondary: { - defaultColor: COLORS.black90, - disabledColor: COLORS.grey50, - defaultBackgroundColor: COLORS.blue35, - activeBackgroundColor: COLORS.blue40, - disabledBackgroundColor: COLORS.grey35, - iconColor: COLORS.blue50, - disabledIconColor: COLORS.grey50, - focusVisibleOutlineColor: COLORS.blue50, - focusVisibleBackgroundColor: COLORS.blue40, - }, - alert: { - defaultColor: COLORS.red60, - disabledColor: COLORS.grey50, - defaultBackgroundColor: COLORS.red35, - activeBackgroundColor: COLORS.red40, - disabledBackgroundColor: COLORS.grey35, - iconColor: COLORS.red60, - disabledIconColor: COLORS.grey50, - focusVisibleOutlineColor: COLORS.blue50, - focusVisibleBackgroundColor: COLORS.red40, - }, - primary: { - defaultColor: COLORS.white, - disabledColor: COLORS.grey50, - defaultBackgroundColor: COLORS.blue50, - activeBackgroundColor: COLORS.blue60, - disabledBackgroundColor: COLORS.grey35, - iconColor: COLORS.white, - disabledIconColor: COLORS.grey50, - focusVisibleOutlineColor: COLORS.blue55, - focusVisibleBackgroundColor: COLORS.blue55, - hoverBackgroundColor: COLORS.blue55, - hoverColor: COLORS.white, - }, - alertStroke: { - defaultColor: COLORS.white, - disabledColor: COLORS.grey50, - activeColor: COLORS.red60, - defaultBackgroundColor: COLORS.transparent, - activeBackgroundColor: COLORS.red35, - disabledBackgroundColor: COLORS.grey35, - iconColor: COLORS.white, - disabledIconColor: COLORS.grey50, - activeIconColor: COLORS.red60, - focusVisibleOutlineColor: COLORS.blue50, - focusVisibleBackgroundColor: COLORS.red40, - }, - alertAlt: { - defaultColor: COLORS.red50, - disabledColor: COLORS.grey50, - defaultBackgroundColor: COLORS.white, - activeBackgroundColor: COLORS.red35, - disabledBackgroundColor: COLORS.grey35, - iconColor: COLORS.red50, - disabledIconColor: COLORS.grey50, - activeIconColor: COLORS.red60, - activeColor: COLORS.red60, - focusVisibleOutlineColor: COLORS.blue50, - focusVisibleBackgroundColor: COLORS.red40, - }, - stroke: { - defaultColor: COLORS.blue50, - disabledColor: COLORS.grey50, - defaultBackgroundColor: COLORS.white, - activeBackgroundColor: COLORS.white, - disabledBackgroundColor: COLORS.white, - iconColor: COLORS.blue50, - disabledIconColor: COLORS.grey40, - focusVisibleOutlineColor: COLORS.blue55, - focusVisibleBackgroundColor: COLORS.blue55, - hoverBackgroundColor: COLORS.white, - hoverColor: COLORS.blue55, - }, - } const activeColorFor = ( style: keyof typeof LARGE_BUTTON_PROPS_BY_TYPE ): string => - LARGE_BUTTON_PROPS_BY_TYPE[style].activeColor + LARGE_BUTTON_PROPS_BY_TYPE[style].activeColor != null ? `color: ${LARGE_BUTTON_PROPS_BY_TYPE[style].activeColor}` : '' const activeIconStyle = ( style: keyof typeof LARGE_BUTTON_PROPS_BY_TYPE ): string => - LARGE_BUTTON_PROPS_BY_TYPE[style].activeIconColor + LARGE_BUTTON_PROPS_BY_TYPE[style].activeIconColor != null ? `color: ${LARGE_BUTTON_PROPS_BY_TYPE[style].activeIconColor}` : '' @@ -178,9 +181,8 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { const LARGE_BUTTON_STYLE = css` color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].defaultColor}; - background-color: ${ - LARGE_BUTTON_PROPS_BY_TYPE[buttonType].defaultBackgroundColor - }; + background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] + .defaultBackgroundColor}; cursor: ${CURSOR_POINTER}; padding: ${SPACING.spacing16} ${SPACING.spacing24}; text-align: ${TYPOGRAPHY.textAlignLeft}; @@ -189,9 +191,8 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { border: ${computedBorderStyle()}; &:active { - background-color: ${ - LARGE_BUTTON_PROPS_BY_TYPE[buttonType].activeBackgroundColor - }; + background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] + .activeBackgroundColor}; ${activeColorFor(buttonType)}; } &:active #btn-icon { @@ -200,15 +201,12 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { &:hover { color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].hoverColor}; - background-color: ${ - LARGE_BUTTON_PROPS_BY_TYPE[buttonType].hoverBackgroundColor - }; + background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] + .hoverBackgroundColor}; - border: ${ - buttonType === 'stroke' - ? `2px solid ${COLORS.blue55}` - : `${computedBorderStyle()}` - }; + border: ${buttonType === 'stroke' + ? `2px solid ${COLORS.blue55}` + : `${computedBorderStyle()}`}; } &:focus-visible { @@ -217,16 +215,15 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { &:disabled { color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledColor}; - background-color: ${ - LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor - }; + background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] + .disabledBackgroundColor}; } &[aria-disabled='true'] { color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledColor}; - background-color: ${ - LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor - }; + background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] + .disabledBackgroundColor}; + border: none; } @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { @@ -240,48 +237,41 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { gap: ${SPACING.spacing60}; &:active { - background-color: ${ - computedDisabled - ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor - : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].activeBackgroundColor - }; + background-color: ${computedDisabled + ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor + : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].activeBackgroundColor}; ${!computedDisabled && activeColorFor(buttonType)}; - outline: ${BORDERS.borderRadius4} solid - ${ - computedDisabled - ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor - : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].activeBackgroundColor - }; + outline: 4px solid + ${computedDisabled + ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor + : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].activeBackgroundColor}; } + &:active #btn-icon { ${activeIconStyle(buttonType)}; } &:focus-visible { - background-color: ${ - computedDisabled - ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor - : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].focusVisibleBackgroundColor - }; + background-color: ${computedDisabled + ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor + : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].focusVisibleBackgroundColor}; ${!computedDisabled && activeColorFor(buttonType)}; padding: calc(${SPACING.spacing24} + ${SPACING.spacing2}); border: ${computedBorderStyle()}; - outline: ${ - computedDisabled - ? 'none' - : `3px solid - ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].focusVisibleOutlineColor}` - }; + outline: ${computedDisabled + ? 'none' + : `3px solid + ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].focusVisibleOutlineColor}`}; background-clip: padding-box; box-shadow: none; } &:disabled { color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledColor}; - background-color: ${ - LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor - }; + background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] + .disabledBackgroundColor}; } + } ` const appliedIconColor = computedDisabled @@ -307,7 +297,7 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { > {buttonText} - {iconName ? ( + {iconName != null ? ( Date: Fri, 11 Oct 2024 09:21:35 -0400 Subject: [PATCH 037/101] fix(shared-data): fix well util for partial-column 8-channel (#16458) --- shared-data/js/helpers/__tests__/wellSets.test.ts | 10 +++++----- shared-data/js/helpers/wellSets.ts | 7 ++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/shared-data/js/helpers/__tests__/wellSets.test.ts b/shared-data/js/helpers/__tests__/wellSets.test.ts index 115a4ee8428..78739a91ff2 100644 --- a/shared-data/js/helpers/__tests__/wellSets.test.ts +++ b/shared-data/js/helpers/__tests__/wellSets.test.ts @@ -421,27 +421,27 @@ describe('getWellSetForMultichannel with pipetteNozzleDetails', () => { it('returns partial column for 8-channel pipette with partial column config', () => { const result = getWellSetForMultichannel({ labwareDef: labwareDef, - wellName: 'C1', + wellName: 'G1', channels: 8, pipetteNozzleDetails: { nozzleConfig: 'column', activeNozzleCount: 4, }, }) - expect(result).toEqual(['C1', 'D1', 'E1', 'F1']) + expect(result).toEqual(['D1', 'E1', 'F1', 'G1']) }) it('handles edge cases for 8-channel partial column selection', () => { - const bottomEdgeResult = getWellSetForMultichannel({ + const result = getWellSetForMultichannel({ labwareDef: labwareDef, - wellName: 'G1', + wellName: 'C1', channels: 8, pipetteNozzleDetails: { nozzleConfig: 'column', activeNozzleCount: 4, }, }) - expect(bottomEdgeResult).toEqual(['G1', 'H1']) + expect(result).toEqual(['A1', 'B1', 'C1']) }) it('returns full plate for 96-channel pipette with no config', () => { diff --git a/shared-data/js/helpers/wellSets.ts b/shared-data/js/helpers/wellSets.ts index a896e807c62..b9e2040ea46 100644 --- a/shared-data/js/helpers/wellSets.ts +++ b/shared-data/js/helpers/wellSets.ts @@ -111,6 +111,8 @@ export const makeWellSetHelpers = (): WellSetHelpers => { return wellSetByPrimaryWell } + // TODO(jh 10-10-24): The partial tip logic is strongly coupled to lower-level partial tip API changes. + // Consider alternative methods for deriving well sets when in partial nozzle configurations. const getWellSetForMultichannel = ({ labwareDef, wellName, @@ -142,7 +144,10 @@ export const makeWellSetHelpers = (): WellSetHelpers => { const wellIndex = targetColumn.indexOf(wellName) // If there are fewer wells than active nozzles, only select as many wells as there are nozzles. - return targetColumn.slice(wellIndex, wellIndex + activeNozzleCount) + return targetColumn.slice( + Math.max(wellIndex - activeNozzleCount + 1, 0), + wellIndex + 1 + ) } if (channels === 8) { From d75d42b3a83596c4683c88013844a69c147e116d Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 11 Oct 2024 09:22:40 -0400 Subject: [PATCH 038/101] feat(components): add two icons for PD (#16455) * feat(components): add two icons for PD --- components/src/icons/icon-data.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index 7b97f52c13c..bafc46e94f9 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -192,6 +192,11 @@ export const ICON_DATA_BY_NAME = { 'M6.51799 0.867188H5.48162L5.48162 5.73647L4.03906 4.2939L3.30623 5.02673L5.43529 7.15579C5.74704 7.46754 6.25249 7.46754 6.56424 7.15579L8.6933 5.02673L7.96047 4.2939L6.51799 5.73638L6.51799 0.867188Z M2.03637 8.17387H1V9.81246C1 10.2533 1.35741 10.6107 1.79829 10.6107H10.2013C10.6422 10.6107 10.9996 10.2533 10.9996 9.81246V8.17387H9.96324V9.57437H2.03637V8.17387Z', viewBox: '0 0 12 12', }, + end: { + path: + 'M18.334 15V5H16.6673V15H18.334ZM6.66732 15L7.85482 13.8333L4.85482 10.8333H15.0007V9.16667H4.85482L7.83399 6.16667L6.66732 5L1.66732 10L6.66732 15Z', + viewBox: '0 0 20 20', + }, ethernet: { path: 'M14.1673 10L13.2923 9.125L17.3757 5L13.2923 0.875L14.1673 0L19.1673 5L14.1673 10ZM5.83398 10L0.833984 5L5.83398 0L6.70898 0.875L2.62565 5L6.70898 9.125L5.83398 10ZM6.66732 5.72917C6.47287 5.72917 6.30273 5.65625 6.1569 5.51042C6.01107 5.36458 5.93815 5.19444 5.93815 5C5.93815 4.80556 6.01107 4.63542 6.1569 4.48958C6.30273 4.34375 6.47287 4.27083 6.66732 4.27083C6.86176 4.27083 7.0319 4.34375 7.17773 4.48958C7.32357 4.63542 7.39648 4.80556 7.39648 5C7.39648 5.19444 7.32357 5.36458 7.17773 5.51042C7.0319 5.65625 6.86176 5.72917 6.66732 5.72917ZM10.0007 5.72917C9.80621 5.72917 9.63607 5.65625 9.49023 5.51042C9.3444 5.36458 9.27148 5.19444 9.27148 5C9.27148 4.80556 9.3444 4.63542 9.49023 4.48958C9.63607 4.34375 9.80621 4.27083 10.0007 4.27083C10.1951 4.27083 10.3652 4.34375 10.5111 4.48958C10.6569 4.63542 10.7298 4.80556 10.7298 5C10.7298 5.19444 10.6569 5.36458 10.5111 5.51042C10.3652 5.65625 10.1951 5.72917 10.0007 5.72917ZM13.334 5.72917C13.1395 5.72917 12.9694 5.65625 12.8236 5.51042C12.6777 5.36458 12.6048 5.19444 12.6048 5C12.6048 4.80556 12.6777 4.63542 12.8236 4.48958C12.9694 4.34375 13.1395 4.27083 13.334 4.27083C13.5284 4.27083 13.6986 4.34375 13.8444 4.48958C13.9902 4.63542 14.0632 4.80556 14.0632 5C14.0632 5.19444 13.9902 5.36458 13.8444 5.51042C13.6986 5.65625 13.5284 5.72917 13.334 5.72917Z', @@ -697,6 +702,11 @@ export const ICON_DATA_BY_NAME = { 'M3.5 9.5V11H2C1.5875 11 1.23438 10.8531 0.940625 10.5594C0.646875 10.2656 0.5 9.9125 0.5 9.5V2C0.5 1.5875 0.646875 1.23438 0.940625 0.940625C1.23438 0.646875 1.5875 0.5 2 0.5H9.5C9.9125 0.5 10.2656 0.646875 10.5594 0.940625C10.8531 1.23438 11 1.5875 11 2V3.5H9.5V2H2V9.5H3.5ZM6.5 15.5C6.0875 15.5 5.73438 15.3531 5.44063 15.0594C5.14688 14.7656 5 14.4125 5 14V6.5C5 6.0875 5.14688 5.73438 5.44063 5.44063C5.73438 5.14688 6.0875 5 6.5 5H14C14.4125 5 14.7656 5.14688 15.0594 5.44063C15.3531 5.73438 15.5 6.0875 15.5 6.5V14C15.5 14.4125 15.3531 14.7656 15.0594 15.0594C14.7656 15.3531 14.4125 15.5 14 15.5H6.5ZM6.5 14H14V6.5H6.5V14Z', viewBox: '0 0 16 16', }, + start: { + path: + 'M1.66602 15V5H3.33268V15H1.66602ZM13.3327 15L12.1452 13.8333L15.1452 10.8333H4.99935V9.16667H15.1452L12.166 6.16667L13.3327 5L18.3327 10L13.3327 15Z', + viewBox: '0 0 20 20', + }, 'swap-horizontal': { path: 'M21,9L17,5V8H10V10H17V13M7,11L3,15L7,19V16H14V14H7V11Z', viewBox: '0 0 24 24', From ca6488f43c6201821ed74fc9446231c59ec87aad Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 11 Oct 2024 09:25:47 -0400 Subject: [PATCH 039/101] fix(protocol-designer): remove # from version text (#16463) * fix(protocol-designer): remove # from version text --- protocol-designer/src/__tests__/NavigationBar.test.tsx | 2 +- protocol-designer/src/assets/localization/en/shared.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol-designer/src/__tests__/NavigationBar.test.tsx b/protocol-designer/src/__tests__/NavigationBar.test.tsx index c51e17e03ee..deac271d6d5 100644 --- a/protocol-designer/src/__tests__/NavigationBar.test.tsx +++ b/protocol-designer/src/__tests__/NavigationBar.test.tsx @@ -31,7 +31,7 @@ describe('NavigationBar', () => { render() screen.getByText('Opentrons') screen.getByText('Protocol Designer') - screen.getByText('Version # fake_PD_version') + screen.getByText('Version fake_PD_version') screen.getByText('Create new') screen.getByText('Import') screen.getByText('mock SettingsIcon') diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index fcf63f043da..74e853632fb 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -117,7 +117,7 @@ "trashBin": "Trash Bin", "user_settings": "User settings", "uses_standard_namespace": "Opentrons verified labware", - "version": "Version # {{version}}", + "version": "Version {{version}}", "view_release_notes": "View release notes", "warning": "WARNING:", "wasteChute": "Waste chute", From daf2280f7b3c3c58058fc9d70c5d7758b448bd29 Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Fri, 11 Oct 2024 09:51:06 -0400 Subject: [PATCH 040/101] fix(api,app,shared-data): Fix LPC for protocols using the plate reader module. (#16453) --- .../protocol_engine/commands/load_module.py | 14 +++++++++----- .../LabwarePositionCheck/useLaunchLPC.tsx | 5 ++++- .../utils/getProbeBasedLPCSteps.ts | 7 ++++++- .../1.json | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/load_module.py b/api/src/opentrons/protocol_engine/commands/load_module.py index e9146ccaa62..f8127658ea0 100644 --- a/api/src/opentrons/protocol_engine/commands/load_module.py +++ b/api/src/opentrons/protocol_engine/commands/load_module.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ..errors import ModuleNotLoadedError from ..errors.error_occurrence import ErrorOccurrence from ..types import ( DeckSlotLocation, @@ -159,11 +160,14 @@ async def execute( and params.model == ModuleModel.ABSORBANCE_READER_V1 and params.moduleId is not None ): - abs_reader = self._equipment.get_module_hardware_api( - self._state_view.modules.get_absorbance_reader_substate( - params.moduleId - ).module_id - ) + try: + abs_reader = self._equipment.get_module_hardware_api( + self._state_view.modules.get_absorbance_reader_substate( + params.moduleId + ).module_id + ) + except ModuleNotLoadedError: + abs_reader = None if abs_reader is not None: result = await abs_reader.get_current_lid_status() diff --git a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx index 0ad5ea06a50..695d79a6733 100644 --- a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx +++ b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx @@ -15,7 +15,10 @@ import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/ import type { RobotType } from '@opentrons/shared-data' -const filteredLabware = ['opentrons_tough_pcr_auto_sealing_lid'] +const filteredLabware = [ + 'opentrons_tough_pcr_auto_sealing_lid', + 'opentrons_flex_lid_absorbance_plate_reader_module', +] export function useLaunchLPC( runId: string, diff --git a/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts b/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts index b309703f333..80e2650760c 100644 --- a/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts +++ b/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts @@ -73,7 +73,12 @@ function getAllCheckSectionSteps( [] ) - return labwareLocations.map( + // HACK: Remove LPC for plate reader to unblock science. + const filteredLabwareLocations = labwareLocations.filter(labware => { + return labware.location?.moduleModel !== 'absorbanceReaderV1' + }) + + return filteredLabwareLocations.map( ({ location, labwareId, moduleId, adapterId, definitionUri }) => ({ section: SECTIONS.CHECK_POSITIONS, labwareId: labwareId, diff --git a/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json b/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json index a477991f629..ab6330347d4 100644 --- a/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json +++ b/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json @@ -32,7 +32,7 @@ "namespace": "opentrons", "version": 1, "schemaVersion": 2, - "allowedRoles": ["fixture"], + "allowedRoles": ["fixture", "lid"], "gripForce": 21.0, "gripHeightFromLabwareBottom": 48.0, "cornerOffsetFromSlot": { From 630f086f4b56eb1af74d7ddd7fb8f7685e76f04e Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 11 Oct 2024 09:54:09 -0400 Subject: [PATCH 041/101] fix(protocol-designer): remove opentrons phrase from checkboxes in select pipette screen (#16464) * fix(protocol-designer): remove opentrons phrase from checkboxes in select pipette screen --- .../organisms/EditInstrumentsModal/index.tsx | 13 +----- .../SelectPipettes.tsx | 3 +- .../src/utils/__tests__/utils.test.ts | 46 +++++++++++++++++++ protocol-designer/src/utils/index.ts | 30 ++++++++++++ 4 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 protocol-designer/src/utils/__tests__/utils.test.ts diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx index 6f1729a2110..c00362ae7de 100644 --- a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx +++ b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx @@ -46,7 +46,7 @@ import { getInitialDeckSetup, getPipetteEntities, } from '../../step-forms/selectors' -import { getHas96Channel } from '../../utils' +import { getHas96Channel, removeOpentronsPhrases } from '../../utils' import { changeSavedStepForm } from '../../steplist/actions' import { INITIAL_DECK_SETUP_STEP_ID } from '../../constants' import { PipetteInfoItem } from '../PipetteInfoItem' @@ -67,6 +67,7 @@ import { selectors as stepFormSelectors } from '../../step-forms' import { BUTTON_LINK_STYLE } from '../../atoms' import { getSectionsFromPipetteName } from './utils' import { editPipettes } from './editPipettes' + import type { PipetteMount, PipetteName } from '@opentrons/shared-data' import type { Gen, @@ -144,16 +145,6 @@ export function EditInstrumentsModal( ? getSectionsFromPipetteName(leftPip.name, leftPip.spec) : null - const removeOpentronsPhrases = (input: string): string => { - const phrasesToRemove = ['Opentrons Flex 96', 'Opentrons OT-2 96'] - - return phrasesToRemove - .reduce((text, phrase) => { - return text.replace(new RegExp(phrase, 'gi'), '') - }, input) - .trim() - } - return createPortal( { const updatedValues = selectedValues.includes( value diff --git a/protocol-designer/src/utils/__tests__/utils.test.ts b/protocol-designer/src/utils/__tests__/utils.test.ts new file mode 100644 index 00000000000..460722cdba7 --- /dev/null +++ b/protocol-designer/src/utils/__tests__/utils.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { removeOpentronsPhrases } from '..' + +describe('removeOpentronsPhrases', () => { + it('should remove "Opentrons Flex 96"', () => { + const input = 'This is an Opentrons Flex 96 Tip Rack' + const expectedOutput = 'This is an Tip Rack' + expect(removeOpentronsPhrases(input)).toBe(expectedOutput) + }) + + it('should remove "Opentrons OT-2 96"', () => { + const input = 'This is an Opentrons OT-2 96 Tip Rack' + const expectedOutput = 'This is an Tip Rack' + expect(removeOpentronsPhrases(input)).toBe(expectedOutput) + }) + + it('should remove "(Retired)"', () => { + const input = 'This is a (Retired) Tip Rack' + const expectedOutput = 'This is a Tip Rack' + expect(removeOpentronsPhrases(input)).toBe(expectedOutput) + }) + + it('should remove "96" if it is not the first two characters', () => { + const input = 'This is a Filter 96 Tip Rack' + const expectedOutput = 'This is a Filter Tip Rack' + expect(removeOpentronsPhrases(input)).toBe(expectedOutput) + }) + + it('should remove "96" if it is the first two characters', () => { + const input = '96 Filter Tip Rack' + const expectedOutput = 'Filter Tip Rack' + expect(removeOpentronsPhrases(input)).toBe(expectedOutput) + }) + + it('should handle multiple phrases in the input', () => { + const input = '(Retired) Opentrons Flex 96 and Opentrons OT-2 96 Tip Rack' + const expectedOutput = 'and Tip Rack' + expect(removeOpentronsPhrases(input)).toBe(expectedOutput) + }) + + it('should handle an empty input', () => { + const input = '' + const expectedOutput = '' + expect(removeOpentronsPhrases(input)).toBe(expectedOutput) + }) +}) diff --git a/protocol-designer/src/utils/index.ts b/protocol-designer/src/utils/index.ts index 3366f1c3ccb..73c0a3291fc 100644 --- a/protocol-designer/src/utils/index.ts +++ b/protocol-designer/src/utils/index.ts @@ -248,3 +248,33 @@ export function getMatchingTipLiquidSpecs( return matchingTipLiquidSpecs } + +/** + * Removes specific phrases from the input string. + * + * This function removes the following phrases from the input string: + * - 'Opentrons Flex 96' + * - 'Opentrons OT-2 96' + * - '(Retired)' + * - '96' (only if it is not the first two characters) + * + * @param {string} input - The input string from which phrases will be removed. + * @returns {string} - The modified string with specified phrases removed. + */ +export const removeOpentronsPhrases = (input: string): string => { + const phrasesToRemove = [ + 'Opentrons Flex 96', + 'Opentrons OT-2 96', + '\\(Retired\\)', + '96', + ] + + const updatedText = phrasesToRemove + .reduce((text, phrase) => { + return text.replace(new RegExp(phrase, 'gi'), '') + }, input) + .trim() + .replace(/\s+/g, ' ') + + return updatedText.trim() +} From 1e7577ebb1bdf8f882d92d81e9cf73fc7f6c831b Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 11 Oct 2024 10:10:44 -0400 Subject: [PATCH 042/101] refactor(protocol-designer): export metadata as a component (#16443) * refactor(protocol-designer): export metadata as a component --- .../ProtocolOverview/ProtocolMetadata.tsx | 83 +++++++++++++++++++ .../__tests__/ProtocolMetadata.test.tsx | 72 ++++++++++++++++ .../__tests__/ProtocolOverview.test.tsx | 37 ++------- .../src/pages/ProtocolOverview/index.tsx | 51 ++---------- 4 files changed, 166 insertions(+), 77 deletions(-) create mode 100644 protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx create mode 100644 protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx diff --git a/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx new file mode 100644 index 00000000000..7bb26264b8f --- /dev/null +++ b/protocol-designer/src/pages/ProtocolOverview/ProtocolMetadata.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next' +import { + Flex, + DIRECTION_COLUMN, + SPACING, + JUSTIFY_SPACE_BETWEEN, + TYPOGRAPHY, + StyledText, + ListItem, + ListItemDescriptor, + Btn, +} from '@opentrons/components' + +import { BUTTON_LINK_STYLE } from '../../atoms' + +const REQUIRED_APP_VERSION = '8.0.0' + +type MetadataInfo = Array<{ + author?: string + description?: string | null + created?: string + modified?: string +}> + +interface ProtocolMetadataProps { + setShowEditMetadataModal: (showEditMetadataModal: boolean) => void + metaDataInfo: MetadataInfo +} + +export function ProtocolMetadata({ + setShowEditMetadataModal, + metaDataInfo, +}: ProtocolMetadataProps): JSX.Element { + const { t } = useTranslation('protocol_overview') + + return ( + + + + {t('protocol_metadata')} + + + { + setShowEditMetadataModal(true) + }} + css={BUTTON_LINK_STYLE} + data-testid="ProtocolOverview_MetadataEditButton" + > + + {t('edit')} + + + + + + {metaDataInfo.map(info => { + const [title, value] = Object.entries(info)[0] + + return ( + + + + ) + })} + + + + + + ) +} diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx new file mode 100644 index 00000000000..405bd946279 --- /dev/null +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolMetadata.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' + +import { i18n } from '../../../assets/localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { ProtocolMetadata } from '../ProtocolMetadata' + +import type { ComponentProps } from 'react' + +const mockSetShowEditMetadataModal = vi.fn() +const mockMetaDataInfo = [ + { + description: + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.', + }, + { + author: 'Opentrons', + }, + { + created: 'June 10, 2024', + }, + { + modified: 'September 20, 2024 | 3:44 PM', + }, +] as any + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ProtocolMetadata', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + setShowEditMetadataModal: mockSetShowEditMetadataModal, + metaDataInfo: [], + } + }) + + it('should render text and button', () => { + render(props) + screen.getByText('Protocol Metadata') + screen.getByText('Edit') + screen.getByText('Required app version') + screen.getByText('8.0.0 or higher') + }) + + it('should render protocol metadata', () => { + props = { + ...props, + metaDataInfo: mockMetaDataInfo, + } + render(props) + screen.getByText('Description') + screen.getByText('Organization/Author') + screen.getByText('Date created') + screen.getByText('Last exported') + screen.getByText('Opentrons') + screen.getByText( + 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.' + ) + }) + + it('should call a mock function when clicking edit button', () => { + render(props) + fireEvent.click(screen.getByText('Edit')) + expect(mockSetShowEditMetadataModal).toHaveBeenCalledWith(true) + }) +}) diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx index 1ee9718966b..bd037ba7a8e 100644 --- a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx @@ -2,7 +2,6 @@ import { describe, it, vi, beforeEach, expect } from 'vitest' import { fireEvent, screen } from '@testing-library/react' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' -import { EditProtocolMetadataModal } from '../../../organisms' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../assets/localization' import { getFileMetadata, getRobotType } from '../../../file-data/selectors' @@ -16,6 +15,7 @@ import { selectors as labwareIngredSelectors } from '../../../labware-ingred/sel import { ProtocolOverview } from '../index' import { DeckThumbnail } from '../DeckThumbnail' import { OffDeckThumbnail } from '../OffdeckThumbnail' +import { ProtocolMetadata } from '../ProtocolMetadata' import { InstrumentsInfo } from '../InstrumentsInfo' import { LiquidDefinitions } from '../LiquidDefinitions' @@ -31,6 +31,7 @@ vi.mock('../../../organisms') vi.mock('../../../labware-ingred/selectors') vi.mock('../LiquidDefinitions') vi.mock('../InstrumentsInfo') +vi.mock('../ProtocolMetadata') const mockNavigate = vi.fn() @@ -81,6 +82,9 @@ describe('ProtocolOverview', () => {
    mock LiquidDefinitions
    ) vi.mocked(InstrumentsInfo).mockReturnValue(
    mock InstrumentsInfo
    ) + vi.mocked(ProtocolMetadata).mockReturnValue( +
    mock ProtocolMetadata
    + ) }) it('renders each section with text', () => { @@ -92,16 +96,7 @@ describe('ProtocolOverview', () => { // metadata screen.getByText('mockName') - screen.getByText('Protocol Metadata') - screen.getAllByText('Edit') - screen.getByText('Description') - screen.getByText('mockDescription') - screen.getByText('Organization/Author') - screen.getByText('mockAuthor') - screen.getByText('Date created') - screen.getByText('Last exported') - screen.getByText('Required app version') - screen.getByText('8.0.0 or higher') + screen.getByText('mock ProtocolMetadata') // instruments screen.getByText('mock InstrumentsInfo') @@ -120,17 +115,6 @@ describe('ProtocolOverview', () => { screen.getByText('mock OffdeckThumbnail') }) - it('should render text N/A if there is no data', () => { - vi.mocked(getFileMetadata).mockReturnValue({ - protocolName: undefined, - author: undefined, - description: undefined, - }) - render() - // ToDo (kk: 2024/10/07) this part should be replaced - expect(screen.getAllByText('N/A').length).toBe(4) - }) - it('navigates to starting deck state', () => { render() const button = screen.getByRole('button', { name: 'Edit protocol' }) @@ -143,13 +127,4 @@ describe('ProtocolOverview', () => { fireEvent.click(screen.getByText('Materials list')) screen.getByText('mock MaterialsListModal') }) - - it('renders the edit protocol metadata modal', () => { - vi.mocked(EditProtocolMetadataModal).mockReturnValue( -
    mock EditProtocolMetadataModal
    - ) - render() - fireEvent.click(screen.getByTestId('ProtocolOverview_MetadataEditButton')) - screen.getByText('mock EditProtocolMetadataModal') - }) }) diff --git a/protocol-designer/src/pages/ProtocolOverview/index.tsx b/protocol-designer/src/pages/ProtocolOverview/index.tsx index 9a8dd1b4f4b..a98b2cd3f2d 100644 --- a/protocol-designer/src/pages/ProtocolOverview/index.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/index.tsx @@ -53,6 +53,7 @@ import { import { DeckThumbnail } from './DeckThumbnail' import { OffDeckThumbnail } from './OffdeckThumbnail' import { getWarningContent } from './UnusedModalContent' +import { ProtocolMetadata } from './ProtocolMetadata' import { InstrumentsInfo } from './InstrumentsInfo' import { LiquidDefinitions } from './LiquidDefinitions' @@ -60,7 +61,6 @@ import type { CreateCommand } from '@opentrons/shared-data' import type { DeckSlot } from '@opentrons/step-generation' import type { ThunkDispatch } from '../../types' -const REQUIRED_APP_VERSION = '8.0.0' const DATE_ONLY_FORMAT = 'MMMM dd, yyyy' const DATETIME_FORMAT = 'MMMM dd, yyyy | h:mm a' @@ -334,51 +334,10 @@ export function ProtocolOverview(): JSX.Element { flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing40} > - - - - {t('protocol_metadata')} - - - { - setShowEditMetadataModal(true) - }} - css={BUTTON_LINK_STYLE} - data-testid="ProtocolOverview_MetadataEditButton" - > - - {t('edit')} - - - - - - {metaDataInfo.map(info => { - const [title, value] = Object.entries(info)[0] - - return ( - - - - ) - })} - - - - - + Date: Fri, 11 Oct 2024 10:31:48 -0400 Subject: [PATCH 043/101] refactor(api): Ensure we handle state updates even for failed commands (#16461) --- .../protocol_engine/actions/__init__.py | 3 + .../actions/get_state_update.py | 18 +++ .../protocol_engine/state/labware.py | 67 +++++------ .../protocol_engine/state/pipettes.py | 108 +++++++----------- 4 files changed, 89 insertions(+), 107 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/actions/get_state_update.py diff --git a/api/src/opentrons/protocol_engine/actions/__init__.py b/api/src/opentrons/protocol_engine/actions/__init__.py index ff59548971d..dfd497817c0 100644 --- a/api/src/opentrons/protocol_engine/actions/__init__.py +++ b/api/src/opentrons/protocol_engine/actions/__init__.py @@ -30,6 +30,7 @@ SetPipetteMovementSpeedAction, AddAbsorbanceReaderLidAction, ) +from .get_state_update import get_state_update __all__ = [ # action pipeline interface @@ -61,4 +62,6 @@ # action payload values "PauseSource", "FinishErrorDetails", + # helper functions + "get_state_update", ] diff --git a/api/src/opentrons/protocol_engine/actions/get_state_update.py b/api/src/opentrons/protocol_engine/actions/get_state_update.py new file mode 100644 index 00000000000..e0ddadc3222 --- /dev/null +++ b/api/src/opentrons/protocol_engine/actions/get_state_update.py @@ -0,0 +1,18 @@ +# noqa: D100 + + +from .actions import Action, SucceedCommandAction, FailCommandAction +from ..commands.command import DefinedErrorData +from ..state.update_types import StateUpdate + + +def get_state_update(action: Action) -> StateUpdate | None: + """Extract the StateUpdate from an action, if there is one.""" + if isinstance(action, SucceedCommandAction): + return action.state_update + elif isinstance(action, FailCommandAction) and isinstance( + action.error, DefinedErrorData + ): + return action.error.state_update + else: + return None diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 78f2124bdb4..4614883fa6f 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -48,9 +48,9 @@ ) from ..actions import ( Action, - SucceedCommandAction, AddLabwareOffsetAction, AddLabwareDefinitionAction, + get_state_update, ) from ._abstract_store import HasState, HandlesActions from ._move_types import EdgePathType @@ -64,8 +64,6 @@ "opentrons/usascientific_96_wellplate_2.4ml_deep/1", } -_OT3_INSTRUMENT_ATTACH_SLOT = DeckSlotName.SLOT_D1 - _RIGHT_SIDE_SLOTS = { # OT-2: DeckSlotName.FIXED_TRASH, @@ -148,10 +146,12 @@ def __init__( def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" - if isinstance(action, SucceedCommandAction): - self._handle_command(action) + state_update = get_state_update(action) + if state_update is not None: + self._add_loaded_labware(state_update) + self._set_labware_location(state_update) - elif isinstance(action, AddLabwareOffsetAction): + if isinstance(action, AddLabwareOffsetAction): labware_offset = LabwareOffset.construct( id=action.labware_offset_id, createdAt=action.created_at, @@ -169,11 +169,6 @@ def handle_action(self, action: Action) -> None: ) self._state.definitions_by_uri[uri] = action.definition - def _handle_command(self, action: Action) -> None: - """Modify state in reaction to a command.""" - self._add_loaded_labware(action) - self._set_labware_location(action) - def _add_labware_offset(self, labware_offset: LabwareOffset) -> None: """Add a new labware offset to state. @@ -185,56 +180,50 @@ def _add_labware_offset(self, labware_offset: LabwareOffset) -> None: self._state.labware_offsets_by_id[labware_offset.id] = labware_offset - def _add_loaded_labware(self, action: Action) -> None: - if ( - isinstance(action, SucceedCommandAction) - and action.state_update.loaded_labware != update_types.NO_CHANGE - ): + def _add_loaded_labware(self, state_update: update_types.StateUpdate) -> None: + loaded_labware_update = state_update.loaded_labware + if loaded_labware_update != update_types.NO_CHANGE: # If the labware load refers to an offset, that offset must actually exist. - if action.state_update.loaded_labware.offset_id is not None: + if loaded_labware_update.offset_id is not None: assert ( - action.state_update.loaded_labware.offset_id - in self._state.labware_offsets_by_id + loaded_labware_update.offset_id in self._state.labware_offsets_by_id ) definition_uri = uri_from_details( - namespace=action.state_update.loaded_labware.definition.namespace, - load_name=action.state_update.loaded_labware.definition.parameters.loadName, - version=action.state_update.loaded_labware.definition.version, + namespace=loaded_labware_update.definition.namespace, + load_name=loaded_labware_update.definition.parameters.loadName, + version=loaded_labware_update.definition.version, ) self._state.definitions_by_uri[ definition_uri - ] = action.state_update.loaded_labware.definition + ] = loaded_labware_update.definition - location = action.state_update.loaded_labware.new_location + location = loaded_labware_update.new_location - display_name = action.state_update.loaded_labware.display_name + display_name = loaded_labware_update.display_name self._state.labware_by_id[ - action.state_update.loaded_labware.labware_id + loaded_labware_update.labware_id ] = LoadedLabware.construct( - id=action.state_update.loaded_labware.labware_id, + id=loaded_labware_update.labware_id, location=location, - loadName=action.state_update.loaded_labware.definition.parameters.loadName, + loadName=loaded_labware_update.definition.parameters.loadName, definitionUri=definition_uri, - offsetId=action.state_update.loaded_labware.offset_id, + offsetId=loaded_labware_update.offset_id, displayName=display_name, ) - def _set_labware_location(self, action: Action) -> None: - if ( - isinstance(action, SucceedCommandAction) - and action.state_update.labware_location != update_types.NO_CHANGE - ): - - labware_id = action.state_update.labware_location.labware_id - new_offset_id = action.state_update.labware_location.offset_id + def _set_labware_location(self, state_update: update_types.StateUpdate) -> None: + labware_location_update = state_update.labware_location + if labware_location_update != update_types.NO_CHANGE: + labware_id = labware_location_update.labware_id + new_offset_id = labware_location_update.offset_id self._state.labware_by_id[labware_id].offsetId = new_offset_id - if action.state_update.labware_location.new_location: - new_location = action.state_update.labware_location.new_location + if labware_location_update.new_location: + new_location = labware_location_update.new_location if isinstance( new_location, AddressableAreaLocation diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index e558d3a0fe6..26563b08ced 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -10,9 +10,7 @@ Tuple, Union, ) -from typing_extensions import assert_type -from opentrons_shared_data.errors import EnumeratedError from opentrons_shared_data.pipette import pipette_definition from opentrons.config.defaults_ot2 import Z_RETRACT_DISTANCE from opentrons.hardware_control.dev_types import PipetteDict @@ -21,8 +19,6 @@ NozzleConfigurationType, NozzleMap, ) -from opentrons.protocol_engine.actions.actions import FailCommandAction -from opentrons.protocol_engine.commands.command import DefinedErrorData from opentrons.types import MountType, Mount as HwMount, Point from . import update_types @@ -40,8 +36,10 @@ ) from ..actions import ( Action, + FailCommandAction, SetPipetteMovementSpeedAction, SucceedCommandAction, + get_state_update, ) from ._abstract_store import HasState, HandlesActions @@ -140,37 +138,31 @@ def __init__(self) -> None: def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" + state_update = get_state_update(action) + if state_update is not None: + self._set_load_pipette(state_update) + self._update_current_location(state_update) + self._update_pipette_config(state_update) + self._update_pipette_nozzle_map(state_update) + self._update_tip_state(state_update) + if isinstance(action, (SucceedCommandAction, FailCommandAction)): - self._handle_command(action) + self._update_volumes(action) + elif isinstance(action, SetPipetteMovementSpeedAction): self._state.movement_speed_by_id[action.pipette_id] = action.speed - def _handle_command( - self, action: Union[SucceedCommandAction, FailCommandAction] - ) -> None: - self._set_load_pipette(action) - self._update_current_location(action) - self._update_pipette_config(action) - self._update_pipette_nozzle_map(action) - self._update_tip_state(action) - self._update_volumes(action) - - def _set_load_pipette( - self, action: Union[SucceedCommandAction, FailCommandAction] - ) -> None: - if ( - isinstance(action, SucceedCommandAction) - and action.state_update.loaded_pipette != update_types.NO_CHANGE - ): - pipette_id = action.state_update.loaded_pipette.pipette_id + def _set_load_pipette(self, state_update: update_types.StateUpdate) -> None: + if state_update.loaded_pipette != update_types.NO_CHANGE: + pipette_id = state_update.loaded_pipette.pipette_id self._state.pipettes_by_id[pipette_id] = LoadedPipette( id=pipette_id, - pipetteName=action.state_update.loaded_pipette.pipette_name, - mount=action.state_update.loaded_pipette.mount, + pipetteName=state_update.loaded_pipette.pipette_name, + mount=state_update.loaded_pipette.mount, ) self._state.liquid_presence_detection_by_id[pipette_id] = ( - action.state_update.loaded_pipette.liquid_presence_detection or False + state_update.loaded_pipette.liquid_presence_detection or False ) self._state.aspirated_volume_by_id[pipette_id] = None self._state.movement_speed_by_id[pipette_id] = None @@ -181,17 +173,11 @@ def _set_load_pipette( pipette_id ] = static_config.default_nozzle_map - def _update_tip_state( - self, action: Union[SucceedCommandAction, FailCommandAction] - ) -> None: - - if ( - isinstance(action, SucceedCommandAction) - and action.state_update.pipette_tip_state != update_types.NO_CHANGE - ): - pipette_id = action.state_update.pipette_tip_state.pipette_id - if action.state_update.pipette_tip_state.tip_geometry: - attached_tip = action.state_update.pipette_tip_state.tip_geometry + def _update_tip_state(self, state_update: update_types.StateUpdate) -> None: + if state_update.pipette_tip_state != update_types.NO_CHANGE: + pipette_id = state_update.pipette_tip_state.pipette_id + if state_update.pipette_tip_state.tip_geometry: + attached_tip = state_update.pipette_tip_state.tip_geometry self._state.attached_tip_by_id[pipette_id] = attached_tip self._state.aspirated_volume_by_id[pipette_id] = 0 @@ -220,7 +206,7 @@ def _update_tip_state( ) else: - pipette_id = action.state_update.pipette_tip_state.pipette_id + pipette_id = state_update.pipette_tip_state.pipette_id self._state.aspirated_volume_by_id[pipette_id] = None self._state.attached_tip_by_id[pipette_id] = None @@ -236,17 +222,8 @@ def _update_tip_state( default_dispense=tip_configuration.default_dispense_flowrate.values_by_api_level, ) - def _update_current_location( - self, action: Union[SucceedCommandAction, FailCommandAction] - ) -> None: - if isinstance(action, SucceedCommandAction): - location_update = action.state_update.pipette_location - elif isinstance(action.error, DefinedErrorData): - location_update = action.error.state_update.pipette_location - else: - # The command failed with some undefined error. We have nothing to do. - assert_type(action.error, EnumeratedError) - return + def _update_current_location(self, state_update: update_types.StateUpdate) -> None: + location_update = state_update.pipette_location if location_update is update_types.NO_CHANGE: pass @@ -282,18 +259,13 @@ def _update_current_location( mount=loaded_pipette.mount, deck_point=new_deck_point ) - def _update_pipette_config( - self, action: Union[SucceedCommandAction, FailCommandAction] - ) -> None: - if ( - isinstance(action, SucceedCommandAction) - and action.state_update.pipette_config != update_types.NO_CHANGE - ): - config = action.state_update.pipette_config.config + def _update_pipette_config(self, state_update: update_types.StateUpdate) -> None: + if state_update.pipette_config != update_types.NO_CHANGE: + config = state_update.pipette_config.config self._state.static_config_by_id[ - action.state_update.pipette_config.pipette_id + state_update.pipette_config.pipette_id ] = StaticPipetteConfig( - serial_number=action.state_update.pipette_config.serial_number, + serial_number=state_update.pipette_config.serial_number, model=config.model, display_name=config.display_name, min_volume=config.min_volume, @@ -325,26 +297,26 @@ def _update_pipette_config( lld_settings=config.pipette_lld_settings, ) self._state.flow_rates_by_id[ - action.state_update.pipette_config.pipette_id + state_update.pipette_config.pipette_id ] = config.flow_rates self._state.nozzle_configuration_by_id[ - action.state_update.pipette_config.pipette_id + state_update.pipette_config.pipette_id ] = config.nozzle_map def _update_pipette_nozzle_map( - self, action: Union[SucceedCommandAction, FailCommandAction] + self, state_update: update_types.StateUpdate ) -> None: - if ( - isinstance(action, SucceedCommandAction) - and action.state_update.pipette_nozzle_map != update_types.NO_CHANGE - ): + if state_update.pipette_nozzle_map != update_types.NO_CHANGE: self._state.nozzle_configuration_by_id[ - action.state_update.pipette_nozzle_map.pipette_id - ] = action.state_update.pipette_nozzle_map.nozzle_map + state_update.pipette_nozzle_map.pipette_id + ] = state_update.pipette_nozzle_map.nozzle_map def _update_volumes( self, action: Union[SucceedCommandAction, FailCommandAction] ) -> None: + # todo(mm, 2024-10-10): Port these isinstance checks to StateUpdate. + # https://opentrons.atlassian.net/browse/EXEC-754 + if isinstance(action, SucceedCommandAction) and isinstance( action.command.result, (commands.AspirateResult, commands.AspirateInPlaceResult), From f479efce965e6dbb725303f504550734bf78c1fd Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 11 Oct 2024 10:45:29 -0400 Subject: [PATCH 044/101] fix(protocol-designer): fix snackbar display issue in select a pipette (#16465) * fix(protocol-designer): fix snackbar display issue in select a pipette --- .../SelectPipettes.tsx | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx index 5f4dd920b78..f15a716c617 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx @@ -337,22 +337,29 @@ export function SelectPipettes(props: WizardTileProps): JSX.Element | null { isChecked={selectedValues.includes(value)} labelText={removeOpentronsPhrases(name)} onClick={() => { - const updatedValues = selectedValues.includes( + const isCurrentlySelected = selectedValues.includes( value ) - ? selectedValues.filter(v => v !== value) - : [...selectedValues, value] - setValue( - `pipettesByMount.${defaultMount}.tiprackDefURI`, - updatedValues.slice(0, 3) - ) - if ( - selectedValues.length === - MAX_TIPRACKS_ALLOWED - ) { - makeSnackbar( - t('up_to_3_tipracks') as string + + if (isCurrentlySelected) { + setValue( + `pipettesByMount.${defaultMount}.tiprackDefURI`, + selectedValues.filter(v => v !== value) ) + } else { + if ( + selectedValues.length === + MAX_TIPRACKS_ALLOWED + ) { + makeSnackbar( + t('up_to_3_tipracks') as string + ) + } else { + setValue( + `pipettesByMount.${defaultMount}.tiprackDefURI`, + [...selectedValues, value] + ) + } } }} /> From ecd7cc62c2a84b03f171b74882a275c3eeb34b25 Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 11 Oct 2024 12:53:29 -0400 Subject: [PATCH 045/101] fix(protocol-designer): fix pipette type button display condition in EditInstrumentsModal (#16438) * fix(protocol-designer): fix pipette type button display condition in EditInstrumentsModal --- .../localization/en/create_new_protocol.json | 3 +- .../src/assets/localization/en/shared.json | 1 + .../__tests__/utils.test.ts | 106 +++++++++++++++++ .../organisms/EditInstrumentsModal/index.tsx | 110 +++++++++--------- .../organisms/EditInstrumentsModal/utils.ts | 36 +++++- .../__tests__/PipetteInfoItem.test.tsx | 4 +- .../src/organisms/PipetteInfoItem/index.tsx | 5 +- 7 files changed, 206 insertions(+), 59 deletions(-) create mode 100644 protocol-designer/src/organisms/EditInstrumentsModal/__tests__/utils.test.ts diff --git a/protocol-designer/src/assets/localization/en/create_new_protocol.json b/protocol-designer/src/assets/localization/en/create_new_protocol.json index b75005152d7..1b9d1f72898 100644 --- a/protocol-designer/src/assets/localization/en/create_new_protocol.json +++ b/protocol-designer/src/assets/localization/en/create_new_protocol.json @@ -13,14 +13,15 @@ "incompatible_tip_body": "Using a pipette with an incompatible tip rack may result reduce pipette accuracy and collisions. We strongly recommend that you do not pair a pipette with an incompatible tip rack.", "incompatible_tips": "Incompatible tips", "labware_name": "Labware name", + "left_right": "Left+Right", "modules_added": "Modules added", "name": "Name", "need_gripper": "Do you want to move labware automatically with the gripper?", + "pip": "{{mount}} Pipette", "pipette_gen": "Pipette generation", "pipette_tips": "Pipette tips", "pipette_type": "Pipette type", "pipette_vol": "Pipette volume", - "pip": "{{mount}} pipette", "quantity": "Quantity", "remove": "Remove", "rename_error": "Labware names must be 115 characters or fewer.", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 74e853632fb..382314e4052 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -3,6 +3,7 @@ "amount": "Amount:", "app_settings": "App settings", "ask_for_labware_overwrite": "Duplicate labware name", + "back": "Back", "cancel": "Cancel", "close": "Close", "confirm_import": "Are you sure you want to upload this protocol?", diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/utils.test.ts b/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/utils.test.ts new file mode 100644 index 00000000000..e46af101944 --- /dev/null +++ b/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/utils.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { getShouldShowPipetteType } from '../utils' + +const mockLeftPipette = { + mount: 'left', + id: 'mockLeft', + name: 'p50_single_flex', +} as any +const mockRightPiette = { + mount: 'right', + id: 'mockRight', + name: 'p50_multi_flex', +} as any + +describe('getShouldShowPipetteType', () => { + it('should always show 1-Channel and 8-Channel pipettes', () => { + const scenarios = [ + { + has96Channel: false, + leftPipette: null, + rightPipette: null, + currentEditingMount: null, + }, + { + has96Channel: true, + leftPipette: mockLeftPipette, + rightPipette: mockRightPiette, + currentEditingMount: 'left' as any, + }, + ] + + scenarios.forEach(scenario => { + expect( + getShouldShowPipetteType( + 'single', + scenario.has96Channel, + scenario.leftPipette, + scenario.rightPipette, + scenario.currentEditingMount + ) + ).toBe(true) + expect( + getShouldShowPipetteType( + 'multi', + scenario.has96Channel, + scenario.leftPipette, + scenario.rightPipette, + scenario.currentEditingMount + ) + ).toBe(true) + }) + }) + + it('should not show 96-Channel when has96Channel is true', () => { + expect(getShouldShowPipetteType('96', true, null, null, null)).toBe(false) + expect( + getShouldShowPipetteType('96', true, mockLeftPipette, null, 'right') + ).toBe(false) + }) + + it('should show 96-Channel when adding a new pipette and both mounts are empty', () => { + expect(getShouldShowPipetteType('96', false, null, null, null)).toBe(true) + }) + + it('should not show 96-Channel when adding a new pipette and one mount is occupied', () => { + expect( + getShouldShowPipetteType('96', false, mockLeftPipette, null, null) + ).toBe(false) + expect( + getShouldShowPipetteType('96', false, null, mockRightPiette, null) + ).toBe(false) + }) + + it('should show 96-Channel when editing left mount and right is empty', () => { + expect( + getShouldShowPipetteType('96', false, mockLeftPipette, null, 'left') + ).toBe(true) + }) + + it('should show 96-Channel when editing right mount and left is empty', () => { + expect( + getShouldShowPipetteType('96', false, null, mockRightPiette, 'right') + ).toBe(true) + }) + + it('should not show 96-Channel when editing a mount and the other is occupied', () => { + expect( + getShouldShowPipetteType( + '96', + false, + mockLeftPipette, + mockRightPiette, + 'left' + ) + ).toBe(false) + expect( + getShouldShowPipetteType( + '96', + false, + mockLeftPipette, + mockRightPiette, + 'right' + ) + ).toBe(false) + }) +}) diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx index c00362ae7de..90ff62c327d 100644 --- a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx +++ b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' import styled, { css } from 'styled-components' import mapValues from 'lodash/mapValues' + import { ALIGN_CENTER, ALIGN_STRETCH, @@ -17,8 +18,8 @@ import { DISPLAY_FLEX, DISPLAY_INLINE_BLOCK, EmptySelectorButton, - Flex, FLEX_MAX_CONTENT, + Flex, Icon, JUSTIFY_END, JUSTIFY_SPACE_BETWEEN, @@ -36,9 +37,10 @@ import { } from '@opentrons/components' import { FLEX_ROBOT_TYPE, - OT2_ROBOT_TYPE, getAllPipetteNames, + OT2_ROBOT_TYPE, } from '@opentrons/shared-data' + import { getTopPortalEl } from '../../components/portals/TopPortal' import { getAllowAllTipracks } from '../../feature-flags/selectors' import { @@ -65,7 +67,7 @@ import { createCustomTiprackDef } from '../../labware-defs/actions' import { deleteContainer } from '../../labware-ingred/actions' import { selectors as stepFormSelectors } from '../../step-forms' import { BUTTON_LINK_STYLE } from '../../atoms' -import { getSectionsFromPipetteName } from './utils' +import { getSectionsFromPipetteName, getShouldShowPipetteType } from './utils' import { editPipettes } from './editPipettes' import type { PipetteMount, PipetteName } from '@opentrons/shared-data' @@ -108,8 +110,8 @@ export function EditInstrumentsModal( const { pipettes, labware } = initialDeckSetup const pipettesOnDeck = Object.values(pipettes) const has96Channel = getHas96Channel(pipetteEntities) - const leftPip = pipettesOnDeck.find(pip => pip.mount === 'left') - const rightPip = pipettesOnDeck.find(pip => pip.mount === 'right') + const leftPipette = pipettesOnDeck.find(pipette => pipette.mount === 'left') + const rightPipette = pipettesOnDeck.find(pipette => pipette.mount === 'right') const gripper = Object.values(additionalEquipment).find( ae => ae.name === 'gripper' ) @@ -131,20 +133,24 @@ export function EditInstrumentsModal( const previousLeftPipetteTipracks = Object.values(labware) .filter(lw => lw.def.parameters.isTiprack) - .filter(tip => leftPip?.tiprackDefURI.includes(tip.labwareDefURI)) + .filter(tip => leftPipette?.tiprackDefURI.includes(tip.labwareDefURI)) const previousRightPipetteTipracks = Object.values(labware) .filter(lw => lw.def.parameters.isTiprack) - .filter(tip => rightPip?.tiprackDefURI.includes(tip.labwareDefURI)) + .filter(tip => rightPipette?.tiprackDefURI.includes(tip.labwareDefURI)) const rightInfo = - rightPip != null - ? getSectionsFromPipetteName(rightPip.name, rightPip.spec) + rightPipette != null + ? getSectionsFromPipetteName(rightPipette.name, rightPipette.spec) : null const leftInfo = - leftPip != null - ? getSectionsFromPipetteName(leftPip.name, leftPip.spec) + leftPipette != null + ? getSectionsFromPipetteName(leftPipette.name, leftPipette.spec) : null + // Note (kk:2024/10/09) + // if a user removes all pipettes, left mount is the first target. + const targetPipetteMount = leftPipette == null ? 'left' : 'right' + return createPortal( - {page === 'overview' ? null : ( - { + { + if (page === 'overview') { + onClose() + } else { setPage('overview') resetFields() - }} - > - {t('shared:cancel')} - - )} + } + }} + > + {page === 'overview' ? t('shared:cancel') : t('shared:back')} + - {t(page === 'overview' ? 'shared:close' : 'shared:save')} + {t('shared:save')}
    } @@ -215,7 +223,8 @@ export function EditInstrumentsModal( {t('your_pipettes')} - {has96Channel ? null : ( + {has96Channel || + (leftPipette == null && rightPipette == null) ? null : ( @@ -243,67 +252,54 @@ export function EditInstrumentsModal( )} - {leftPip != null && - leftPip.tiprackDefURI != null && - leftInfo != null ? ( + {leftPipette?.tiprackDefURI != null && leftInfo != null ? ( { setPage('add') setMount('left') setPipetteType(leftInfo.type) setPipetteGen(leftInfo.gen) setPipetteVolume(leftInfo.volume) - setSelectedTips(leftPip.tiprackDefURI) + setSelectedTips(leftPipette.tiprackDefURI as string[]) }} cleanForm={() => { - dispatch(deletePipettes([leftPip.id])) + dispatch(deletePipettes([leftPipette.id as string])) previousLeftPipetteTipracks.forEach(tip => dispatch(deleteContainer({ labwareId: tip.id })) ) }} /> - ) : ( - { - setPage('add') - setMount('left') - resetFields() - }} - text={t('add_pipette')} - textAlignment="left" - iconName="plus" - /> - )} - {rightPip != null && - rightPip.tiprackDefURI != null && - rightInfo != null ? ( + ) : null} + {rightPipette?.tiprackDefURI != null && rightInfo != null ? ( { setPage('add') setMount('right') setPipetteType(rightInfo.type) setPipetteGen(rightInfo.gen) setPipetteVolume(rightInfo.volume) - setSelectedTips(rightPip.tiprackDefURI) + setSelectedTips(rightPipette.tiprackDefURI as string[]) }} cleanForm={() => { - dispatch(deletePipettes([rightPip.id])) + dispatch(deletePipettes([rightPipette.id as string])) previousRightPipetteTipracks.forEach(tip => dispatch(deleteContainer({ labwareId: tip.id })) ) }} /> - ) : has96Channel ? null : ( + ) : null} + {has96Channel || + (leftPipette != null && rightPipette != null) ? null : ( { setPage('add') - setMount('right') + setMount(targetPipetteMount) }} text={t('add_pipette')} textAlignment="left" @@ -347,6 +343,8 @@ export function EditInstrumentsModal( { dispatch(toggleIsGripperRequired()) }} @@ -383,7 +381,13 @@ export function EditInstrumentsModal( {PIPETTE_TYPES[robotType].map(type => { - return type.value === '96' && has96Channel ? null : ( + return getShouldShowPipetteType( + type.value as PipetteType, + has96Channel, + leftPipette, + rightPipette, + mount + ) ? ( { @@ -396,7 +400,7 @@ export function EditInstrumentsModal( buttonValue="single" isSelected={pipetteType === type.value} /> - ) + ) : null })} diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/utils.ts b/protocol-designer/src/organisms/EditInstrumentsModal/utils.ts index 81f810e2a70..91b0d48d666 100644 --- a/protocol-designer/src/organisms/EditInstrumentsModal/utils.ts +++ b/protocol-designer/src/organisms/EditInstrumentsModal/utils.ts @@ -1,8 +1,13 @@ -import type { PipetteName, PipetteV2Specs } from '@opentrons/shared-data' +import type { + PipetteName, + PipetteV2Specs, + PipetteMount, +} from '@opentrons/shared-data' import type { Gen, PipetteType, } from '../../pages/CreateNewProtocolWizard/types' +import type { PipetteOnDeck } from '../../step-forms' export interface PipetteSections { type: PipetteType @@ -28,3 +33,32 @@ export const getSectionsFromPipetteName = ( volume, } } + +export const getShouldShowPipetteType = ( + type: PipetteType, + has96Channel: boolean, + leftPipette?: PipetteOnDeck | null, + rightPipette?: PipetteOnDeck | null, + currentEditingMount?: PipetteMount | null +): boolean => { + if (type === '96') { + // if a protocol has 96-Channel, no 96-Channel button + if (has96Channel) { + return false + } + + // If no mount is being edited (adding a new pipette) + if (currentEditingMount == null) { + // Only show if both mounts are empty + return leftPipette == null && rightPipette == null + } + + // Only show if the opposite mount of the one being edited is empty + return currentEditingMount === 'left' + ? rightPipette == null + : leftPipette == null + } + + // Always show 1-Channel and Multi-Channel options + return true +} diff --git a/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx b/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx index 5b6f51e414c..53ab36986ea 100644 --- a/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx +++ b/protocol-designer/src/organisms/PipetteInfoItem/__tests__/PipetteInfoItem.test.tsx @@ -34,7 +34,7 @@ describe('PipetteInfoItem', () => { it('renders pipette with edit and remove buttons', () => { render(props) screen.getByText('P1000 Single-Channel GEN1') - screen.getByText('Left pipette') + screen.getByText('Left Pipette') screen.getByText('mock display name') fireEvent.click(screen.getByText('Edit')) expect(props.editClick).toHaveBeenCalled() @@ -49,7 +49,7 @@ describe('PipetteInfoItem', () => { } render(props) screen.getByText('P1000 Single-Channel GEN1') - screen.getByText('Right pipette') + screen.getByText('Right Pipette') screen.getByText('mock display name') fireEvent.click(screen.getByText('Edit')) expect(props.editClick).toHaveBeenCalled() diff --git a/protocol-designer/src/organisms/PipetteInfoItem/index.tsx b/protocol-designer/src/organisms/PipetteInfoItem/index.tsx index 220b08cb823..97ffe27f8cd 100644 --- a/protocol-designer/src/organisms/PipetteInfoItem/index.tsx +++ b/protocol-designer/src/organisms/PipetteInfoItem/index.tsx @@ -29,6 +29,7 @@ export function PipetteInfoItem(props: PipetteInfoItemProps): JSX.Element { const { t, i18n } = useTranslation('create_new_protocol') const allLabware = useSelector(getLabwareDefsByURI) const is96Channel = pipetteName === 'p1000_96' + return ( {i18n.format( t('pip', { - mount: is96Channel ? t('left_right') : t(`${mount}`), + mount: is96Channel ? t('left_right') : mount, }), - 'capitalize' + 'titleCase' )} From 9e5a3446fb1d4a85641769a2d2a1952a11295708 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 11 Oct 2024 13:13:26 -0400 Subject: [PATCH 046/101] feat(app): add "fixed trash" as a predefined drop tip location on OT-2 (#16467) Closes RQA-3286 Adds "fixed trash" as a predefined drop tip location for OT-2s during drop tip wizard. --- .../localization/en/drop_tip_wizard.json | 1 + .../__tests__/useDropTipLocations.test.ts | 22 ++++++++++++++++--- .../hooks/useDropTipLocations.ts | 22 ++++++++++++++----- .../steps/ChooseLocation.tsx | 3 +++ app/src/organisms/DropTipWizardFlows/types.ts | 1 + 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/app/src/assets/localization/en/drop_tip_wizard.json b/app/src/assets/localization/en/drop_tip_wizard.json index a03827ae2af..420e2972dcf 100644 --- a/app/src/assets/localization/en/drop_tip_wizard.json +++ b/app/src/assets/localization/en/drop_tip_wizard.json @@ -18,6 +18,7 @@ "error_dropping_tips": "Error dropping tips", "exit": "Exit", "exit_and_home_pipette": "Exit and home pipette", + "fixed_trash_in_12": "Fixed trash in 12", "getting_ready": "Getting ready…", "go_back": "Go back", "jog_too_far": "Jog too far?", diff --git a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipLocations.test.ts b/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipLocations.test.ts index ef52d17efed..9195ebf8afd 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipLocations.test.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useDropTipLocations.test.ts @@ -12,9 +12,11 @@ const TRASH_BIN_CUTOUT = 'cutoutA2' const WASTE_CHUTE_CUTOUT = 'cutoutA3' const SLOT_A2 = 'A2' const SLOT_A3 = 'A3' +const SLOT_FIXED_TRASH = 'fixedTrash' const CHOOSE_DECK_LOCATION = 'CHOOSE_DECK_LOCATION' const TRASH_BIN_LOCATION = 'trash-bin' const WASTE_CHUTE_LOCATION = 'waste-chute' +const FIXED_TRASH_LOCATION = 'fixed-trash' const DECK_LOCATION = 'deck' vi.mock('/app/resources/deck_configuration') @@ -39,6 +41,7 @@ describe('useDropTipLocations', () => { const { result } = renderHook(() => useDropTipLocations(OT2_ROBOT_TYPE)) expect(result.current).toEqual([ + { location: FIXED_TRASH_LOCATION, slotName: SLOT_FIXED_TRASH }, { location: DECK_LOCATION, slotName: CHOOSE_DECK_LOCATION }, ]) }) @@ -63,25 +66,38 @@ describe('useDropTipLocations', () => { ]) }) - it('should handle an empty deck configuration', () => { + it('should handle an empty deck configuration for a Flex', () => { vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ data: [], } as any) - const { result } = renderHook(() => useDropTipLocations(OT2_ROBOT_TYPE)) + const { result } = renderHook(() => useDropTipLocations(FLEX_ROBOT_TYPE)) expect(result.current).toEqual([ { location: DECK_LOCATION, slotName: CHOOSE_DECK_LOCATION }, ]) }) - it('should handle an undefined deck configuration', () => { + it('should handle an undefined deck configuration for an OT-2', () => { vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ data: undefined, } as any) const { result } = renderHook(() => useDropTipLocations(OT2_ROBOT_TYPE)) + expect(result.current).toEqual([ + { location: FIXED_TRASH_LOCATION, slotName: SLOT_FIXED_TRASH }, + { location: DECK_LOCATION, slotName: CHOOSE_DECK_LOCATION }, + ]) + }) + + it('should handle an undefined deck configuration for a Flex', () => { + vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ + data: undefined, + } as any) + + const { result } = renderHook(() => useDropTipLocations(FLEX_ROBOT_TYPE)) + expect(result.current).toEqual([ { location: DECK_LOCATION, slotName: CHOOSE_DECK_LOCATION }, ]) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipLocations.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipLocations.ts index 62e4272449f..d482d8fca0c 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipLocations.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipLocations.ts @@ -70,10 +70,22 @@ export function useDropTipLocations( ) .map(config => createLocation(config, validLocation)) - return [ - ...filterAndMap(TRASH_BIN_ADAPTER_FIXTURE, 'trash-bin'), - ...filterAndMap(WASTE_CHUTE_FIXTURES, 'waste-chute'), - { location: 'deck', slotName: 'CHOOSE_DECK_LOCATION' }, - ] + return robotType === OT2_ROBOT_TYPE + ? [FIXED_TRASH_LOCATION, CHOOSE_DECK_LOCATION] + : [ + ...filterAndMap(TRASH_BIN_ADAPTER_FIXTURE, 'trash-bin'), + ...filterAndMap(WASTE_CHUTE_FIXTURES, 'waste-chute'), + CHOOSE_DECK_LOCATION, + ] }, [deckConfig, deckDef, robotType]) } + +const FIXED_TRASH_LOCATION: DropTipBlowoutLocationDetails = { + location: 'fixed-trash', + slotName: 'fixedTrash', +} + +const CHOOSE_DECK_LOCATION: DropTipBlowoutLocationDetails = { + location: 'deck', + slotName: 'CHOOSE_DECK_LOCATION', +} diff --git a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx index 6bf0dbbfed5..e53f0006bf0 100644 --- a/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx +++ b/app/src/organisms/DropTipWizardFlows/steps/ChooseLocation.tsx @@ -76,6 +76,8 @@ export function ChooseLocation({ return t('trash_bin_in_slot', { slot: slotName }) case 'waste-chute': return t('waste_chute_in_slot', { slot: slotName }) + case 'fixed-trash': + return t('fixed_trash_in_12') case 'deck': return t('choose_deck_location') default: @@ -115,6 +117,7 @@ export function ChooseLocation({ case 'labware': case 'trash-bin': case 'waste-chute': + case 'fixed-trash': executeCommands() break default: diff --git a/app/src/organisms/DropTipWizardFlows/types.ts b/app/src/organisms/DropTipWizardFlows/types.ts index 513c3277ab5..a9538730b90 100644 --- a/app/src/organisms/DropTipWizardFlows/types.ts +++ b/app/src/organisms/DropTipWizardFlows/types.ts @@ -65,6 +65,7 @@ export type DropTipWizardContainerProps = DropTipWizardProps & { */ export type ValidDropTipBlowoutLocation = | 'trash-bin' + | 'fixed-trash' | 'waste-chute' | 'labware' | 'deck' From 4f8d4d7ffe831b87f48147ac79224ddaba74205c Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 11 Oct 2024 13:16:27 -0400 Subject: [PATCH 047/101] fix(app): do not load pipette during error recovery (#16466) Closes EXEC-760 The loadPipette command is important for executing commands associated with a specific pipette, but it also resets internal state, which can leads to bugs like the ones in the linked ticket. If the pipette has been loaded earlier (ie, during error recovery, synonymous with fixit command flows), we should not load the pipette. We still load the pipette during maintenance runs (drop tip wizard when not in error recovery). --- .../hooks/useDropTipWithType.ts | 33 ++----------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType.ts index df783ca901f..92ff68fb763 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType.ts @@ -1,19 +1,15 @@ // This is the main unifying function for maintenanceRun and fixit type flows. -import { useState, useEffect } from 'react' +import { useState } from 'react' import { useDropTipCommandErrors } from '.' import { useDropTipMaintenanceRun } from './useDropTipMaintenanceRun' import { useDropTipCreateCommands } from './useDropTipCreateCommands' -import { - useDropTipCommands, - buildLoadPipetteCommand, -} from './useDropTipCommands' +import { useDropTipCommands } from './useDropTipCommands' import type { SetRobotErrorDetailsParams } from '.' import type { UseDropTipCommandsResult } from './useDropTipCommands' import type { ErrorDetails, IssuedCommandsType } from '../types' import type { DropTipWizardFlowsProps } from '..' -import type { UseDropTipCreateCommandsResult } from './useDropTipCreateCommands' export type UseDTWithTypeParams = DropTipWizardFlowsProps & { issuedCommandsType: IssuedCommandsType @@ -62,8 +58,6 @@ export function useDropTipWithType( fixitCommandTypeUtils, }) - useRegisterPipetteFixitType({ ...params, ...dtCreateCommandUtils }) - return { activeMaintenanceRunId, errorDetails, @@ -106,26 +100,3 @@ function useIsExitingDT( return { isExiting: isExitingIfNotFixit, toggleIsExiting } } - -type UseRegisterPipetteFixitType = UseDTWithTypeParams & - UseDropTipCreateCommandsResult - -// On mount, if fixit command type, load the managed pipette ID for use in DT Wiz. -function useRegisterPipetteFixitType({ - mount, - instrumentModelSpecs, - issuedCommandsType, - chainRunCommands, - fixitCommandTypeUtils, -}: UseRegisterPipetteFixitType): void { - useEffect(() => { - if (issuedCommandsType === 'fixit') { - const command = buildLoadPipetteCommand( - instrumentModelSpecs.name, - mount, - fixitCommandTypeUtils?.pipetteId - ) - void chainRunCommands([command], true) - } - }, []) -} From 21b8e177f444eb6d1fbd88e798f9c05501b264a0 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:21:25 -0400 Subject: [PATCH 048/101] feat(protocol-designer): add wrap to protocol overview columns (#16239) # Overview Wrap second `ProtocolOverview` column once minimum page width is hit Closes AUTH-716 --- .../pages/ProtocolOverview/DeckThumbnail.tsx | 5 ++-- .../ProtocolOverview/OffdeckThumbnail.tsx | 5 ++-- .../src/pages/ProtocolOverview/index.tsx | 24 +++++++++++++------ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx index b37fa5897fe..a96e84b3b44 100644 --- a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx @@ -96,8 +96,9 @@ export function DeckThumbnail(props: DeckThumbnailProps): JSX.Element { ) return ( > + width?: string } export function OffDeckThumbnail(props: OffDeckThumbnailProps): JSX.Element { - const { hover, setHover } = props + const { hover, setHover, width = '32.5rem' } = props const { t, i18n } = useTranslation('starting_deck_state') const robotType = useSelector(getRobotType) const deckSetup = useSelector(getInitialDeckSetup) @@ -43,7 +44,7 @@ export function OffDeckThumbnail(props: OffDeckThumbnailProps): JSX.Element { return ( - + - + ) : ( - + )} Date: Fri, 11 Oct 2024 14:26:29 -0400 Subject: [PATCH 049/101] fix(protocol-designer): update Step part design and export it as a component (#16432) * fix(protocol-designer): update Step part design and export it as a component --- .../localization/en/protocol_overview.json | 4 +- .../src/pages/ProtocolOverview/StepsInfo.tsx | 58 ++++++++++ .../__tests__/ProtocolOverview.test.tsx | 7 +- .../__tests__/StepsInfo.test.tsx | 101 ++++++++++++++++++ .../src/pages/ProtocolOverview/index.tsx | 27 +---- 5 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx create mode 100644 protocol-designer/src/pages/ProtocolOverview/__tests__/StepsInfo.test.tsx diff --git a/protocol-designer/src/assets/localization/en/protocol_overview.json b/protocol-designer/src/assets/localization/en/protocol_overview.json index ef978507ab5..e1975f4e4ef 100644 --- a/protocol-designer/src/assets/localization/en/protocol_overview.json +++ b/protocol-designer/src/assets/localization/en/protocol_overview.json @@ -24,12 +24,14 @@ "no_liquids_defined": "No liquids defined", "no_liquids": "No liquids", "no_steps": "No steps defined", + "number_of_steps": "Number of steps", "protocol_metadata": "Protocol Metadata", + "protocol_steps": "Protocol Steps", "required_app_version": "Required app version", "right_pip": "Right pipette", "robotType": "Robot type", "starting_deck": "Protocol Starting Deck", - "step": "Protocol steps", + "steps": "{{count}} steps", "total_well_volume": "Total Well Volume", "untitled_protocol": "Untitled protocol", "your_gripper": "Your gripper" diff --git a/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx b/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx new file mode 100644 index 00000000000..e0385336251 --- /dev/null +++ b/protocol-designer/src/pages/ProtocolOverview/StepsInfo.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next' + +import { + Flex, + DIRECTION_COLUMN, + SPACING, + StyledText, + InfoScreen, + ListItem, + ListItemDescriptor, + COLORS, +} from '@opentrons/components' + +import type { SavedStepFormState } from '../../step-forms' + +interface StepsInfoProps { + savedStepForms: SavedStepFormState +} + +export function StepsInfo({ savedStepForms }: StepsInfoProps): JSX.Element { + const { t } = useTranslation('protocol_overview') + + return ( + + + + {t('protocol_steps')} + + + + {Object.keys(savedStepForms).length <= 1 ? ( + + ) : ( + + + {t('number_of_steps')} + + } + content={ + + {t('steps', { + count: (Object.keys(savedStepForms).length - 1).toString(), + })} + + } + /> + + )} + + + ) +} diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx index bd037ba7a8e..e8536e4a549 100644 --- a/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/ProtocolOverview.test.tsx @@ -18,6 +18,7 @@ import { OffDeckThumbnail } from '../OffdeckThumbnail' import { ProtocolMetadata } from '../ProtocolMetadata' import { InstrumentsInfo } from '../InstrumentsInfo' import { LiquidDefinitions } from '../LiquidDefinitions' +import { StepsInfo } from '../StepsInfo' import type { NavigateFunction } from 'react-router-dom' @@ -29,9 +30,10 @@ vi.mock('../../../organisms/MaterialsListModal') vi.mock('../../../labware-ingred/selectors') vi.mock('../../../organisms') vi.mock('../../../labware-ingred/selectors') +vi.mock('../ProtocolMetadata') vi.mock('../LiquidDefinitions') vi.mock('../InstrumentsInfo') -vi.mock('../ProtocolMetadata') +vi.mock('../StepsInfo') const mockNavigate = vi.fn() @@ -82,6 +84,7 @@ describe('ProtocolOverview', () => {
    mock LiquidDefinitions
    ) vi.mocked(InstrumentsInfo).mockReturnValue(
    mock InstrumentsInfo
    ) + vi.mocked(StepsInfo).mockReturnValue(
    mock StepsInfo
    ) vi.mocked(ProtocolMetadata).mockReturnValue(
    mock ProtocolMetadata
    ) @@ -105,7 +108,7 @@ describe('ProtocolOverview', () => { screen.getByText('mock LiquidDefinitions') // steps - screen.getByText('Protocol steps') + screen.getByText('mock StepsInfo') }) it('should render the deck thumbnail and offdeck thumbnail', () => { diff --git a/protocol-designer/src/pages/ProtocolOverview/__tests__/StepsInfo.test.tsx b/protocol-designer/src/pages/ProtocolOverview/__tests__/StepsInfo.test.tsx new file mode 100644 index 00000000000..8c878448c11 --- /dev/null +++ b/protocol-designer/src/pages/ProtocolOverview/__tests__/StepsInfo.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { i18n } from '../../../assets/localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { StepsInfo } from '../StepsInfo' + +import type { ComponentProps } from 'react' +import type { InfoScreen } from '@opentrons/components' + +vi.mock('@opentrons/components', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + InfoScreen: () =>
    mock InfoScreen
    , + } +}) + +const mockSavedStepForms = { + __INITIAL_DECK_SETUP_STEP__: { + labwareLocationUpdate: { + 'd093dc35-f4b6-457e-b981-9b9828898f1c:opentrons/opentrons_flex_96_tiprack_50ul/1': + 'C2', + }, + moduleLocationUpdate: { + '64c722fe-515c-47dc-a56c-5250aee6bc82:heaterShakerModuleType': 'D1', + 'e99b8b82-98c6-4e44-911d-186d24ec104e:temperatureModuleType': 'C1', + }, + pipetteLocationUpdate: { + 'ae5fdda9-4b63-4951-a325-21d92a334991': 'left', + '25d7ff44-cd73-4843-a27c-11aae71b931f': 'right', + }, + stepType: 'manualIntervention', + id: '__INITIAL_DECK_SETUP_STEP__', + }, + '4e843572-58fc-43dd-bdb0-734123c1251a': { + labware: + 'd093dc35-f4b6-457e-b981-9b9828898f1c:opentrons/opentrons_flex_96_tiprack_50ul/1', + newLocation: 'C4', + useGripper: true, + id: '4e843572-58fc-43dd-bdb0-734123c1251a', + stepType: 'moveLabware', + stepName: 'move labware', + stepDetails: '', + }, + 'f1f44592-7b04-4486-af82-18c94151693f': { + labware: + 'd093dc35-f4b6-457e-b981-9b9828898f1c:opentrons/opentrons_flex_96_tiprack_50ul/1', + newLocation: 'B2', + useGripper: true, + id: 'f1f44592-7b04-4486-af82-18c94151693f', + stepType: 'moveLabware', + stepName: 'move labware', + stepDetails: '', + }, + 'c4e8170c-a462-42b8-af50-62208db65d07': { + labware: + 'd093dc35-f4b6-457e-b981-9b9828898f1c:opentrons/opentrons_flex_96_tiprack_50ul/1', + newLocation: 'offDeck', + useGripper: false, + id: 'c4e8170c-a462-42b8-af50-62208db65d07', + stepType: 'moveLabware', + stepName: 'move labware', + stepDetails: '', + }, +} as any + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('StepsInfo', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + savedStepForms: {}, + } + }) + + it('should render text', () => { + render(props) + screen.getByText('Protocol Steps') + }) + + it('should render mock infoscreen when savedStepForm is empty', () => { + render(props) + screen.getByText('mock InfoScreen') + }) + + it('should render number of steps', () => { + props = { + savedStepForms: mockSavedStepForms, + } + render(props) + screen.getByText('Number of steps') + screen.getByText('3 steps') + }) +}) diff --git a/protocol-designer/src/pages/ProtocolOverview/index.tsx b/protocol-designer/src/pages/ProtocolOverview/index.tsx index 4d79cf75970..f66dd6d084b 100644 --- a/protocol-designer/src/pages/ProtocolOverview/index.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/index.tsx @@ -11,13 +11,10 @@ import { Btn, DIRECTION_COLUMN, Flex, - InfoScreen, JUSTIFY_END, JUSTIFY_FLEX_END, JUSTIFY_SPACE_BETWEEN, LargeButton, - ListItem, - ListItemDescriptor, Modal, NO_WRAP, PrimaryButton, @@ -57,6 +54,7 @@ import { getWarningContent } from './UnusedModalContent' import { ProtocolMetadata } from './ProtocolMetadata' import { InstrumentsInfo } from './InstrumentsInfo' import { LiquidDefinitions } from './LiquidDefinitions' +import { StepsInfo } from './StepsInfo' import type { CreateCommand } from '@opentrons/shared-data' import type { DeckSlot } from '@opentrons/step-generation' @@ -349,28 +347,7 @@ export function ProtocolOverview(): JSX.Element { - - - - {t('step')} - - - - {Object.keys(savedStepForms).length <= 1 ? ( - - ) : ( - - - - )} - - + Date: Fri, 11 Oct 2024 15:14:29 -0400 Subject: [PATCH 050/101] refactor(api): Port tip consumption to StateUpdate (#16469) --- .../protocol_engine/commands/drop_tip.py | 4 +- .../commands/drop_tip_in_place.py | 4 +- .../protocol_engine/commands/pick_up_tip.py | 18 +- .../unsafe/unsafe_drop_tip_in_place.py | 4 +- .../opentrons/protocol_engine/state/tips.py | 120 ++++----- .../protocol_engine/state/update_types.py | 39 ++- .../commands/test_pick_up_tip.py | 8 +- .../protocol_engine/state/test_tip_state.py | 242 ++++++++---------- 8 files changed, 218 insertions(+), 221 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip.py b/api/src/opentrons/protocol_engine/commands/drop_tip.py index 021c7a47bc5..a006bd73dd3 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip.py @@ -114,7 +114,9 @@ async def execute(self, params: DropTipParams) -> SuccessData[DropTipResult, Non await self._tip_handler.drop_tip(pipette_id=pipette_id, home_after=home_after) - state_update.update_tip_state(pipette_id=params.pipetteId, tip_geometry=None) + state_update.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) return SuccessData( public=DropTipResult(position=deck_point), diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py index a8e62354f40..c414df86428 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py @@ -57,7 +57,9 @@ async def execute( state_update = update_types.StateUpdate() - state_update.update_tip_state(pipette_id=params.pipetteId, tip_geometry=None) + state_update.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) return SuccessData( public=DropTipInPlaceResult(), private=None, state_update=state_update diff --git a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py index 465ede2f86f..c5019b3c590 100644 --- a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py +++ b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py @@ -71,6 +71,10 @@ class TipPhysicallyMissingError(ErrorOccurrence): of the pipette. """ + # The thing above about marking the tips as used makes it so that + # when the protocol is resumed and the Python Protocol API calls + # `get_next_tip()`, we'll move on to other tips as expected. + isDefined: bool = True errorType: Literal["tipPhysicallyMissing"] = "tipPhysicallyMissing" errorCode: str = ErrorCodes.TIP_PICKUP_FAILED.value.code @@ -130,11 +134,10 @@ async def execute( labware_id=labware_id, well_name=well_name, ) - state_update.update_tip_state( - pipette_id=pipette_id, - tip_geometry=tip_geometry, - ) except TipNotAttachedError as e: + state_update.mark_tips_as_used( + pipette_id=pipette_id, labware_id=labware_id, well_name=well_name + ) return DefinedErrorData( public=TipPhysicallyMissingError( id=self._model_utils.generate_id(), @@ -150,6 +153,13 @@ async def execute( state_update=state_update, ) else: + state_update.update_pipette_tip_state( + pipette_id=pipette_id, + tip_geometry=tip_geometry, + ) + state_update.mark_tips_as_used( + pipette_id=pipette_id, labware_id=labware_id, well_name=well_name + ) return SuccessData( public=PickUpTipResult( tipVolume=tip_geometry.volume, diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py index e27a118ea60..33d4baebeea 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py @@ -74,7 +74,9 @@ async def execute( ) state_update = StateUpdate() - state_update.update_tip_state(pipette_id=params.pipetteId, tip_geometry=None) + state_update.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) return SuccessData( public=UnsafeDropTipInPlaceResult(), private=None, state_update=state_update diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index 1e14843114c..4ed78c1df96 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -3,27 +3,18 @@ from enum import Enum from typing import Dict, Optional, List, Union +from opentrons.protocol_engine.state import update_types + from ._abstract_store import HasState, HandlesActions -from ..actions import ( - Action, - SucceedCommandAction, - FailCommandAction, - ResetTipsAction, -) +from ..actions import Action, SucceedCommandAction, ResetTipsAction, get_state_update from ..commands import ( Command, LoadLabwareResult, - PickUpTip, - PickUpTipResult, - DropTipResult, - DropTipInPlaceResult, - unsafe, ) from ..commands.configuring_common import ( PipetteConfigUpdateResultMixin, PipetteNozzleLayoutResultMixin, ) -from ..error_recovery_policy import ErrorRecoveryType from opentrons.hardware_control.nozzle_manager import NozzleMap @@ -38,6 +29,18 @@ class TipRackWellState(Enum): TipRackStateByWellName = Dict[str, TipRackWellState] +# todo(mm, 2024-10-10): This info is duplicated between here and PipetteState because +# TipStore is using it to compute which tips a PickUpTip removes from the tip rack, +# given the pipette's current nozzle map. We could avoid this duplication by moving the +# computation to TipView, calling it from PickUpTipImplementation, and passing the +# precomputed list of wells to TipStore. +@dataclass +class _PipetteInfo: + channels: int + active_channels: int + nozzle_map: NozzleMap + + @dataclass class TipState: """State of all tips.""" @@ -45,9 +48,7 @@ class TipState: tips_by_labware_id: Dict[str, TipRackStateByWellName] column_by_labware_id: Dict[str, List[List[str]]] - channels_by_pipette_id: Dict[str, int] - active_channels_by_pipette_id: Dict[str, int] - nozzle_map_by_pipette_id: Dict[str, NozzleMap] + pipette_info_by_pipette_id: Dict[str, _PipetteInfo] class TipStore(HasState[TipState], HandlesActions): @@ -60,37 +61,33 @@ def __init__(self) -> None: self._state = TipState( tips_by_labware_id={}, column_by_labware_id={}, - channels_by_pipette_id={}, - active_channels_by_pipette_id={}, - nozzle_map_by_pipette_id={}, + pipette_info_by_pipette_id={}, ) def handle_action(self, action: Action) -> None: """Modify state in reaction to an action.""" + state_update = get_state_update(action) + if state_update is not None: + self._handle_state_update(state_update) + if isinstance(action, SucceedCommandAction): if isinstance(action.private_result, PipetteConfigUpdateResultMixin): pipette_id = action.private_result.pipette_id config = action.private_result.config - self._state.channels_by_pipette_id[pipette_id] = config.channels - self._state.active_channels_by_pipette_id[pipette_id] = config.channels - self._state.nozzle_map_by_pipette_id[pipette_id] = config.nozzle_map + self._state.pipette_info_by_pipette_id[pipette_id] = _PipetteInfo( + channels=config.channels, + active_channels=config.channels, + nozzle_map=config.nozzle_map, + ) + self._handle_succeeded_command(action.command) if isinstance(action.private_result, PipetteNozzleLayoutResultMixin): pipette_id = action.private_result.pipette_id nozzle_map = action.private_result.nozzle_map - if nozzle_map: - self._state.active_channels_by_pipette_id[ - pipette_id - ] = nozzle_map.tip_count - self._state.nozzle_map_by_pipette_id[pipette_id] = nozzle_map - else: - self._state.active_channels_by_pipette_id[ - pipette_id - ] = self._state.channels_by_pipette_id[pipette_id] - - elif isinstance(action, FailCommandAction): - self._handle_failed_command(action) + pipette_info = self._state.pipette_info_by_pipette_id[pipette_id] + pipette_info.active_channels = nozzle_map.tip_count + pipette_info.nozzle_map = nozzle_map elif isinstance(action, ResetTipsAction): labware_id = action.labware_id @@ -116,48 +113,20 @@ def _handle_succeeded_command(self, command: Command) -> None: column for column in definition.ordering ] - elif isinstance(command.result, PickUpTipResult): - labware_id = command.params.labwareId - well_name = command.params.wellName - pipette_id = command.params.pipetteId - self._set_used_tips( - pipette_id=pipette_id, well_name=well_name, labware_id=labware_id - ) - - elif isinstance( - command.result, - (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), - ): - pipette_id = command.params.pipetteId - - def _handle_failed_command( - self, - action: FailCommandAction, - ) -> None: - # If a pickUpTip command fails recoverably, mark the tips as used. This way, - # when the protocol is resumed and the Python Protocol API calls - # `get_next_tip()`, we'll move on to other tips as expected. - # - # We don't attempt this for nonrecoverable errors because maybe the failure - # was due to a bad labware ID or well name. - if ( - isinstance(action.running_command, PickUpTip) - and action.type != ErrorRecoveryType.FAIL_RUN - ): + def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: + if state_update.tips_used != update_types.NO_CHANGE: self._set_used_tips( - pipette_id=action.running_command.params.pipetteId, - labware_id=action.running_command.params.labwareId, - well_name=action.running_command.params.wellName, + pipette_id=state_update.tips_used.pipette_id, + labware_id=state_update.tips_used.labware_id, + well_name=state_update.tips_used.well_name, ) - # Note: We're logically removing the tip from the tip rack, - # but we're not logically updating the pipette to have that tip on it. def _set_used_tips( # noqa: C901 self, pipette_id: str, well_name: str, labware_id: str ) -> None: columns = self._state.column_by_labware_id.get(labware_id, []) wells = self._state.tips_by_labware_id.get(labware_id, {}) - nozzle_map = self._state.nozzle_map_by_pipette_id[pipette_id] + nozzle_map = self._state.pipette_info_by_pipette_id[pipette_id].nozzle_map # TODO (cb, 02-28-2024): Transition from using partial nozzle map to full instrument map for the set used logic num_nozzle_cols = len(nozzle_map.columns) @@ -225,7 +194,7 @@ def _identify_tip_cluster( critical_row: int, entry_well: str, ) -> Optional[List[str]]: - tip_cluster = [] + tip_cluster: list[str] = [] for i in range(active_columns): if entry_well == "A1" or entry_well == "H1": if critical_column - i >= 0: @@ -276,12 +245,12 @@ def _validate_tip_cluster( # In the case of a 96ch we can attempt to index in by singular rows and columns assuming that indexed direction is safe # The tip cluster list is ordered: Each row from a column in order by columns - tip_cluster_final_column = [] + tip_cluster_final_column: list[str] = [] for i in range(active_rows): tip_cluster_final_column.append( tip_cluster[((active_columns * active_rows) - 1) - i] ) - tip_cluster_final_row = [] + tip_cluster_final_row: list[str] = [] for i in range(active_columns): tip_cluster_final_row.append( tip_cluster[(active_rows - 1) + (i * active_rows)] @@ -472,19 +441,22 @@ def _cluster_search_H12(active_columns: int, active_rows: int) -> Optional[str]: def get_pipette_channels(self, pipette_id: str) -> int: """Return the given pipette's number of channels.""" - return self._state.channels_by_pipette_id[pipette_id] + return self._state.pipette_info_by_pipette_id[pipette_id].channels def get_pipette_active_channels(self, pipette_id: str) -> int: """Get the number of channels being used in the given pipette's configuration.""" - return self._state.active_channels_by_pipette_id[pipette_id] + return self._state.pipette_info_by_pipette_id[pipette_id].active_channels def get_pipette_nozzle_map(self, pipette_id: str) -> NozzleMap: """Get the current nozzle map the given pipette's configuration.""" - return self._state.nozzle_map_by_pipette_id[pipette_id] + return self._state.pipette_info_by_pipette_id[pipette_id].nozzle_map def get_pipette_nozzle_maps(self) -> Dict[str, NozzleMap]: """Get current nozzle maps keyed by pipette id.""" - return self._state.nozzle_map_by_pipette_id + return { + pipette_id: pipette_info.nozzle_map + for pipette_id, pipette_info in self._state.pipette_info_by_pipette_id.items() + } def has_clean_tip(self, labware_id: str, well_name: str) -> bool: """Get whether a well in a labware has a clean tip. diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 91cdf0194a3..0bf00cfdd86 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -136,6 +136,23 @@ class PipetteTipStateUpdate: tip_geometry: typing.Optional[TipGeometry] +@dataclasses.dataclass +class TipsUsedUpdate: + """Represents an update that marks tips in a tip rack as used.""" + + pipette_id: str + """The pipette that did the tip pickup.""" + + labware_id: str + + well_name: str + """The well that the pipette's primary nozzle targeted. + + Wells in addition to this one will also be marked as used, depending on the + pipette's nozzle layout. + """ + + @dataclasses.dataclass class StateUpdate: """Represents an update to perform on engine state.""" @@ -154,8 +171,10 @@ class StateUpdate: loaded_labware: LoadedLabwareUpdate | NoChangeType = NO_CHANGE + tips_used: TipsUsedUpdate | NoChangeType = NO_CHANGE + # These convenience functions let the caller avoid the boilerplate of constructing a - # complicated dataclass tree, and they give us a + # complicated dataclass tree. @typing.overload def set_pipette_location( @@ -207,6 +226,10 @@ def set_pipette_location( # noqa: D102 new_deck_point=new_deck_point, ) + def clear_all_pipette_locations(self) -> None: + """Mark all pipettes as having an unknown location.""" + self.pipette_location = CLEAR + def set_labware_location( self, *, @@ -238,10 +261,6 @@ def set_loaded_labware( display_name=display_name, ) - def clear_all_pipette_locations(self) -> None: - """Mark all pipettes as having an unknown location.""" - self.pipette_location = CLEAR - def set_load_pipette( self, pipette_id: str, @@ -274,10 +293,18 @@ def update_pipette_nozzle(self, pipette_id: str, nozzle_map: NozzleMap) -> None: pipette_id=pipette_id, nozzle_map=nozzle_map ) - def update_tip_state( + def update_pipette_tip_state( self, pipette_id: str, tip_geometry: typing.Optional[TipGeometry] ) -> None: """Update tip state.""" self.pipette_tip_state = PipetteTipStateUpdate( pipette_id=pipette_id, tip_geometry=tip_geometry ) + + def mark_tips_as_used( + self, pipette_id: str, labware_id: str, well_name: str + ) -> None: + """Mark tips in a tip rack as used. See `MarkTipsUsedState`.""" + self.tips_used = TipsUsedUpdate( + pipette_id=pipette_id, labware_id=labware_id, well_name=well_name + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py index c4ff37c501d..55a4504d5a3 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_pick_up_tip.py @@ -83,6 +83,9 @@ async def test_success( pipette_id="pipette-id", tip_geometry=TipGeometry(length=42, diameter=5, volume=300), ), + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", labware_id="labware-id", well_name="A3" + ), ), ) @@ -139,6 +142,9 @@ async def test_tip_physically_missing_error( labware_id="labware-id", well_name="well-name" ), new_deck_point=DeckPoint(x=111, y=222, z=333), - ) + ), + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", labware_id="labware-id", well_name="well-name" + ), ), ) diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index bdc5cc639f4..dc603ac4ca8 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -14,8 +14,9 @@ from opentrons.hardware_control.nozzle_manager import NozzleMap from opentrons.protocol_engine import actions, commands +from opentrons.protocol_engine.state import update_types from opentrons.protocol_engine.state.tips import TipStore, TipView -from opentrons.protocol_engine.types import FlowRates, DeckPoint +from opentrons.protocol_engine.types import FlowRates from opentrons.protocol_engine.resources.pipette_data_provider import ( LoadedStaticPipetteData, ) @@ -70,54 +71,9 @@ def load_labware_command(labware_definition: LabwareDefinition) -> commands.Load ) -@pytest.fixture -def pick_up_tip_command() -> commands.PickUpTip: - """Get a pick-up tip command value object.""" - return commands.PickUpTip.construct( # type: ignore[call-arg] - params=commands.PickUpTipParams.construct( - pipetteId="pipette-id", - labwareId="cool-labware", - wellName="A1", - ), - result=commands.PickUpTipResult.construct( - position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 - ), - ) - - -@pytest.fixture -def drop_tip_command() -> commands.DropTip: - """Get a drop tip command value object.""" - return commands.DropTip.construct( # type: ignore[call-arg] - params=commands.DropTipParams.construct( - pipetteId="pipette-id", - labwareId="cool-labware", - wellName="A1", - ), - result=commands.DropTipResult.construct(position=DeckPoint(x=0, y=0, z=0)), - ) - - -@pytest.fixture -def drop_tip_in_place_command() -> commands.DropTipInPlace: - """Get a drop tip in place command object.""" - return commands.DropTipInPlace.construct( # type: ignore[call-arg] - params=commands.DropTipInPlaceParams.construct( - pipetteId="pipette-id", - ), - result=commands.DropTipInPlaceResult.construct(), - ) - - -@pytest.fixture -def unsafe_drop_tip_in_place_command() -> commands.unsafe.UnsafeDropTipInPlace: - """Get an unsafe drop-tip-in-place command.""" - return commands.unsafe.UnsafeDropTipInPlace.construct( # type: ignore[call-arg] - params=commands.unsafe.UnsafeDropTipInPlaceParams.construct( - pipetteId="pipette-id" - ), - result=commands.unsafe.UnsafeDropTipInPlaceResult.construct(), - ) +def _dummy_command() -> commands.Command: + """Return a placeholder command.""" + return commands.Comment.construct() # type: ignore[call-arg] @pytest.mark.parametrize( @@ -310,7 +266,6 @@ def test_get_next_tip_used_starting_tip( ) def test_get_next_tip_skips_picked_up_tip( load_labware_command: commands.LoadLabware, - pick_up_tip_command: commands.PickUpTip, subject: TipStore, input_tip_amount: int, get_next_tip_tips: int, @@ -322,6 +277,7 @@ def test_get_next_tip_skips_picked_up_tip( subject.handle_action( actions.SucceedCommandAction(private_result=None, command=load_labware_command) ) + load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] result=commands.LoadPipetteResult(pipetteId="pipette-id") ) @@ -372,8 +328,20 @@ def test_get_next_tip_skips_picked_up_tip( private_result=load_pipette_private_result, command=load_pipette_command ) ) + + pick_up_tip_state_update = update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", + labware_id="cool-labware", + well_name="A1", + ) + ) subject.handle_action( - actions.SucceedCommandAction(command=pick_up_tip_command, private_result=None) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=pick_up_tip_state_update, + ) ) result = TipView(subject.state).get_next_tip( @@ -436,19 +404,15 @@ def test_get_next_tip_with_starting_tip( assert result == "B2" - pick_up_tip = commands.PickUpTip.construct( # type: ignore[call-arg] - params=commands.PickUpTipParams.construct( - pipetteId="pipette-id", - labwareId="cool-labware", - wellName="B2", - ), - result=commands.PickUpTipResult.construct( - position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 - ), + pick_up_tip_state_update = update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate("pipette-id", "cool-labware", "B2") ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=pick_up_tip_state_update, + ) ) result = TipView(subject.state).get_next_tip( @@ -512,19 +476,17 @@ def test_get_next_tip_with_starting_tip_8_channel( assert result == "A2" - pick_up_tip = commands.PickUpTip.construct( # type: ignore[call-arg] - params=commands.PickUpTipParams.construct( - pipetteId="pipette-id", - labwareId="cool-labware", - wellName="A2", - ), - result=commands.PickUpTipResult.construct( - position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 - ), + pick_up_tip_state_update = update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", labware_id="cool-labware", well_name="A2" + ) ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=pick_up_tip_state_update, + ) ) result = TipView(subject.state).get_next_tip( @@ -578,10 +540,10 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( private_result=load_pipette_private_result, command=load_pipette_command ) ) - load_pipette_command2 = commands.LoadPipette.construct( # type: ignore[call-arg] + load_pipette_command_2 = commands.LoadPipette.construct( # type: ignore[call-arg] result=commands.LoadPipetteResult(pipetteId="pipette-id2") ) - load_pipette_private_result2 = commands.LoadPipettePrivateResult( + load_pipette_private_result_2 = commands.LoadPipettePrivateResult( pipette_id="pipette-id2", serial_number="pipette-serial2", config=LoadedStaticPipetteData( @@ -607,7 +569,7 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( ) subject.handle_action( actions.SucceedCommandAction( - private_result=load_pipette_private_result2, command=load_pipette_command2 + private_result=load_pipette_private_result_2, command=load_pipette_command_2 ) ) @@ -620,19 +582,17 @@ def test_get_next_tip_with_1_channel_followed_by_8_channel( assert result == "A1" - pick_up_tip2 = commands.PickUpTip.construct( # type: ignore[call-arg] - params=commands.PickUpTipParams.construct( - pipetteId="pipette-id2", - labwareId="cool-labware", - wellName="A1", - ), - result=commands.PickUpTipResult.construct( - position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 - ), + pick_up_tip_2_state_update = update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id2", labware_id="cool-labware", well_name="A1" + ) ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip2) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=pick_up_tip_2_state_update, + ) ) result = TipView(subject.state).get_next_tip( @@ -696,19 +656,17 @@ def test_get_next_tip_with_starting_tip_out_of_tips( assert result == "H12" - pick_up_tip = commands.PickUpTip.construct( # type: ignore[call-arg] - params=commands.PickUpTipParams.construct( - pipetteId="pipette-id", - labwareId="cool-labware", - wellName="H12", - ), - result=commands.PickUpTipResult.construct( - position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 - ), + pick_up_tip_state_update = update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", labware_id="cool-labware", well_name="H12" + ) ) - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=pick_up_tip_state_update, + ) ) result = TipView(subject.state).get_next_tip( @@ -776,7 +734,6 @@ def test_get_next_tip_with_column_and_starting_tip( def test_reset_tips( subject: TipStore, load_labware_command: commands.LoadLabware, - pick_up_tip_command: commands.PickUpTip, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, ) -> None: """It should be able to reset tip tracking state.""" @@ -818,18 +775,30 @@ def test_reset_tips( ) subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", + labware_id="cool-labware", + well_name="A1", + ) + ), + ) ) - subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) - result = TipView(subject.state).get_next_tip( - labware_id="cool-labware", - num_tips=1, - starting_tip_name=None, - nozzle_map=None, - ) + def get_result() -> str | None: + return TipView(subject.state).get_next_tip( + labware_id="cool-labware", + num_tips=1, + starting_tip_name=None, + nozzle_map=None, + ) - assert result == "A1" + assert get_result() != "A1" + subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) + assert get_result() == "A1" def test_handle_pipette_config_action( @@ -1032,7 +1001,6 @@ def test_next_tip_uses_active_channels( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, load_labware_command: commands.LoadLabware, - pick_up_tip_command: commands.PickUpTip, ) -> None: """Test that tip tracking logic uses pipette's active channels.""" # Load labware @@ -1102,7 +1070,17 @@ def test_next_tip_uses_active_channels( ) # Pick up partial tips subject.handle_action( - actions.SucceedCommandAction(command=pick_up_tip_command, private_result=None) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", + labware_id="cool-labware", + well_name="A1", + ) + ), + ) ) result = TipView(subject.state).get_next_tip( @@ -1118,7 +1096,6 @@ def test_next_tip_automatic_tip_tracking_with_partial_configurations( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, load_labware_command: commands.LoadLabware, - pick_up_tip_command: commands.PickUpTip, ) -> None: """Test tip tracking logic using multiple pipette configurations.""" # Load labware @@ -1167,21 +1144,22 @@ def _assert_and_pickup(well: str, nozzle_map: NozzleMap) -> None: starting_tip_name=None, nozzle_map=nozzle_map, ) - assert result == well + assert result is not None and result == well - pick_up_tip = commands.PickUpTip.construct( # type: ignore[call-arg] - params=commands.PickUpTipParams.construct( - pipetteId="pipette-id", - labwareId="cool-labware", - wellName=result, - ), - result=commands.PickUpTipResult.construct( - position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 - ), + pick_up_tip_state_update = update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", + labware_id="cool-labware", + well_name=result, + ) ) subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=pick_up_tip_state_update, + ) ) # Configure nozzle for partial configurations @@ -1277,7 +1255,6 @@ def test_next_tip_automatic_tip_tracking_tiprack_limits( subject: TipStore, supported_tip_fixture: pipette_definition.SupportedTipsDefinition, load_labware_command: commands.LoadLabware, - pick_up_tip_command: commands.PickUpTip, ) -> None: """Test tip tracking logic to ensure once a tiprack is consumed it returns None when consuming tips using multiple pipette configurations.""" # Load labware @@ -1327,19 +1304,18 @@ def _get_next_and_pickup(nozzle_map: NozzleMap) -> str | None: nozzle_map=nozzle_map, ) if result is not None: - pick_up_tip = commands.PickUpTip.construct( # type: ignore[call-arg] - params=commands.PickUpTipParams.construct( - pipetteId="pipette-id", - labwareId="cool-labware", - wellName=result, - ), - result=commands.PickUpTipResult.construct( - position=DeckPoint(x=0, y=0, z=0), tipLength=1.23 - ), + pick_up_tip_state_update = update_types.StateUpdate( + tips_used=update_types.TipsUsedUpdate( + pipette_id="pipette-id", labware_id="cool-labware", well_name=result + ) ) subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip) + actions.SucceedCommandAction( + command=_dummy_command(), + private_result=None, + state_update=pick_up_tip_state_update, + ) ) return result From 83cec1872ff77db9a242f85046b0ace86525d44c Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 11 Oct 2024 15:40:45 -0400 Subject: [PATCH 051/101] feat(app): Add partial tip support to Error Recovery (#16447) Closes RSQ-161 This PR adds partial tip support during Error Recovery flows, specifically for tip pick-up during overpressure error recovery. After talks with design, users will be unable to select wells themselves if using a partial tip layout, since this helps prevents unexpected pickups. There are some special-cased copy updates, too. --- .../localization/en/error_recovery.json | 1 + app/src/local-resources/instruments/index.ts | 1 + app/src/local-resources/instruments/utils.ts | 18 ++++ .../hooks/useDropTipCommands.ts | 8 +- .../ErrorRecoveryFlows/__fixtures__/index.ts | 7 +- .../__tests__/useFailedPipetteUtils.test.ts} | 2 +- .../__tests__/useRecoveryCommands.test.ts | 4 +- .../ErrorRecoveryFlows/hooks/useERUtils.ts | 15 +-- .../hooks/useFailedLabwareUtils.ts | 4 +- .../hooks/useFailedPipetteUtils.ts | 91 ++++++++++++++++ .../hooks/useRecoveryCommands.ts | 4 +- .../ErrorRecoveryFlows/shared/SelectTips.tsx | 20 +++- .../shared/TipSelection.tsx | 32 ++++-- .../shared/TwoColLwInfoAndDeck.tsx | 19 +++- .../shared/__tests__/SelectTips.test.tsx | 74 +++++++++++-- .../shared/__tests__/TipSelection.test.tsx | 11 +- .../utils/getFailedCommandPipetteInfo.ts | 34 ------ .../ErrorRecoveryFlows/utils/index.ts | 1 - .../ODD/QuickTransferFlow/SelectDestWells.tsx | 2 +- .../QuickTransferFlow/SelectSourceWells.tsx | 2 +- app/src/organisms/WellSelection/index.tsx | 102 ++++++++++-------- 21 files changed, 319 insertions(+), 133 deletions(-) create mode 100644 app/src/local-resources/instruments/utils.ts rename app/src/organisms/ErrorRecoveryFlows/{utils/__tests__/getFailedCommandPipetteInfo.test.ts => hooks/__tests__/useFailedPipetteUtils.test.ts} (96%) create mode 100644 app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts delete mode 100644 app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index dde0d183387..63c259ce1f3 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -61,6 +61,7 @@ "release": "Release", "release_labware_from_gripper": "Release labware from gripper", "remove_any_attached_tips": "Remove any attached tips", + "replace_tips_and_select_loc_partial_tip": "Replace tips and select the last location used for partial tip pickup.", "replace_tips_and_select_location": "It's best to replace tips and select the last location used for tip pickup.", "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in slot {{slot}}", "replace_with_new_tip_rack": "Replace with new tip rack in slot {{slot}}", diff --git a/app/src/local-resources/instruments/index.ts b/app/src/local-resources/instruments/index.ts index fc78d35129c..f3723b374bf 100644 --- a/app/src/local-resources/instruments/index.ts +++ b/app/src/local-resources/instruments/index.ts @@ -1 +1,2 @@ export * from './hooks' +export * from './utils' diff --git a/app/src/local-resources/instruments/utils.ts b/app/src/local-resources/instruments/utils.ts new file mode 100644 index 00000000000..ef92e580725 --- /dev/null +++ b/app/src/local-resources/instruments/utils.ts @@ -0,0 +1,18 @@ +export interface IsPartialTipConfigParams { + channel: 1 | 8 | 96 + activeNozzleCount: number +} + +export function isPartialTipConfig({ + channel, + activeNozzleCount, +}: IsPartialTipConfigParams): boolean { + switch (channel) { + case 1: + return false + case 8: + return activeNozzleCount !== 8 + case 96: + return activeNozzleCount !== 96 + } +} diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts index 0f88c2ccc1c..fc040be2b52 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts @@ -9,8 +9,6 @@ import type { CreateCommand, AddressableAreaName, PipetteModelSpecs, - DropTipInPlaceCreateCommand, - UnsafeDropTipInPlaceCreateCommand, } from '@opentrons/shared-data' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import type { CommandData, PipetteData } from '@opentrons/api-client' @@ -325,19 +323,21 @@ const UPDATE_PLUNGER_ESTIMATORS: CreateCommand = { const buildDropTipInPlaceCommand = ( isFlex: boolean, pipetteId: string | null -): Array => +): CreateCommand[] => isFlex ? [ { commandType: 'unsafe/dropTipInPlace', params: { pipetteId: pipetteId ?? MANAGED_PIPETTE_ID }, }, + Z_HOME, ] : [ { commandType: 'dropTipInPlace', params: { pipetteId: pipetteId ?? MANAGED_PIPETTE_ID }, }, + Z_HOME, ] const buildBlowoutCommands = ( @@ -366,6 +366,7 @@ const buildBlowoutCommands = ( pipetteId: pipetteId ?? MANAGED_PIPETTE_ID, }, }, + Z_HOME, ] : [ { @@ -376,6 +377,7 @@ const buildBlowoutCommands = ( flowRate: specs.defaultBlowOutFlowRate.value, }, }, + Z_HOME, ] const buildMoveToAACommand = ( diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index b2efd1947e2..d2efa1cc218 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -72,11 +72,8 @@ export const mockRecoveryContentProps: RecoveryContentProps = { recoveryCommands: {} as any, tipStatusUtils: {} as any, currentRecoveryOptionUtils: {} as any, - failedLabwareUtils: { - pickUpTipLabware: mockPickUpTipLabware, - selectedTipLocation: { A1: null }, - } as any, - failedPipetteInfo: {} as any, + failedLabwareUtils: { pickUpTipLabware: mockPickUpTipLabware } as any, + failedPipetteUtils: {} as any, deckMapUtils: { setSelectedLocation: () => {} } as any, stepCounts: {} as any, protocolAnalysis: mockRobotSideAnalysis, diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getFailedCommandPipetteInfo.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedPipetteUtils.test.ts similarity index 96% rename from app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getFailedCommandPipetteInfo.test.ts rename to app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedPipetteUtils.test.ts index 7031dedc5fc..f0da2aef402 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getFailedCommandPipetteInfo.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedPipetteUtils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { getFailedCommandPipetteInfo } from '../getFailedCommandPipetteInfo' +import { getFailedCommandPipetteInfo } from '../useFailedPipetteUtils' describe('getFailedCommandPipetteInfo', () => { const failedCommand = { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index a12430f72d9..8df2c3ec86b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -27,7 +27,7 @@ describe('useRecoveryCommands', () => { } as any const mockRunId = '123' const mockFailedLabwareUtils = { - selectedTipLocation: { A1: null }, + selectedTipLocations: { A1: null }, pickUpTipLabware: { id: 'MOCK_LW_ID' }, } as any const mockProceedToRouteAndStep = vi.fn() @@ -223,7 +223,7 @@ describe('useRecoveryCommands', () => { } as any const buildPickUpTipsCmd = buildPickUpTips( - mockFailedLabwareUtils.selectedTipLocation, + mockFailedLabwareUtils.selectedTipLocations, mockFailedCmdWithPipetteId, mockFailedLabware ) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 9f6a6536504..38af12de4a7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -5,7 +5,7 @@ import { useRecoveryCommands } from './useRecoveryCommands' import { useRecoveryTipStatus } from './useRecoveryTipStatus' import { useRecoveryRouting } from './useRecoveryRouting' import { useFailedLabwareUtils } from './useFailedLabwareUtils' -import { getFailedCommandPipetteInfo, getNextSteps } from '../utils' +import { getNextSteps } from '../utils' import { useDeckMapUtils } from './useDeckMapUtils' import { useNotifyAllCommandsQuery, @@ -18,8 +18,8 @@ import { useRecoveryToasts } from './useRecoveryToasts' import { useRecoveryAnalytics } from '/app/redux-resources/analytics' import { useShowDoorInfo } from './useShowDoorInfo' import { useCleanupRecoveryState } from './useCleanupRecoveryState' +import { useFailedPipetteUtils } from './useFailedPipetteUtils' -import type { PipetteData } from '@opentrons/api-client' import type { RobotType } from '@opentrons/shared-data' import type { IRecoveryMap, RouteStep, RecoveryRoute } from '../types' import type { ErrorRecoveryFlowsProps } from '..' @@ -38,6 +38,7 @@ import type { UseRecoveryAnalyticsResult } from '/app/redux-resources/analytics' import type { UseRecoveryTakeoverResult } from './useRecoveryTakeover' import type { useRetainedFailedCommandBySource } from './useRetainedFailedCommandBySource' import type { UseShowDoorInfoResult } from './useShowDoorInfo' +import type { UseFailedPipetteUtilsResult } from './useFailedPipetteUtils' export type ERUtilsProps = Omit & { toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] @@ -55,10 +56,10 @@ export interface ERUtilsResults { recoveryCommands: UseRecoveryCommandsResult tipStatusUtils: RecoveryTipStatusUtils failedLabwareUtils: UseFailedLabwareUtilsResult + failedPipetteUtils: UseFailedPipetteUtilsResult deckMapUtils: UseDeckMapUtilsResult getRecoveryOptionCopy: ReturnType recoveryActionMutationUtils: RecoveryActionMutationResult - failedPipetteInfo: PipetteData | null hasLaunchedRecovery: boolean stepCounts: StepCounts commandsAfterFailedCommand: ReturnType @@ -114,11 +115,13 @@ export function useERUtils({ robotType, }) - const failedPipetteInfo = getFailedCommandPipetteInfo({ - failedCommandByRunRecord, + const failedPipetteUtils = useFailedPipetteUtils({ + runId, + failedCommandByRunRecord: failedCommand?.byRunRecord ?? null, runRecord, attachedInstruments, }) + const { failedPipetteInfo } = failedPipetteUtils const tipStatusUtils = useRecoveryTipStatus({ runId, @@ -190,7 +193,7 @@ export function useERUtils({ hasLaunchedRecovery, tipStatusUtils, failedLabwareUtils, - failedPipetteInfo, + failedPipetteUtils, deckMapUtils, getRecoveryOptionCopy, stepCounts, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 253521fd19b..e1c15a9e264 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -170,7 +170,7 @@ function getRelevantPickUpTipCommand( interface UseTipSelectionUtilsResult { /* Always returns null if the relevant labware is not relevant to tip pick up. */ - selectedTipLocation: WellGroup | null + selectedTipLocations: WellGroup | null tipSelectorDef: LabwareDefinition2 selectTips: (tipGroup: WellGroup) => void deselectTips: (locations: string[]) => void @@ -220,7 +220,7 @@ function useTipSelectionUtils( selectedLocs != null && Object.keys(selectedLocs).length > 0 return { - selectedTipLocation: selectedLocs, + selectedTipLocations: selectedLocs, tipSelectorDef, selectTips, deselectTips, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts new file mode 100644 index 00000000000..f997592f8cd --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedPipetteUtils.ts @@ -0,0 +1,91 @@ +import { useRunCurrentState } from '@opentrons/react-api-client' + +import { isPartialTipConfig } from '/app/local-resources/instruments' + +import type { + NozzleLayoutValues, + Instruments, + Run, + PipetteData, +} from '@opentrons/api-client' +import type { ErrorRecoveryFlowsProps } from '/app/organisms/ErrorRecoveryFlows' + +export interface UseFailedPipetteUtilsParams + extends UseFailedCommandPipetteInfoProps { + runId: string +} + +export interface UseFailedPipetteUtilsResult { + relevantActiveNozzleLayout: NozzleLayoutValues | null + isPartialTipConfigValid: boolean + failedPipetteInfo: ReturnType +} + +export function useFailedPipetteUtils( + props: UseFailedPipetteUtilsParams +): UseFailedPipetteUtilsResult { + const { failedCommandByRunRecord, runId } = props + + const failedPipetteId = + failedCommandByRunRecord != null + ? 'pipetteId' in failedCommandByRunRecord.params + ? failedCommandByRunRecord.params.pipetteId + : null + : null + + const { data: runCurrentState } = useRunCurrentState(runId, { + enabled: failedPipetteId != null, + }) + + const relevantActiveNozzleLayout = + runCurrentState?.data.activeNozzleLayouts[failedPipetteId] ?? null + + const failedPipetteInfo = getFailedCommandPipetteInfo(props) + + const isPartialTipConfigValid = + failedPipetteInfo != null && relevantActiveNozzleLayout != null + ? isPartialTipConfig({ + channel: failedPipetteInfo.data.channels, + activeNozzleCount: + relevantActiveNozzleLayout.activeNozzles.length ?? 0, + }) + : false + + return { + relevantActiveNozzleLayout, + isPartialTipConfigValid, + failedPipetteInfo, + } +} + +interface UseFailedCommandPipetteInfoProps { + runRecord: Run | undefined + attachedInstruments: Instruments | undefined + failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] +} + +// /instruments data for the pipette used in the failedCommand, if any. +export function getFailedCommandPipetteInfo({ + failedCommandByRunRecord, + runRecord, + attachedInstruments, +}: UseFailedCommandPipetteInfoProps): PipetteData | null { + if ( + failedCommandByRunRecord == null || + !('pipetteId' in failedCommandByRunRecord.params) + ) { + return null + } else { + const failedPipetteId = failedCommandByRunRecord.params.pipetteId + const runRecordPipette = runRecord?.data.pipettes.find( + pipette => pipette.id === failedPipetteId + ) + + const failedInstrumentInfo = attachedInstruments?.data.find( + instrument => + 'mount' in instrument && instrument.mount === runRecordPipette?.mount + ) as PipetteData + + return failedInstrumentInfo ?? null + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index e6b5eefbca3..f463d4dd107 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -162,10 +162,10 @@ export function useRecoveryCommands({ // Pick up the user-selected tips const pickUpTips = useCallback((): Promise => { - const { selectedTipLocation, failedLabware } = failedLabwareUtils + const { selectedTipLocations, failedLabware } = failedLabwareUtils const pickUpTipCmd = buildPickUpTips( - selectedTipLocation, + selectedTipLocations, failedCommandByRunRecord, failedLabware ) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx index 0f55a5abe79..13dcfaa73c6 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/SelectTips.tsx @@ -13,14 +13,15 @@ import type { RecoveryContentProps } from '../types' export function SelectTips(props: RecoveryContentProps): JSX.Element | null { const { - failedPipetteInfo, routeUpdateActions, recoveryCommands, isOnDevice, failedLabwareUtils, + failedPipetteUtils, } = props const { ROBOT_PICKING_UP_TIPS } = RECOVERY_MAP const { pickUpTips } = recoveryCommands + const { isPartialTipConfigValid, failedPipetteInfo } = failedPipetteUtils const { goBackPrevStep, handleMotionRouting, @@ -44,7 +45,8 @@ export function SelectTips(props: RecoveryContentProps): JSX.Element | null { tertiaryBtnOnClick?: () => void tertiaryBtnText?: string } => { - if (isOnDevice) { + // If partial tip config, do not give users the option to select tip location. + if (isOnDevice && !isPartialTipConfigValid) { return { tertiaryBtnDisabled: failedPipetteInfo?.data.channels === 96, tertiaryBtnOnClick: toggleModal, @@ -55,12 +57,17 @@ export function SelectTips(props: RecoveryContentProps): JSX.Element | null { } } + const buildBannerText = (): string => + isPartialTipConfigValid + ? t('replace_tips_and_select_loc_partial_tip') + : t('replace_tips_and_select_location') + return ( <> {showTipSelectModal && ( )} @@ -70,9 +77,12 @@ export function SelectTips(props: RecoveryContentProps): JSX.Element | null { {...props} title={t('select_tip_pickup_location')} type="location" - bannerText={t('replace_tips_and_select_location')} + bannerText={buildBannerText()} + /> + - { - if (allowTipSelection) { - selectTips(tipGroup) - } + selectTips(tipGroup) } const onDeselectTips = (locations: string[]): void => { - if (allowTipSelection) { - deselectTips(locations) - } + deselectTips(locations) } return ( ) } + +function buildNozzleLayoutDetails( + relevantActiveNozzleLayout: TipSelectionProps['failedPipetteUtils']['relevantActiveNozzleLayout'] +): NozzleLayoutDetails | undefined { + return relevantActiveNozzleLayout != null + ? { + activeNozzleCount: relevantActiveNozzleLayout.activeNozzles.length, + nozzleConfig: relevantActiveNozzleLayout.config, + } + : undefined +} diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx index feb5a4b88b2..aab38a1aee0 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx @@ -10,17 +10,18 @@ import { RECOVERY_MAP } from '../constants' import type { RecoveryContentProps } from '../types' +// TODO(jh, 10-09-24): Add testing for this component. + export function TwoColLwInfoAndDeck( props: RecoveryContentProps ): JSX.Element | null { const { routeUpdateActions, - failedPipetteInfo, + failedPipetteUtils, failedLabwareUtils, deckMapUtils, currentRecoveryOptionUtils, } = props - console.log('=>(TwoColLwInfoAndDeck.tsx:23) deckMapUtils', deckMapUtils) const { RETRY_NEW_TIPS, SKIP_STEP_WITH_NEW_TIPS, @@ -30,6 +31,7 @@ export function TwoColLwInfoAndDeck( const { selectedRecoveryOption } = currentRecoveryOptionUtils const { relevantWellName, failedLabware } = failedLabwareUtils const { proceedNextStep } = routeUpdateActions + const { failedPipetteInfo, isPartialTipConfigValid } = failedPipetteUtils const { t } = useTranslation('error_recovery') const primaryOnClick = (): void => { @@ -46,7 +48,11 @@ export function TwoColLwInfoAndDeck( return t('manually_replace_lw_on_deck') case RETRY_NEW_TIPS.ROUTE: case SKIP_STEP_WITH_NEW_TIPS.ROUTE: { - if (failedPipetteInfo?.data.channels === 96) { + // Only special case the "full" 96-channel nozzle config. + if ( + failedPipetteInfo?.data.channels === 96 && + !isPartialTipConfigValid + ) { return t('replace_with_new_tip_rack', { slot }) } else { return t('replace_used_tips_in_rack_location', { @@ -69,8 +75,11 @@ export function TwoColLwInfoAndDeck( case MANUAL_REPLACE_AND_RETRY.ROUTE: return t('ensure_lw_is_accurately_placed') case RETRY_NEW_TIPS.ROUTE: - case SKIP_STEP_WITH_NEW_TIPS.ROUTE: - return t('replace_tips_and_select_location') + case SKIP_STEP_WITH_NEW_TIPS.ROUTE: { + return isPartialTipConfigValid + ? t('replace_tips_and_select_loc_partial_tip') + : t('replace_tips_and_select_location') + } default: console.error( 'Unexpected recovery option. Handle retry step copy explicitly.' diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx index 88fd55e982d..8b8ab83d9f9 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx @@ -13,7 +13,6 @@ import type { Mock } from 'vitest' vi.mock('../TipSelectionModal') vi.mock('../TipSelection') -vi.mock('../LeftColumnLabwareInfo') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -44,10 +43,12 @@ describe('SelectTips', () => { recoveryCommands: { pickUpTips: mockPickUpTips, } as any, - failedPipetteInfo: { - data: { - channels: 8, - }, + failedPipetteUtils: { + failedPipetteInfo: { + data: { + channels: 8, + }, + } as any, } as any, failedLabwareUtils: { selectedTipLocations: { A1: null }, @@ -126,13 +127,23 @@ describe('SelectTips', () => { expect(mockGoBackPrevStep).toHaveBeenCalled() }) + it('renders expected banner text', () => { + render(props) + + screen.getByText( + "It's best to replace tips and select the last location used for tip pickup." + ) + }) + it('disables the tertiary button when the pipette has 96 channels', () => { props = { ...props, - failedPipetteInfo: { - data: { - channels: 96, - }, + failedPipetteUtils: { + failedPipetteInfo: { + data: { + channels: 96, + }, + } as any, } as any, } render(props) @@ -160,4 +171,49 @@ describe('SelectTips', () => { expect(primaryBtn[0]).toBeDisabled() }) + + it('does not render the tertiary button if a partial tip config is used', () => { + const mockFailedPipetteUtils = { + failedPipetteInfo: { + data: { + channels: 8, + }, + } as any, + isPartialTipConfigValid: true, + relevantActiveNozzleLayout: { + activeNozzles: ['H1', 'G1'], + startingNozzle: 'A1', + config: 'column', + }, + } as any + + render({ ...props, failedPipetteUtils: mockFailedPipetteUtils }) + + const tertiaryBtn = screen.queryByRole('button', { + name: 'Change location', + }) + expect(tertiaryBtn).not.toBeInTheDocument() + }) + + it('renders alternative banner text if partial tip config is used', () => { + const mockFailedPipetteUtils = { + failedPipetteInfo: { + data: { + channels: 8, + }, + } as any, + isPartialTipConfigValid: true, + relevantActiveNozzleLayout: { + activeNozzles: ['H1', 'G1'], + startingNozzle: 'A1', + config: 'column', + }, + } as any + + render({ ...props, failedPipetteUtils: mockFailedPipetteUtils }) + + screen.getByText( + 'Replace tips and select the last location used for partial tip pickup.' + ) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx index 9ac8c8dbc99..9df7f8e02ec 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TipSelection.test.tsx @@ -22,7 +22,9 @@ describe('TipSelection', () => { props = { ...mockRecoveryContentProps, allowTipSelection: true, - failedPipetteInfo: { data: { channels: 8 } } as any, + failedPipetteUtils: { + failedPipetteInfo: { data: { channels: 8 } } as any, + } as any, } vi.mocked(WellSelection).mockReturnValue(
    MOCK WELL SELECTION
    ) @@ -35,8 +37,11 @@ describe('TipSelection', () => { expect(vi.mocked(WellSelection)).toHaveBeenCalledWith( expect.objectContaining({ definition: props.failedLabwareUtils.tipSelectorDef, - selectedPrimaryWell: 'A1', - channels: props.failedPipetteInfo?.data.channels ?? 1, + selectedPrimaryWells: props.failedLabwareUtils.selectedTipLocations, + channels: + props.failedPipetteUtils.failedPipetteInfo?.data.channels ?? 1, + allowSelect: props.allowTipSelection, + pipetteNozzleDetails: undefined, }), {} ) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts deleted file mode 100644 index 7d49b51f931..00000000000 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { PipetteData, Instruments, Run } from '@opentrons/api-client' -import type { ErrorRecoveryFlowsProps } from '..' - -interface UseFailedCommandPipetteInfoProps { - failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] - runRecord?: Run - attachedInstruments?: Instruments -} - -// /instruments data for the pipette used in the failedCommand, if any. -export function getFailedCommandPipetteInfo({ - failedCommandByRunRecord, - runRecord, - attachedInstruments, -}: UseFailedCommandPipetteInfoProps): PipetteData | null { - if ( - failedCommandByRunRecord == null || - !('pipetteId' in failedCommandByRunRecord.params) - ) { - return null - } else { - const failedPipetteId = failedCommandByRunRecord.params.pipetteId - const runRecordPipette = runRecord?.data.pipettes.find( - pipette => pipette.id === failedPipetteId - ) - - const failedInstrumentInfo = attachedInstruments?.data.find( - instrument => - 'mount' in instrument && instrument.mount === runRecordPipette?.mount - ) as PipetteData - - return failedInstrumentInfo ?? null - } -} diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/index.ts b/app/src/organisms/ErrorRecoveryFlows/utils/index.ts index 53195d5d5fa..0a4db48783a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/index.ts @@ -1,3 +1,2 @@ export { getErrorKind } from './getErrorKind' -export { getFailedCommandPipetteInfo } from './getFailedCommandPipetteInfo' export { getNextStep, getNextSteps } from './getNextStep' diff --git a/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx b/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx index 602098f6418..0cb402f6ee8 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/SelectDestWells.tsx @@ -189,7 +189,7 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { ) ) }} - selectedPrimaryWell={Object.keys(selectedWells)[0]} + selectedPrimaryWells={selectedWells} selectWells={wellGroup => { if (Object.keys(wellGroup).length > 0) { setIsNumberWellsSelectedError(false) diff --git a/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx b/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx index e095654bc5a..a78ec884560 100644 --- a/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx +++ b/app/src/organisms/ODD/QuickTransferFlow/SelectSourceWells.tsx @@ -117,7 +117,7 @@ export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { ) ) }} - selectedPrimaryWell={Object.keys(selectedWells)[0]} + selectedPrimaryWells={selectedWells} selectWells={wellGroup => { setSelectedWells(prevWells => ({ ...prevWells, ...wellGroup })) }} diff --git a/app/src/organisms/WellSelection/index.tsx b/app/src/organisms/WellSelection/index.tsx index dbaa8e800d6..06daf9536a5 100644 --- a/app/src/organisms/WellSelection/index.tsx +++ b/app/src/organisms/WellSelection/index.tsx @@ -14,26 +14,32 @@ import type { WellFill, WellGroup, WellStroke } from '@opentrons/components' import type { LabwareDefinition2, PipetteChannels, + NozzleLayoutDetails, } from '@opentrons/shared-data' import type { GenericRect } from './types' interface WellSelectionProps { definition: LabwareDefinition2 deselectWells: (wells: string[]) => void - /* A well from which to derive the well set. - * If utilizing this component specifically in the context of a command, this should be the 'wellName'. */ - selectedPrimaryWell: string + /* The actual wells that are clicked. */ + selectedPrimaryWells: WellGroup selectWells: (wellGroup: WellGroup) => unknown channels: PipetteChannels + /* Highlight only valid wells given the current pipette nozzle configuration. */ + pipetteNozzleDetails?: NozzleLayoutDetails + /* Whether highlighting and selectWells() updates are permitted. */ + allowSelect?: boolean } export function WellSelection(props: WellSelectionProps): JSX.Element { const { definition, deselectWells, - selectedPrimaryWell, + selectedPrimaryWells, selectWells, channels, + pipetteNozzleDetails, + allowSelect = true, } = props const [highlightedWells, setHighlightedWells] = useState({}) @@ -51,6 +57,7 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { labwareDef: definition, wellName, channels, + pipetteNozzleDetails, }) if (!wellSet) { return acc @@ -72,54 +79,63 @@ export function WellSelection(props: WellSelectionProps): JSX.Element { } const handleSelectionMove: (rect: GenericRect) => void = rect => { - if (channels === 8 || channels === 96) { - const selectedWells = _getWellsFromRect(rect) - const allWellsForMulti: WellGroup = reduce( - selectedWells, - (acc: WellGroup, _, wellName: string): WellGroup => { - const wellSetForMulti = - getWellSetForMultichannel({ - labwareDef: definition, - wellName, - channels, - }) || [] - const channelWells = arrayToWellGroup(wellSetForMulti) - return { - ...acc, - ...channelWells, - } - }, - {} - ) - setHighlightedWells(allWellsForMulti) - } else { - setHighlightedWells(_getWellsFromRect(rect)) + if (allowSelect) { + if (channels === 8 || channels === 96) { + const selectedWells = _getWellsFromRect(rect) + const allWellsForMulti: WellGroup = reduce( + selectedWells, + (acc: WellGroup, _, wellName: string): WellGroup => { + const wellSetForMulti = + getWellSetForMultichannel({ + labwareDef: definition, + wellName, + channels, + pipetteNozzleDetails, + }) || [] + const channelWells = arrayToWellGroup(wellSetForMulti) + return { + ...acc, + ...channelWells, + } + }, + {} + ) + setHighlightedWells(allWellsForMulti) + } else { + setHighlightedWells(_getWellsFromRect(rect)) + } } } const handleSelectionDone: (rect: GenericRect) => void = rect => { const wells = _wellsFromSelected(_getWellsFromRect(rect)) - selectWells(wells) - setHighlightedWells({}) - } - - // For rendering, show all valid wells, not just primary wells - const buildAllSelectedWells = (): WellGroup => { - if (channels === 8 || channels === 96) { - const wellSet = getWellSetForMultichannel({ - labwareDef: definition, - wellName: selectedPrimaryWell, - channels, - }) - - return wellSet != null ? arrayToWellGroup(wellSet) : {} - } else { - return { [selectedPrimaryWell]: null } + if (allowSelect) { + selectWells(wells) + setHighlightedWells({}) } } - const allSelectedWells = buildAllSelectedWells() + // For rendering, show all valid wells, not just primary wells + const allSelectedWells = + channels === 8 || channels === 96 + ? reduce( + selectedPrimaryWells, + (acc, _, wellName): WellGroup => { + const wellSet = getWellSetForMultichannel({ + labwareDef: definition, + wellName, + channels, + pipetteNozzleDetails, + }) + if (!wellSet) { + return acc + } + return { ...acc, ...arrayToWellGroup(wellSet) } + }, + {} + ) + : selectedPrimaryWells const wellFill: WellFill = {} const wellStroke: WellStroke = {} From 33e53f8c2b5009c4d0f62a7df263975d6f022439 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 14 Oct 2024 09:47:09 -0400 Subject: [PATCH 052/101] feat(app): Add Error Recovery Mode toggle to desktop & ODD (#16471) Closes EXEC-765 Adds an option for toggling error recovery. If ER mode is disabled, the run fails instead of entering recovery mode. --- .../assets/localization/en/app_settings.json | 8 ++- .../AdvancedTab/EnableErrorRecoveryMode.tsx | 54 ++++++++++++++ .../EnableErrorRecoveryMode.test.tsx | 52 ++++++++++++++ .../RobotSettings/AdvancedTab/index.ts | 1 + .../RobotSettings/RobotSettingsAdvanced.tsx | 7 ++ .../RobotSettingsList.tsx | 11 +++ .../__tests__/RobotSettingsDashboard.test.tsx | 33 +++++++++ .../useErrorRecoverySettingsToggle.test.ts | 72 +++++++++++++++++++ app/src/resources/errorRecovery/index.ts | 1 + .../useErrorRecoverySettingsToggle.ts | 34 +++++++++ react-api-client/src/errorRecovery/index.ts | 2 + .../errorRecovery/useErrorRecoverySettings.ts | 30 ++++++++ .../useUpdateErrorRecoverySettings.ts | 54 ++++++++++++++ react-api-client/src/index.ts | 1 + 14 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/EnableErrorRecoveryMode.tsx create mode 100644 app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/__tests__/EnableErrorRecoveryMode.test.tsx create mode 100644 app/src/resources/errorRecovery/__tests__/useErrorRecoverySettingsToggle.test.ts create mode 100644 app/src/resources/errorRecovery/index.ts create mode 100644 app/src/resources/errorRecovery/useErrorRecoverySettingsToggle.ts create mode 100644 react-api-client/src/errorRecovery/index.ts create mode 100644 react-api-client/src/errorRecovery/useErrorRecoverySettings.ts create mode 100644 react-api-client/src/errorRecovery/useUpdateErrorRecoverySettings.ts diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 933fde16eea..1b3fcce0a07 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -1,10 +1,10 @@ { + "__dev_internal__enableLabwareCreator": "Enable App Labware Creator", + "__dev_internal__enableLocalization": "Enable App Localization", + "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", "__dev_internal__forceHttpPolling": "Poll all network requests instead of using MQTT", "__dev_internal__protocolStats": "Protocol Stats", "__dev_internal__protocolTimeline": "Protocol Timeline", - "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", - "__dev_internal__enableLabwareCreator": "Enable App Labware Creator", - "__dev_internal__enableLocalization": "Enable App Localization", "add_folder_button": "Add labware source folder", "add_ip_button": "Add", "add_ip_error": "Enter an IP Address or Hostname", @@ -36,6 +36,8 @@ "enable_dev_tools_description": "Enabling this setting opens Developer Tools on app launch, enables additional logging and gives access to feature flags.", "error_boundary_desktop_app_description": "You need to reload the app. Contact support with the following error message:", "error_boundary_title": "An unknown error has occurred", + "error_recovery_mode": "Error Recovery Mode", + "error_recovery_mode_description": "Pause on protocol errors instead of canceling the run.", "feature_flags": "Feature Flags", "general": "General", "heater_shaker_attach_description": "Display a reminder to attach the Heater-Shaker properly before running a test shake or using it in a protocol.", diff --git a/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/EnableErrorRecoveryMode.tsx b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/EnableErrorRecoveryMode.tsx new file mode 100644 index 00000000000..979dc155e6e --- /dev/null +++ b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/EnableErrorRecoveryMode.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + Box, + DIRECTION_COLUMN, + Flex, + JUSTIFY_SPACE_BETWEEN, + SPACING, + TYPOGRAPHY, + LegacyStyledText, +} from '@opentrons/components' + +import { ToggleButton } from '/app/atoms/buttons' +import { useErrorRecoverySettingsToggle } from '/app/resources/errorRecovery' + +export function EnableErrorRecoveryMode({ + isRobotBusy, +}: { + isRobotBusy: boolean +}): JSX.Element { + const { t } = useTranslation('app_settings') + const { isEREnabled, toggleERSettings } = useErrorRecoverySettingsToggle() + + return ( + + + + + {t('error_recovery_mode')} + + + {t('error_recovery_mode_description')} + + + + + + ) +} diff --git a/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/__tests__/EnableErrorRecoveryMode.test.tsx b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/__tests__/EnableErrorRecoveryMode.test.tsx new file mode 100644 index 00000000000..a2d4824951f --- /dev/null +++ b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/__tests__/EnableErrorRecoveryMode.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, vi, expect, beforeEach } from 'vitest' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { useErrorRecoverySettingsToggle } from '/app/resources/errorRecovery' +import { EnableErrorRecoveryMode } from '../EnableErrorRecoveryMode' +import type * as React from 'react' + +vi.mock('/app/resources/errorRecovery') + +const mockToggleERSettings = vi.fn() +const render = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('EnableErrorRecoveryMode', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { isRobotBusy: false } + + vi.mocked(useErrorRecoverySettingsToggle).mockReturnValue({ + isEREnabled: false, + toggleERSettings: mockToggleERSettings, + }) + }) + + it('should render text and toggle button', () => { + render(props) + screen.getByText('Error Recovery Mode') + screen.getByText('Pause on protocol errors instead of canceling the run.') + expect( + screen.getByLabelText('enable_error_recovery_mode') + ).toBeInTheDocument() + }) + + it('should call a mock function when clicking toggle button', () => { + render(props) + fireEvent.click(screen.getByLabelText('enable_error_recovery_mode')) + expect(mockToggleERSettings).toHaveBeenCalled() + }) + + it('should disable the toggle if the robot is busy', () => { + render({ isRobotBusy: true }) + expect(screen.getByLabelText('enable_error_recovery_mode')).toBeDisabled() + }) +}) diff --git a/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/index.ts b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/index.ts index e3359c3998b..d68585a08d3 100644 --- a/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/index.ts +++ b/app/src/organisms/Desktop/Devices/RobotSettings/AdvancedTab/index.ts @@ -12,3 +12,4 @@ export * from './Troubleshooting' export * from './UpdateRobotSoftware' export * from './UsageSettings' export * from './UseOlderAspirateBehavior' +export * from './EnableErrorRecoveryMode' diff --git a/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsAdvanced.tsx b/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsAdvanced.tsx index 5d8ee694784..feb67b08d9f 100644 --- a/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsAdvanced.tsx +++ b/app/src/organisms/Desktop/Devices/RobotSettings/RobotSettingsAdvanced.tsx @@ -18,6 +18,7 @@ import { DeviceReset, DisplayRobotName, EnableStatusLight, + EnableErrorRecoveryMode, FactoryMode, GantryHoming, LegacySettings, @@ -211,6 +212,12 @@ export function RobotSettingsAdvanced({ /> ) : null} + {isFlex ? ( + <> + + + + ) : null} @@ -177,6 +180,14 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { rightElement={} onClick={() => dispatch(toggleHistoricOffsets())} /> + } + onClick={toggleERSettings} + /> { return renderWithProviders( @@ -74,6 +77,10 @@ describe('RobotSettingsDashboard', () => { toggleLights: mockToggleLights, }) vi.mocked(useNetworkConnection).mockReturnValue({} as any) + vi.mocked(useErrorRecoverySettingsToggle).mockReturnValue({ + isEREnabled: true, + toggleERSettings: mockToggleER, + }) }) afterEach(() => { @@ -92,6 +99,7 @@ describe('RobotSettingsDashboard', () => { screen.getByText('Robot System Version') screen.getByText('Network Settings') screen.getByText('Status LEDs') + screen.getByText('Error Recovery Mode') screen.getByText( 'Control the strip of color lights on the front of the robot.' ) @@ -139,6 +147,31 @@ describe('RobotSettingsDashboard', () => { ).toHaveTextContent('On') }) + it('should render appropriate error recovery mode copy, and calls the toggle', () => { + render() + const toggle = screen.getByTestId('RobotSettingButton_error_recovery_mode') + fireEvent.click(toggle) + expect(mockToggleER).toHaveBeenCalled() + }) + + it('should render the on toggle when ER mode is enabled', () => { + render() + expect( + screen.getByTestId('RobotSettingButton_error_recovery_mode') + ).toHaveTextContent('On') + }) + + it('should render the off toggle when ER mode is disabled', () => { + vi.mocked(useErrorRecoverySettingsToggle).mockReturnValue({ + isEREnabled: false, + toggleERSettings: mockToggleER, + }) + render() + expect( + screen.getByTestId('RobotSettingButton_error_recovery_mode') + ).toHaveTextContent('Off') + }) + it('should render component when tapping network settings', () => { render() const button = screen.getByText('Network Settings') diff --git a/app/src/resources/errorRecovery/__tests__/useErrorRecoverySettingsToggle.test.ts b/app/src/resources/errorRecovery/__tests__/useErrorRecoverySettingsToggle.test.ts new file mode 100644 index 00000000000..c2b7b9c19d5 --- /dev/null +++ b/app/src/resources/errorRecovery/__tests__/useErrorRecoverySettingsToggle.test.ts @@ -0,0 +1,72 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + useErrorRecoverySettings, + useUpdateErrorRecoverySettings, +} from '@opentrons/react-api-client' + +import { useErrorRecoverySettingsToggle } from '..' + +vi.mock('@opentrons/react-api-client') + +describe('useErrorRecoverySettingsToggle', () => { + beforeEach(() => { + vi.mocked(useErrorRecoverySettings).mockReturnValue({ + data: undefined, + } as any) + vi.mocked(useUpdateErrorRecoverySettings).mockReturnValue({ + useErrorRecoverySettings: vi.fn(), + } as any) + }) + + it('should initialize with default value', () => { + const { result } = renderHook(() => useErrorRecoverySettingsToggle()) + + expect(result.current.isEREnabled).toBe(true) + }) + + it('should update isEREnabled when data changes', () => { + const { result, rerender } = renderHook(() => + useErrorRecoverySettingsToggle() + ) + + expect(result.current.isEREnabled).toBe(true) + + vi.mocked(useErrorRecoverySettings).mockReturnValue({ + data: { data: { enabled: false } }, + } as any) + rerender() + + expect(result.current.isEREnabled).toBe(false) + }) + + it('should toggle ER settings', () => { + const mockUpdateSettings = vi.fn() + vi.mocked(useErrorRecoverySettings).mockReturnValue({ + data: { data: { enabled: true } }, + } as any) + vi.mocked(useUpdateErrorRecoverySettings).mockReturnValue({ + updateErrorRecoverySettings: mockUpdateSettings, + } as any) + + const { result } = renderHook(() => useErrorRecoverySettingsToggle()) + + expect(result.current.isEREnabled).toBe(true) + + act(() => { + result.current.toggleERSettings() + }) + + expect(result.current.isEREnabled).toBe(false) + expect(mockUpdateSettings).toHaveBeenCalledWith({ + data: { enabled: false }, + }) + + act(() => { + result.current.toggleERSettings() + }) + + expect(result.current.isEREnabled).toBe(true) + expect(mockUpdateSettings).toHaveBeenCalledWith({ data: { enabled: true } }) + }) +}) diff --git a/app/src/resources/errorRecovery/index.ts b/app/src/resources/errorRecovery/index.ts new file mode 100644 index 00000000000..ce24a73c1db --- /dev/null +++ b/app/src/resources/errorRecovery/index.ts @@ -0,0 +1 @@ +export * from './useErrorRecoverySettingsToggle' diff --git a/app/src/resources/errorRecovery/useErrorRecoverySettingsToggle.ts b/app/src/resources/errorRecovery/useErrorRecoverySettingsToggle.ts new file mode 100644 index 00000000000..da0e5514098 --- /dev/null +++ b/app/src/resources/errorRecovery/useErrorRecoverySettingsToggle.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' + +import { + useErrorRecoverySettings, + useUpdateErrorRecoverySettings, +} from '@opentrons/react-api-client' + +export interface UseERSettingsToggleResult { + isEREnabled: boolean + toggleERSettings: () => void +} + +export function useErrorRecoverySettingsToggle(): UseERSettingsToggleResult { + const [isEREnabled, setIsEREnabled] = useState(true) + + const { data } = useErrorRecoverySettings() + const { updateErrorRecoverySettings } = useUpdateErrorRecoverySettings() + const isEREnabledData = data?.data.enabled ?? true + + useEffect(() => { + if (isEREnabledData != null) { + setIsEREnabled(isEREnabledData as boolean) + } + }, [isEREnabledData]) + + const toggleERSettings = (): void => { + setIsEREnabled(isEREnabled => { + updateErrorRecoverySettings({ data: { enabled: !isEREnabled } }) + return !isEREnabled + }) + } + + return { isEREnabled, toggleERSettings } +} diff --git a/react-api-client/src/errorRecovery/index.ts b/react-api-client/src/errorRecovery/index.ts new file mode 100644 index 00000000000..590701b62a0 --- /dev/null +++ b/react-api-client/src/errorRecovery/index.ts @@ -0,0 +1,2 @@ +export * from './useErrorRecoverySettings' +export * from './useUpdateErrorRecoverySettings' diff --git a/react-api-client/src/errorRecovery/useErrorRecoverySettings.ts b/react-api-client/src/errorRecovery/useErrorRecoverySettings.ts new file mode 100644 index 00000000000..b33b450148e --- /dev/null +++ b/react-api-client/src/errorRecovery/useErrorRecoverySettings.ts @@ -0,0 +1,30 @@ +import { useQuery } from 'react-query' + +import { getErrorRecoverySettings } from '@opentrons/api-client' + +import { useHost } from '../api' + +import type { UseQueryOptions, UseQueryResult } from 'react-query' +import type { AxiosError } from 'axios' +import type { + HostConfig, + ErrorRecoverySettingsResponse, +} from '@opentrons/api-client' + +export function useErrorRecoverySettings( + options: UseQueryOptions = {} +): UseQueryResult { + const host = useHost() + const query = useQuery( + [host, 'errorRecovery', 'settings'], + () => + getErrorRecoverySettings(host as HostConfig) + .then(response => response.data) + .catch((e: AxiosError) => { + throw e + }), + { enabled: host !== null, ...options } + ) + + return query +} diff --git a/react-api-client/src/errorRecovery/useUpdateErrorRecoverySettings.ts b/react-api-client/src/errorRecovery/useUpdateErrorRecoverySettings.ts new file mode 100644 index 00000000000..3956fd620fc --- /dev/null +++ b/react-api-client/src/errorRecovery/useUpdateErrorRecoverySettings.ts @@ -0,0 +1,54 @@ +import { useMutation } from 'react-query' + +import { updateErrorRecoverySettings } from '@opentrons/api-client' + +import { useHost } from '../api' + +import type { + UseMutationOptions, + UseMutationResult, + UseMutateFunction, +} from 'react-query' +import type { AxiosError } from 'axios' +import type { + HostConfig, + ErrorRecoverySettingsResponse, + ErrorRecoverySettingsRequest, +} from '@opentrons/api-client' + +export type UseUpdateErrorRecoverySettingsMutationResult = UseMutationResult< + ErrorRecoverySettingsResponse, + AxiosError, + ErrorRecoverySettingsRequest +> & { + updateErrorRecoverySettings: UseMutateFunction< + ErrorRecoverySettingsResponse, + AxiosError, + ErrorRecoverySettingsRequest + > +} + +export function useUpdateErrorRecoverySettings( + options: UseMutationOptions< + ErrorRecoverySettingsResponse, + AxiosError, + ErrorRecoverySettingsRequest + > = {} +): UseUpdateErrorRecoverySettingsMutationResult { + const host = useHost() + const mutation = useMutation( + [host, 'errorRecovery', 'settings'], + (settings: ErrorRecoverySettingsRequest) => + updateErrorRecoverySettings(host as HostConfig, settings) + .then(response => response.data) + .catch((e: AxiosError) => { + throw e + }), + options + ) + + return { + ...mutation, + updateErrorRecoverySettings: mutation.mutateAsync, + } +} diff --git a/react-api-client/src/index.ts b/react-api-client/src/index.ts index fbfd11ad355..fc9bb741b14 100644 --- a/react-api-client/src/index.ts +++ b/react-api-client/src/index.ts @@ -17,3 +17,4 @@ export * from './sessions' export * from './subsystems' export * from './system' export * from './client_data' +export * from './errorRecovery' From 6f539244c295e2331c1aaa009c80fd53da6d6157 Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Mon, 14 Oct 2024 11:43:17 -0400 Subject: [PATCH 053/101] feat(shared-data): add Pydantic models for liquid class schema (#16459) This PR implements the liquid class schema as shared data Pydantic models for usage in other parts of the codebase. --- .../fixtures/fixture_glycerol50.json | 14 +- shared-data/liquid-class/schemas/1.json | 6 +- .../liquid_classes/__init__.py | 1 + .../liquid_classes/liquid_class_definition.py | 352 ++++++++++++++++++ .../python/tests/liquid_classes/__init__.py | 0 .../tests/liquid_classes/test_load_schema.py | 16 + 6 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 shared-data/python/opentrons_shared_data/liquid_classes/__init__.py create mode 100644 shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py create mode 100644 shared-data/python/tests/liquid_classes/__init__.py create mode 100644 shared-data/python/tests/liquid_classes/test_load_schema.py diff --git a/shared-data/liquid-class/fixtures/fixture_glycerol50.json b/shared-data/liquid-class/fixtures/fixture_glycerol50.json index fd655c66a61..364715b99eb 100644 --- a/shared-data/liquid-class/fixtures/fixture_glycerol50.json +++ b/shared-data/liquid-class/fixtures/fixture_glycerol50.json @@ -152,7 +152,12 @@ "10": 7, "20": 10 }, - "delay": 1 + "delay": { + "enable": true, + "params": { + "duration": 2.5 + } + } }, "multiDispense": { "submerge": { @@ -212,13 +217,6 @@ "10": 40, "20": 30 }, - "mix": { - "enable": true, - "params": { - "repetitions": 3, - "volume": 15 - } - }, "conditioningByVolume": { "default": 10, "5": 5 diff --git a/shared-data/liquid-class/schemas/1.json b/shared-data/liquid-class/schemas/1.json index 6af0ff9babe..bd987442b37 100644 --- a/shared-data/liquid-class/schemas/1.json +++ b/shared-data/liquid-class/schemas/1.json @@ -359,8 +359,7 @@ "$ref": "#/definitions/pushOutByVolume" }, "delay": { - "$ref": "#/definitions/positiveNumber", - "description": "Delay after dispense, in seconds." + "$ref": "#/definitions/delay" } }, "required": [ @@ -394,9 +393,6 @@ "flowRateByVolume": { "$ref": "#/definitions/flowRateByVolume" }, - "mix": { - "$ref": "#/definitions/mix" - }, "conditioningByVolume": { "$ref": "#/definitions/conditioningByVolume" }, diff --git a/shared-data/python/opentrons_shared_data/liquid_classes/__init__.py b/shared-data/python/opentrons_shared_data/liquid_classes/__init__.py new file mode 100644 index 00000000000..b17bec7bf60 --- /dev/null +++ b/shared-data/python/opentrons_shared_data/liquid_classes/__init__.py @@ -0,0 +1 @@ +"""opentrons_shared_data.liquid_classes: types and functions for accessing liquid class definitions.""" diff --git a/shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py b/shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py new file mode 100644 index 00000000000..6b4599227ed --- /dev/null +++ b/shared-data/python/opentrons_shared_data/liquid_classes/liquid_class_definition.py @@ -0,0 +1,352 @@ +"""Python shared data models for liquid class definitions.""" + +from enum import Enum +from typing import TYPE_CHECKING, Literal, Union, Optional, Dict, Any, Sequence + +from pydantic import ( + BaseModel, + validator, + Field, + conint, + confloat, + StrictInt, + StrictFloat, +) + + +if TYPE_CHECKING: + _StrictNonNegativeInt = int + _StrictNonNegativeFloat = float +else: + _StrictNonNegativeInt = conint(strict=True, ge=0) + _StrictNonNegativeFloat = confloat(strict=True, ge=0.0) + + +_Number = Union[StrictInt, StrictFloat] +"""JSON number type, written to preserve lack of decimal point""" + +_NonNegativeNumber = Union[_StrictNonNegativeInt, _StrictNonNegativeFloat] +"""Non-negative JSON number type, written to preserve lack of decimal point.""" + +LiquidHandlingPropertyByVolume = Dict[str, _NonNegativeNumber] +"""Settings for liquid class settings keyed by target aspiration/dispense volume.""" + + +class PositionReference(Enum): + """Positional reference for liquid handling operations.""" + + WELL_BOTTOM = "well-bottom" + WELL_TOP = "well-top" + WELL_CENTER = "well-center" + LIQUID_MENISCUS = "liquid-meniscus" + + +class BlowoutLocation(Enum): + """Location for blowout during a transfer function.""" + + SOURCE = "source" + DESTINATION = "destination" + TRASH = "trash" + + +class Coordinate(BaseModel): + """Three-dimensional coordinates.""" + + x: _Number + y: _Number + z: _Number + + +class DelayParams(BaseModel): + """Parameters for delay.""" + + duration: _NonNegativeNumber = Field( + ..., description="Duration of delay, in seconds." + ) + + +class DelayProperties(BaseModel): + """Shared properties for delay..""" + + enable: bool = Field(..., description="Whether delay is enabled.") + params: Optional[DelayParams] = Field( + None, description="Parameters for the delay function." + ) + + @validator("params") + def _validate_params( + cls, v: Optional[DelayParams], values: Dict[str, Any] + ) -> Optional[DelayParams]: + if v is None and values["enable"]: + raise ValueError("If enable is true parameters for delay must be defined.") + return v + + +class TouchTipParams(BaseModel): + """Parameters for touch-tip.""" + + zOffset: _Number = Field( + ..., + description="Offset from the top of the well for touch-tip, in millimeters.", + ) + mmToEdge: _Number = Field( + ..., description="Offset away from the the well edge, in millimeters." + ) + speed: _NonNegativeNumber = Field( + ..., description="Touch-tip speed, in millimeters per second." + ) + + +class TouchTipProperties(BaseModel): + """Shared properties for the touch-tip function.""" + + enable: bool = Field(..., description="Whether touch-tip is enabled.") + params: Optional[TouchTipParams] = Field( + None, description="Parameters for the touch-tip function." + ) + + @validator("params") + def _validate_params( + cls, v: Optional[TouchTipParams], values: Dict[str, Any] + ) -> Optional[TouchTipParams]: + if v is None and values["enable"]: + raise ValueError( + "If enable is true parameters for touch tip must be defined." + ) + return v + + +class MixParams(BaseModel): + """Parameters for mix.""" + + repetitions: _StrictNonNegativeInt = Field( + ..., description="Number of mixing repetitions." + ) + volume: _Number = Field(..., description="Volume used for mixing, in microliters.") + + +class MixProperties(BaseModel): + """Mixing properties.""" + + enable: bool = Field(..., description="Whether mix is enabled.") + params: Optional[MixParams] = Field( + None, description="Parameters for the mix function." + ) + + @validator("params") + def _validate_params( + cls, v: Optional[MixParams], values: Dict[str, Any] + ) -> Optional[MixParams]: + if v is None and values["enable"]: + raise ValueError("If enable is true parameters for mix must be defined.") + return v + + +class BlowoutParams(BaseModel): + """Parameters for blowout.""" + + location: BlowoutLocation = Field( + ..., description="Location well or trash entity for blow out." + ) + flowRate: _NonNegativeNumber = Field( + ..., description="Flow rate for blow out, in microliters per second." + ) + + +class BlowoutProperties(BaseModel): + """Blowout properties.""" + + enable: bool = Field(..., description="Whether blow-out is enabled.") + params: Optional[BlowoutParams] = Field( + None, description="Parameters for the blowout function." + ) + + @validator("params") + def _validate_params( + cls, v: Optional[BlowoutParams], values: Dict[str, Any] + ) -> Optional[BlowoutParams]: + if v is None and values["enable"]: + raise ValueError( + "If enable is true parameters for blowout must be defined." + ) + return v + + +class Submerge(BaseModel): + """Shared properties for the submerge function before aspiration or dispense.""" + + positionReference: PositionReference = Field( + ..., description="Position reference for submerge." + ) + offset: Coordinate = Field(..., description="Relative offset for submerge.") + speed: _NonNegativeNumber = Field( + ..., description="Speed of submerging, in millimeters per second." + ) + delay: DelayProperties = Field(..., description="Delay settings for submerge.") + + +class RetractAspirate(BaseModel): + """Shared properties for the retract function after aspiration.""" + + positionReference: PositionReference = Field( + ..., description="Position reference for retract after aspirate." + ) + offset: Coordinate = Field( + ..., description="Relative offset for retract after aspirate." + ) + speed: _NonNegativeNumber = Field( + ..., description="Speed of retraction, in millimeters per second." + ) + airGapByVolume: LiquidHandlingPropertyByVolume = Field( + ..., description="Settings for air gap keyed by target aspiration volume." + ) + touchTip: TouchTipProperties = Field( + ..., description="Touch tip settings for retract after aspirate." + ) + delay: DelayProperties = Field( + ..., description="Delay settings for retract after aspirate." + ) + + +class RetractDispense(BaseModel): + """Shared properties for the retract function after dispense.""" + + positionReference: PositionReference = Field( + ..., description="Position reference for retract after dispense." + ) + offset: Coordinate = Field( + ..., description="Relative offset for retract after dispense." + ) + speed: _NonNegativeNumber = Field( + ..., description="Speed of retraction, in millimeters per second." + ) + airGapByVolume: LiquidHandlingPropertyByVolume = Field( + ..., description="Settings for air gap keyed by target aspiration volume." + ) + blowout: BlowoutProperties = Field( + ..., description="Blowout properties for retract after dispense." + ) + touchTip: TouchTipProperties = Field( + ..., description="Touch tip settings for retract after dispense." + ) + delay: DelayProperties = Field( + ..., description="Delay settings for retract after dispense." + ) + + +class AspirateProperties(BaseModel): + """Properties specific to the aspirate function.""" + + submerge: Submerge = Field(..., description="Submerge settings for aspirate.") + retract: RetractAspirate = Field( + ..., description="Pipette retract settings after an aspirate." + ) + positionReference: PositionReference = Field( + ..., description="Position reference for aspiration." + ) + offset: Coordinate = Field(..., description="Relative offset for aspiration.") + flowRateByVolume: LiquidHandlingPropertyByVolume = Field( + ..., + description="Settings for flow rate keyed by target aspiration volume.", + ) + preWet: bool = Field(..., description="Whether to perform a pre-wet action.") + mix: MixProperties = Field( + ..., description="Mixing settings for before an aspirate" + ) + delay: DelayProperties = Field(..., description="Delay settings after an aspirate") + + +class SingleDispenseProperties(BaseModel): + """Properties specific to the single-dispense function.""" + + submerge: Submerge = Field( + ..., description="Submerge settings for single dispense." + ) + retract: RetractDispense = Field( + ..., description="Pipette retract settings after a single dispense." + ) + positionReference: PositionReference = Field( + ..., description="Position reference for single dispense." + ) + offset: Coordinate = Field(..., description="Relative offset for single dispense.") + flowRateByVolume: LiquidHandlingPropertyByVolume = Field( + ..., + description="Settings for flow rate keyed by target dispense volume.", + ) + mix: MixProperties = Field(..., description="Mixing settings for after a dispense") + pushOutByVolume: LiquidHandlingPropertyByVolume = Field( + ..., description="Settings for pushout keyed by target dispense volume." + ) + delay: DelayProperties = Field(..., description="Delay after dispense, in seconds.") + + +class MultiDispenseProperties(BaseModel): + """Properties specific to the multi-dispense function.""" + + submerge: Submerge = Field(..., description="Submerge settings for multi-dispense.") + retract: RetractDispense = Field( + ..., description="Pipette retract settings after a multi-dispense." + ) + positionReference: PositionReference = Field( + ..., description="Position reference for multi-dispense." + ) + offset: Coordinate = Field( + ..., description="Relative offset for single multi-dispense." + ) + flowRateByVolume: LiquidHandlingPropertyByVolume = Field( + ..., + description="Settings for flow rate keyed by target dispense volume.", + ) + conditioningByVolume: LiquidHandlingPropertyByVolume = Field( + ..., + description="Settings for conditioning volume keyed by target dispense volume.", + ) + disposalByVolume: LiquidHandlingPropertyByVolume = Field( + ..., description="Settings for disposal volume keyed by target dispense volume." + ) + delay: DelayProperties = Field( + ..., description="Delay settings after each dispense" + ) + + +class ByTipTypeSetting(BaseModel): + """Settings for each kind of tip this pipette can use.""" + + tipType: str = Field( + ..., + description="The tip type whose properties will be used when handling this specific liquid class with this pipette", + ) + aspirate: AspirateProperties = Field( + ..., description="Aspirate parameters for this tip type." + ) + singleDispense: SingleDispenseProperties = Field( + ..., description="Single dispense parameters for this tip type." + ) + multiDispense: Optional[MultiDispenseProperties] = Field( + None, description="Optional multi-dispense parameters for this tip type." + ) + + +class ByPipetteSetting(BaseModel): + """The settings for this liquid class when used with a specific kind of pipette.""" + + pipetteModel: str = Field(..., description="The pipette model this applies to.") + byTipType: Sequence[ByTipTypeSetting] = Field( + ..., description="Settings for each kind of tip this pipette can use" + ) + + +class LiquidClassSchemaV1(BaseModel): + """Defines a single liquid class's properties for liquid handling functions.""" + + liquidName: str = Field( + ..., description="The name of the liquid (e.g., water, ethanol, serum)." + ) + schemaVersion: Literal[1] = Field( + ..., description="Which schema version a liquid class is using" + ) + namespace: str = Field(...) + byPipette: Sequence[ByPipetteSetting] = Field( + ..., + description="Liquid class settings by each pipette compatible with this liquid class.", + ) diff --git a/shared-data/python/tests/liquid_classes/__init__.py b/shared-data/python/tests/liquid_classes/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shared-data/python/tests/liquid_classes/test_load_schema.py b/shared-data/python/tests/liquid_classes/test_load_schema.py new file mode 100644 index 00000000000..c29393a8b2b --- /dev/null +++ b/shared-data/python/tests/liquid_classes/test_load_schema.py @@ -0,0 +1,16 @@ +import json + +from opentrons_shared_data import load_shared_data +from opentrons_shared_data.liquid_classes.liquid_class_definition import ( + LiquidClassSchemaV1, +) + + +def test_load_liquid_class_schema_v1() -> None: + fixture_data = load_shared_data("liquid-class/fixtures/fixture_glycerol50.json") + liquid_class_model = LiquidClassSchemaV1.parse_raw(fixture_data) + liquid_class_def_from_model = json.loads( + liquid_class_model.json(exclude_unset=True) + ) + expected_liquid_class_def = json.loads(fixture_data) + assert liquid_class_def_from_model == expected_liquid_class_def From 0b88d87d6419ad8923c3ef5f5f22041929e16192 Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Mon, 14 Oct 2024 15:41:21 -0400 Subject: [PATCH 054/101] refactor(api): Port `TipState`'s nozzle layout to `StateUpdate` (#16479) --- .../protocol_engine/commands/__init__.py | 2 - .../commands/command_unions.py | 2 - .../commands/configure_nozzle_layout.py | 18 +- .../commands/configuring_common.py | 13 - .../opentrons/protocol_engine/state/tips.py | 21 +- .../commands/test_configure_nozzle_layout.py | 6 +- .../protocol_engine/state/test_tip_state.py | 264 +++++++++--------- 7 files changed, 148 insertions(+), 178 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index d0550fce8c5..b8ad7ab0b57 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -306,7 +306,6 @@ ConfigureNozzleLayoutCreate, ConfigureNozzleLayoutParams, ConfigureNozzleLayoutResult, - ConfigureNozzleLayoutPrivateResult, ConfigureNozzleLayoutCommandType, ) @@ -569,7 +568,6 @@ "ConfigureNozzleLayoutParams", "ConfigureNozzleLayoutResult", "ConfigureNozzleLayoutCommandType", - "ConfigureNozzleLayoutPrivateResult", # get pipette tip presence bundle "GetTipPresence", "GetTipPresenceCreate", diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 2c7f768945f..80df6710f8b 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -288,7 +288,6 @@ ConfigureNozzleLayoutParams, ConfigureNozzleLayoutResult, ConfigureNozzleLayoutCommandType, - ConfigureNozzleLayoutPrivateResult, ) from .verify_tip_presence import ( @@ -709,7 +708,6 @@ None, LoadPipettePrivateResult, ConfigureForVolumePrivateResult, - ConfigureNozzleLayoutPrivateResult, ] # All `DefinedErrorData`s that implementations will actually return in practice. diff --git a/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py b/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py index 0d9a5e8c5e7..d04eee55c94 100644 --- a/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py +++ b/api/src/opentrons/protocol_engine/commands/configure_nozzle_layout.py @@ -10,9 +10,6 @@ ) from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence -from .configuring_common import ( - PipetteNozzleLayoutResultMixin, -) from ..types import ( AllNozzleLayoutConfiguration, SingleNozzleLayoutConfiguration, @@ -40,12 +37,6 @@ class ConfigureNozzleLayoutParams(PipetteIdMixin): ] -class ConfigureNozzleLayoutPrivateResult(PipetteNozzleLayoutResultMixin): - """Result sent to the store but not serialized.""" - - pass - - class ConfigureNozzleLayoutResult(BaseModel): """Result data from execution of an configureNozzleLayout command.""" @@ -55,7 +46,7 @@ class ConfigureNozzleLayoutResult(BaseModel): class ConfigureNozzleLayoutImplementation( AbstractCommandImpl[ ConfigureNozzleLayoutParams, - SuccessData[ConfigureNozzleLayoutResult, ConfigureNozzleLayoutPrivateResult], + SuccessData[ConfigureNozzleLayoutResult, None], ] ): """Configure nozzle layout command implementation.""" @@ -68,7 +59,7 @@ def __init__( async def execute( self, params: ConfigureNozzleLayoutParams - ) -> SuccessData[ConfigureNozzleLayoutResult, ConfigureNozzleLayoutPrivateResult]: + ) -> SuccessData[ConfigureNozzleLayoutResult, None]: """Check that requested pipette can support the requested nozzle layout.""" primary_nozzle = params.configurationParams.dict().get("primaryNozzle") front_right_nozzle = params.configurationParams.dict().get("frontRightNozzle") @@ -93,10 +84,7 @@ async def execute( return SuccessData( public=ConfigureNozzleLayoutResult(), - private=ConfigureNozzleLayoutPrivateResult( - pipette_id=params.pipetteId, - nozzle_map=nozzle_map, - ), + private=None, state_update=update_state, ) diff --git a/api/src/opentrons/protocol_engine/commands/configuring_common.py b/api/src/opentrons/protocol_engine/commands/configuring_common.py index 6998bcbac7b..f69cf41fef6 100644 --- a/api/src/opentrons/protocol_engine/commands/configuring_common.py +++ b/api/src/opentrons/protocol_engine/commands/configuring_common.py @@ -1,9 +1,6 @@ """Common configuration command base models.""" from dataclasses import dataclass -from opentrons.hardware_control.nozzle_manager import ( - NozzleMap, -) from ..resources import pipette_data_provider @@ -14,13 +11,3 @@ class PipetteConfigUpdateResultMixin: pipette_id: str serial_number: str config: pipette_data_provider.LoadedStaticPipetteData - - -@dataclass -class PipetteNozzleLayoutResultMixin: - """A nozzle layout result for updating the pipette state.""" - - pipette_id: str - - nozzle_map: NozzleMap - """A dataclass object holding information about the current nozzle configuration.""" diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index 4ed78c1df96..f744b1a01b4 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -11,10 +11,7 @@ Command, LoadLabwareResult, ) -from ..commands.configuring_common import ( - PipetteConfigUpdateResultMixin, - PipetteNozzleLayoutResultMixin, -) +from ..commands.configuring_common import PipetteConfigUpdateResultMixin from opentrons.hardware_control.nozzle_manager import NozzleMap @@ -82,13 +79,6 @@ def handle_action(self, action: Action) -> None: self._handle_succeeded_command(action.command) - if isinstance(action.private_result, PipetteNozzleLayoutResultMixin): - pipette_id = action.private_result.pipette_id - nozzle_map = action.private_result.nozzle_map - pipette_info = self._state.pipette_info_by_pipette_id[pipette_id] - pipette_info.active_channels = nozzle_map.tip_count - pipette_info.nozzle_map = nozzle_map - elif isinstance(action, ResetTipsAction): labware_id = action.labware_id @@ -121,6 +111,15 @@ def _handle_state_update(self, state_update: update_types.StateUpdate) -> None: well_name=state_update.tips_used.well_name, ) + if state_update.pipette_nozzle_map != update_types.NO_CHANGE: + pipette_info = self._state.pipette_info_by_pipette_id[ + state_update.pipette_nozzle_map.pipette_id + ] + pipette_info.active_channels = ( + state_update.pipette_nozzle_map.nozzle_map.tip_count + ) + pipette_info.nozzle_map = state_update.pipette_nozzle_map.nozzle_map + def _set_used_tips( # noqa: C901 self, pipette_id: str, well_name: str, labware_id: str ) -> None: diff --git a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py index d4a9c671dd3..e72b659a83c 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py +++ b/api/tests/opentrons/protocol_engine/commands/test_configure_nozzle_layout.py @@ -19,7 +19,6 @@ from opentrons.protocol_engine.commands.configure_nozzle_layout import ( ConfigureNozzleLayoutParams, ConfigureNozzleLayoutResult, - ConfigureNozzleLayoutPrivateResult, ConfigureNozzleLayoutImplementation, ) @@ -146,10 +145,7 @@ async def test_configure_nozzle_layout_implementation( assert result == SuccessData( public=ConfigureNozzleLayoutResult(), - private=ConfigureNozzleLayoutPrivateResult( - pipette_id="pipette-id", - nozzle_map=expected_nozzlemap, - ), + private=None, state_update=StateUpdate( pipette_nozzle_map=PipetteNozzleMapUpdate( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index dc603ac4ca8..fb07e4696ff 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -978,17 +978,17 @@ def test_active_channels( ) # Configure nozzle for partial configuration - configure_nozzle_layout_cmd = commands.ConfigureNozzleLayout.construct( # type: ignore[call-arg] - result=commands.ConfigureNozzleLayoutResult() - ) - configure_nozzle_private_result = commands.ConfigureNozzleLayoutPrivateResult( - pipette_id="pipette-id", - nozzle_map=nozzle_map, + state_update = update_types.StateUpdate( + pipette_nozzle_map=update_types.PipetteNozzleMapUpdate( + pipette_id="pipette-id", + nozzle_map=nozzle_map, + ) ) subject.handle_action( actions.SucceedCommandAction( - private_result=configure_nozzle_private_result, - command=configure_nozzle_layout_cmd, + command=_dummy_command(), + private_result=None, + state_update=state_update, ) ) assert ( @@ -1043,29 +1043,38 @@ def test_next_tip_uses_active_channels( ) # Configure nozzle for partial configuration - configure_nozzle_layout_cmd = commands.ConfigureNozzleLayout.construct( # type: ignore[call-arg] - result=commands.ConfigureNozzleLayoutResult() - ) - configure_nozzle_private_result = commands.ConfigureNozzleLayoutPrivateResult( - pipette_id="pipette-id", - nozzle_map=NozzleMap.build( - physical_nozzles=NINETY_SIX_MAP, - physical_rows=NINETY_SIX_ROWS, - physical_columns=NINETY_SIX_COLS, - starting_nozzle="A12", - back_left_nozzle="A12", - front_right_nozzle="H12", - valid_nozzle_maps=ValidNozzleMaps( - maps={ - "A12_H12": ["A12", "B12", "C12", "D12", "E12", "F12", "G12", "H12"] - } + state_update = update_types.StateUpdate( + pipette_nozzle_map=update_types.PipetteNozzleMapUpdate( + pipette_id="pipette-id", + nozzle_map=NozzleMap.build( + physical_nozzles=NINETY_SIX_MAP, + physical_rows=NINETY_SIX_ROWS, + physical_columns=NINETY_SIX_COLS, + starting_nozzle="A12", + back_left_nozzle="A12", + front_right_nozzle="H12", + valid_nozzle_maps=ValidNozzleMaps( + maps={ + "A12_H12": [ + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12", + ] + } + ), ), - ), + ) ) subject.handle_action( actions.SucceedCommandAction( - private_result=configure_nozzle_private_result, - command=configure_nozzle_layout_cmd, + command=_dummy_command(), + private_result=None, + state_update=state_update, ) ) # Pick up partial tips @@ -1162,78 +1171,77 @@ def _assert_and_pickup(well: str, nozzle_map: NozzleMap) -> None: ) ) - # Configure nozzle for partial configurations - configure_nozzle_layout_cmd = commands.ConfigureNozzleLayout.construct( # type: ignore[call-arg] - result=commands.ConfigureNozzleLayoutResult() - ) - def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleMap: - configure_nozzle_private_result = commands.ConfigureNozzleLayoutPrivateResult( - pipette_id="pipette-id", - nozzle_map=NozzleMap.build( - physical_nozzles=NINETY_SIX_MAP, - physical_rows=NINETY_SIX_ROWS, - physical_columns=NINETY_SIX_COLS, - starting_nozzle=start, - back_left_nozzle=back_l, - front_right_nozzle=front_r, - valid_nozzle_maps=ValidNozzleMaps( - maps={ - "A1": ["A1"], - "H1": ["H1"], - "A12": ["A12"], - "H12": ["H12"], - "A1_H3": [ - "A1", - "A2", - "A3", - "B1", - "B2", - "B3", - "C1", - "C2", - "C3", - "D1", - "D2", - "D3", - "E1", - "E2", - "E3", - "F1", - "F2", - "F3", - "G1", - "G2", - "G3", - "H1", - "H2", - "H3", - ], - "A1_F2": [ - "A1", - "A2", - "B1", - "B2", - "C1", - "C2", - "D1", - "D2", - "E1", - "E2", - "F1", - "F2", - ], - } - ), + nozzle_map = NozzleMap.build( + physical_nozzles=NINETY_SIX_MAP, + physical_rows=NINETY_SIX_ROWS, + physical_columns=NINETY_SIX_COLS, + starting_nozzle=start, + back_left_nozzle=back_l, + front_right_nozzle=front_r, + valid_nozzle_maps=ValidNozzleMaps( + maps={ + "A1": ["A1"], + "H1": ["H1"], + "A12": ["A12"], + "H12": ["H12"], + "A1_H3": [ + "A1", + "A2", + "A3", + "B1", + "B2", + "B3", + "C1", + "C2", + "C3", + "D1", + "D2", + "D3", + "E1", + "E2", + "E3", + "F1", + "F2", + "F3", + "G1", + "G2", + "G3", + "H1", + "H2", + "H3", + ], + "A1_F2": [ + "A1", + "A2", + "B1", + "B2", + "C1", + "C2", + "D1", + "D2", + "E1", + "E2", + "F1", + "F2", + ], + } ), ) + state_update = update_types.StateUpdate( + pipette_nozzle_map=update_types.PipetteNozzleMapUpdate( + pipette_id="pipette-id", + nozzle_map=nozzle_map, + ) + ) subject.handle_action( actions.SucceedCommandAction( - private_result=configure_nozzle_private_result, - command=configure_nozzle_layout_cmd, + command=_dummy_command(), + private_result=None, + state_update=state_update, ) ) - return configure_nozzle_private_result.nozzle_map + return nozzle_map map = _reconfigure_nozzle_layout("A1", "A1", "H3") _assert_and_pickup("A10", map) @@ -1320,51 +1328,47 @@ def _get_next_and_pickup(nozzle_map: NozzleMap) -> str | None: return result - # Configure nozzle for partial configurations - configure_nozzle_layout_cmd = commands.ConfigureNozzleLayout.construct( # type: ignore[call-arg] - result=commands.ConfigureNozzleLayoutResult() - ) - def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleMap: - configure_nozzle_private_result = commands.ConfigureNozzleLayoutPrivateResult( - pipette_id="pipette-id", - nozzle_map=NozzleMap.build( - physical_nozzles=NINETY_SIX_MAP, - physical_rows=NINETY_SIX_ROWS, - physical_columns=NINETY_SIX_COLS, - starting_nozzle=start, - back_left_nozzle=back_l, - front_right_nozzle=front_r, - valid_nozzle_maps=ValidNozzleMaps( - maps={ - "A1": ["A1"], - "H1": ["H1"], - "A12": ["A12"], - "H12": ["H12"], - "Full": sum( - [ - NINETY_SIX_ROWS["A"], - NINETY_SIX_ROWS["B"], - NINETY_SIX_ROWS["C"], - NINETY_SIX_ROWS["D"], - NINETY_SIX_ROWS["E"], - NINETY_SIX_ROWS["F"], - NINETY_SIX_ROWS["G"], - NINETY_SIX_ROWS["H"], - ], - [], - ), - } - ), + nozzle_map = NozzleMap.build( + physical_nozzles=NINETY_SIX_MAP, + physical_rows=NINETY_SIX_ROWS, + physical_columns=NINETY_SIX_COLS, + starting_nozzle=start, + back_left_nozzle=back_l, + front_right_nozzle=front_r, + valid_nozzle_maps=ValidNozzleMaps( + maps={ + "A1": ["A1"], + "H1": ["H1"], + "A12": ["A12"], + "H12": ["H12"], + "Full": sum( + [ + NINETY_SIX_ROWS["A"], + NINETY_SIX_ROWS["B"], + NINETY_SIX_ROWS["C"], + NINETY_SIX_ROWS["D"], + NINETY_SIX_ROWS["E"], + NINETY_SIX_ROWS["F"], + NINETY_SIX_ROWS["G"], + NINETY_SIX_ROWS["H"], + ], + [], + ), + } ), ) + state_update = update_types.StateUpdate( + pipette_nozzle_map=update_types.PipetteNozzleMapUpdate( + pipette_id="pipette-id", nozzle_map=nozzle_map + ) + ) subject.handle_action( actions.SucceedCommandAction( - private_result=configure_nozzle_private_result, - command=configure_nozzle_layout_cmd, + command=_dummy_command(), private_result=None, state_update=state_update ) ) - return configure_nozzle_private_result.nozzle_map + return nozzle_map map = _reconfigure_nozzle_layout("A1", "A1", "A1") for x in range(96): From d3cb15428ba3f466b995f219fc2e71f0ddcdd1b1 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Mon, 14 Oct 2024 16:55:08 -0400 Subject: [PATCH 055/101] refactor(app): hoist getLabwareDefsFromCommands outside of each protocol command text (#16478) After #16451, it seemed like a good idea to do an audit of our O(n) getLabwareDefinitionsFromCommands util to see if we could improve performance elsewhere in the app. Turns out, pretty much every place that it's used has room for improvement (mainly in the case handled by this PR and in LPC flows). One such place is within each protocol command text. When we display a list of protocol command texts, for every protocol step associated with a labwareId, we iterate over the entirety of protocol analysis. This commits acts as a half-measure: hoist out the util far enough to reduce most of the negative performance implications. --- app/src/molecules/Command/Command.tsx | 7 ++- app/src/molecules/Command/CommandText.tsx | 7 ++- .../Command/__tests__/CommandText.test.tsx | 62 +++++++++++++++++++ .../hooks/useCommandTextString/index.tsx | 12 ++-- .../utils/getLiquidProbeCommandText.ts | 2 + .../utils/getMoveLabwareCommandText.ts | 11 +++- .../utils/getMoveToWellCommandText.ts | 2 + .../utils/getPipettingCommandText.ts | 2 + .../hooks/useCommandTextString/utils/types.ts | 5 +- app/src/molecules/Command/utils/accessors.ts | 5 +- .../utils/getLabwareDisplayLocation.ts | 15 +++-- .../Command/utils/getModuleDisplayLocation.ts | 2 +- .../molecules/Command/utils/getModuleModel.ts | 2 +- .../CategorizedStepContent.tsx | 27 +++++--- .../InterventionContent/InterventionInfo.tsx | 10 ++- .../Desktop/Devices/RunPreview/index.tsx | 25 ++++++-- .../ProtocolDetails/AnnotatedSteps.tsx | 31 +++++++++- .../ProtocolTimelineScrubber/CommandItem.tsx | 21 ++++--- .../ProtocolTimelineScrubber/index.tsx | 28 ++++++--- .../hooks/useRunProgressCopy.tsx | 18 +++++- .../ErrorRecoveryWizard.tsx | 3 +- .../ErrorRecoveryFlows/RecoverySplash.tsx | 3 +- .../ErrorRecoveryFlows/__fixtures__/index.ts | 1 + .../__tests__/ErrorRecoveryFlows.test.tsx | 3 + .../__tests__/useRecoveryToasts.test.tsx | 6 ++ .../ErrorRecoveryFlows/hooks/useERUtils.ts | 5 +- .../organisms/ErrorRecoveryFlows/index.tsx | 15 ++++- .../shared/ErrorDetailsModal.tsx | 3 +- .../shared/FailedStepNextStep.tsx | 3 + .../ErrorRecoveryFlows/shared/StepInfo.tsx | 3 + .../CurrentRunningProtocolCommand.tsx | 4 ++ .../RunningProtocolCommandList.tsx | 15 ++--- .../CurrentRunningProtocolCommand.test.tsx | 1 + .../RunningProtocolCommandList.test.tsx | 1 + app/src/pages/ODD/RunningProtocol/index.tsx | 14 ++++- 35 files changed, 301 insertions(+), 73 deletions(-) diff --git a/app/src/molecules/Command/Command.tsx b/app/src/molecules/Command/Command.tsx index 32d95a33371..3b09498ca00 100644 --- a/app/src/molecules/Command/Command.tsx +++ b/app/src/molecules/Command/Command.tsx @@ -8,7 +8,11 @@ import { SPACING, RESPONSIVENESS, } from '@opentrons/components' -import type { RobotType, RunTimeCommand } from '@opentrons/shared-data' +import type { + LabwareDefinition2, + RobotType, + RunTimeCommand, +} from '@opentrons/shared-data' import { CommandText } from './CommandText' import { CommandIcon } from './CommandIcon' import type { CommandTextData } from './types' @@ -34,6 +38,7 @@ interface SkeletonCommandProps extends FundamentalProps { interface NonSkeletonCommandProps extends FundamentalProps { state: NonSkeletonCommandState command: RunTimeCommand + allRunDefs: LabwareDefinition2[] commandTextData: CommandTextData } diff --git a/app/src/molecules/Command/CommandText.tsx b/app/src/molecules/Command/CommandText.tsx index 00c7337104b..3e8b27d2522 100644 --- a/app/src/molecules/Command/CommandText.tsx +++ b/app/src/molecules/Command/CommandText.tsx @@ -13,7 +13,11 @@ import { import { useCommandTextString } from './hooks' -import type { RobotType, RunTimeCommand } from '@opentrons/shared-data' +import type { + LabwareDefinition2, + RobotType, + RunTimeCommand, +} from '@opentrons/shared-data' import type { StyleProps } from '@opentrons/components' import type { CommandTextData } from './types' import type { @@ -36,6 +40,7 @@ type STProps = LegacySTProps | ModernSTProps interface BaseProps extends StyleProps { command: RunTimeCommand + allRunDefs: LabwareDefinition2[] commandTextData: CommandTextData robotType: RobotType isOnDevice?: boolean diff --git a/app/src/molecules/Command/__tests__/CommandText.test.tsx b/app/src/molecules/Command/__tests__/CommandText.test.tsx index a6614c6b330..621208af0a9 100644 --- a/app/src/molecules/Command/__tests__/CommandText.test.tsx +++ b/app/src/molecules/Command/__tests__/CommandText.test.tsx @@ -41,6 +41,7 @@ describe('CommandText', () => { if (command != null) { renderWithProviders( { if (command != null) { renderWithProviders( { if (pushOutDispenseCommand != null) { renderWithProviders( { it('renders correct text for dispenseInPlace', () => { renderWithProviders( { if (blowoutCommand != null) { renderWithProviders( { it('renders correct text for blowOutInPlace', () => { renderWithProviders( { it('renders correct text for aspirateInPlace', () => { renderWithProviders( { if (moveToWellCommand != null) { renderWithProviders( { it('renders correct text for labware involving an addressable area slot', () => { renderWithProviders( { it('renders correct text for moveToAddressableArea for Waste Chutes', () => { renderWithProviders( { it('renders correct text for moveToAddressableArea for Fixed Trash', () => { renderWithProviders( { it('renders correct text for moveToAddressableArea for Trash Bins', () => { renderWithProviders( { it('renders correct text for moveToAddressableAreaForDropTip for Trash Bin', () => { renderWithProviders( { it('renders correct text for moveToAddressableArea for slots', () => { renderWithProviders( { renderWithProviders( { renderWithProviders( { if (command != null) { renderWithProviders( { it('renders correct text for dropTip into a labware', () => { renderWithProviders( { it('renders correct text for dropTipInPlace', () => { renderWithProviders( { if (command != null) { renderWithProviders( { if (command != null) { renderWithProviders( { if (command != null) { renderWithProviders( { const loadLabwareCommand = loadLabwareCommands[0] renderWithProviders( { const loadTipRackCommand = loadLabwareCommands[2] renderWithProviders( { const loadOnModuleCommand = loadLabwareCommands[3] renderWithProviders( { it('renders correct text for loadLabware in adapter', () => { renderWithProviders( { } as LoadLabwareRunTimeCommand renderWithProviders( { if (reloadLabwareCommand != null) { renderWithProviders( { } renderWithProviders( { const mockTemp = 20 renderWithProviders( { const mockTemp = 20 renderWithProviders( { it('renders correct text for temperatureModule/waitForTemperature with no specified temp', () => { renderWithProviders( { const mockTemp = 20 renderWithProviders( { const mockTemp = 20 renderWithProviders( { const mockTemp = 20 renderWithProviders( { ] renderWithProviders( { ] renderWithProviders( { ] renderWithProviders( { ] renderWithProviders( { it('renders correct text for heaterShaker/setAndWaitForShakeSpeed', () => { renderWithProviders( { it('renders correct text for moveToSlot', () => { renderWithProviders( { it('renders correct text for moveRelative', () => { renderWithProviders( { it('renders correct text for moveToCoordinates', () => { renderWithProviders( { ([commandType, expectedCopy]) => { renderWithProviders( { it('renders correct text for waitForDuration', () => { renderWithProviders( { it('renders correct text for legacy pause with message', () => { renderWithProviders( { it('renders correct text for legacy pause without message', () => { renderWithProviders( { it('renders correct text for waitForResume with message', () => { renderWithProviders( { it('renders correct text for waitForResume without message', () => { renderWithProviders( { it('renders correct text for legacy delay with time', () => { renderWithProviders( { it('renders correct text for legacy delay wait for resume with message', () => { renderWithProviders( { it('renders correct text for legacy delay wait for resume without message', () => { renderWithProviders( { it('renders correct text for comment', () => { renderWithProviders( { it('renders correct text for custom command type with legacy command text', () => { renderWithProviders( { it('renders correct text for custom command type with arbitrary params', () => { renderWithProviders( { it('renders correct text for move labware manually off deck', () => { renderWithProviders( { it('renders correct text for move labware manually to module', () => { renderWithProviders( { it('renders correct text for move labware with gripper off deck', () => { renderWithProviders( { it('renders correct text for move labware with gripper to waste chute', () => { renderWithProviders( { it('renders correct text for move labware with gripper to module', () => { renderWithProviders( { if (command != null) { renderWithProviders( { if (command != null) { renderWithProviders( = Omit< GetCommandText, 'command' -> & { command: T } +> & + UseCommandTextStringParams & { command: T } diff --git a/app/src/molecules/Command/utils/accessors.ts b/app/src/molecules/Command/utils/accessors.ts index 2ca6fda7efb..651fb15769e 100644 --- a/app/src/molecules/Command/utils/accessors.ts +++ b/app/src/molecules/Command/utils/accessors.ts @@ -26,7 +26,10 @@ export function getLoadedPipette( : commandTextData.pipettes[mount] } export function getLoadedModule( - commandTextData: CompletedProtocolAnalysis | RunData | CommandTextData, + commandTextData: + | CompletedProtocolAnalysis + | RunData + | Omit, moduleId: string ): LoadedModule | undefined { // NOTE: old analysis contains a object dictionary of module entities by id, this case is supported for backwards compatibility purposes diff --git a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts b/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts index f86ff3473a8..724775fcc9e 100644 --- a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts +++ b/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts @@ -5,15 +5,21 @@ import { getModuleType, getOccludedSlotCountForModule, } from '@opentrons/shared-data' + import { getModuleDisplayLocation } from './getModuleDisplayLocation' import { getModuleModel } from './getModuleModel' -import { getLabwareDefinitionsFromCommands } from './getLabwareDefinitionsFromCommands' -import type { RobotType, LabwareLocation } from '@opentrons/shared-data' + import type { TFunction } from 'i18next' +import type { + RobotType, + LabwareLocation, + LabwareDefinition2, +} from '@opentrons/shared-data' import type { CommandTextData } from '../types' export function getLabwareDisplayLocation( - commandTextData: CommandTextData, + commandTextData: Omit, + allRunDefs: LabwareDefinition2[], location: LabwareLocation, t: TFunction, robotType: RobotType, @@ -54,8 +60,7 @@ export function getLabwareDisplayLocation( const adapter = commandTextData.labware.find( lw => lw.id === location.labwareId ) - const allDefs = getLabwareDefinitionsFromCommands(commandTextData.commands) - const adapterDef = allDefs.find( + const adapterDef = allRunDefs.find( def => getLabwareDefURI(def) === adapter?.definitionUri ) const adapterDisplayName = diff --git a/app/src/molecules/Command/utils/getModuleDisplayLocation.ts b/app/src/molecules/Command/utils/getModuleDisplayLocation.ts index c71c74c4c86..fa5e527d218 100644 --- a/app/src/molecules/Command/utils/getModuleDisplayLocation.ts +++ b/app/src/molecules/Command/utils/getModuleDisplayLocation.ts @@ -3,7 +3,7 @@ import { getLoadedModule } from './accessors' import type { CommandTextData } from '../types' export function getModuleDisplayLocation( - commandTextData: CommandTextData, + commandTextData: Omit, moduleId: string ): string { const loadedModule = getLoadedModule(commandTextData, moduleId) diff --git a/app/src/molecules/Command/utils/getModuleModel.ts b/app/src/molecules/Command/utils/getModuleModel.ts index 3e95e05ebeb..fdac4850331 100644 --- a/app/src/molecules/Command/utils/getModuleModel.ts +++ b/app/src/molecules/Command/utils/getModuleModel.ts @@ -4,7 +4,7 @@ import type { ModuleModel } from '@opentrons/shared-data' import type { CommandTextData } from '../types' export function getModuleModel( - commandTextData: CommandTextData, + commandTextData: Omit, moduleId: string ): ModuleModel | null { const loadedModule = getLoadedModule(commandTextData, moduleId) diff --git a/app/src/molecules/InterventionModal/CategorizedStepContent.tsx b/app/src/molecules/InterventionModal/CategorizedStepContent.tsx index f0b78a256af..f1c0835d396 100644 --- a/app/src/molecules/InterventionModal/CategorizedStepContent.tsx +++ b/app/src/molecules/InterventionModal/CategorizedStepContent.tsx @@ -1,7 +1,5 @@ import { css } from 'styled-components' -import { Command, CommandIndex } from '../Command' -import type { NonSkeletonCommandState, CommandTextData } from '../Command' -import type { RobotType, RunTimeCommand } from '@opentrons/shared-data' + import { StyledText, Flex, @@ -11,6 +9,15 @@ import { RESPONSIVENESS, } from '@opentrons/components' +import { Command, CommandIndex } from '../Command' + +import type { NonSkeletonCommandState, CommandTextData } from '../Command' +import type { + LabwareDefinition2, + RobotType, + RunTimeCommand, +} from '@opentrons/shared-data' + export interface CommandWithIndex { index: number | undefined command: RunTimeCommand @@ -19,6 +26,7 @@ export interface CommandWithIndex { export interface CategorizedStepContentProps { robotType: RobotType commandTextData: CommandTextData | null + allRunDefs: LabwareDefinition2[] topCategoryHeadline: string topCategory: NonSkeletonCommandState topCategoryCommand: CommandWithIndex | null @@ -35,6 +43,7 @@ const EMPTY_COMMAND = { command: null, state: 'loading', commandTextData: null, + allRunDefs: [], } as const type MappedState = @@ -42,17 +51,19 @@ type MappedState = command: RunTimeCommand state: NonSkeletonCommandState commandTextData: CommandTextData + allRunDefs: LabwareDefinition2[] } | typeof EMPTY_COMMAND const commandAndState = ( command: CommandWithIndex | null, state: NonSkeletonCommandState, - commandTextData: CommandTextData | null + commandTextData: CommandTextData | null, + allRunDefs: LabwareDefinition2[] ): MappedState => command == null || commandTextData == null ? EMPTY_COMMAND - : { state, command: command.command, commandTextData } + : { state, command: command.command, commandTextData, allRunDefs } export function CategorizedStepContent( props: CategorizedStepContentProps @@ -97,7 +108,8 @@ export function CategorizedStepContent( {...commandAndState( props.topCategoryCommand, props.topCategory, - props.commandTextData + props.commandTextData, + props.allRunDefs )} robotType={props.robotType} aligned="left" @@ -137,7 +149,8 @@ export function CategorizedStepContent( {...commandAndState( command, props.bottomCategory, - props.commandTextData + props.commandTextData, + props.allRunDefs )} robotType={props.robotType} aligned="left" diff --git a/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx b/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx index 03435fb48b5..0d9c0d2a191 100644 --- a/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx +++ b/app/src/molecules/InterventionModal/InterventionContent/InterventionInfo.tsx @@ -82,7 +82,7 @@ const buildContent = (props: InterventionInfoProps): JSX.Element => { } const buildLocArrowLoc = (props: InterventionInfoProps): JSX.Element => { - const { currentLocationProps, newLocationProps } = props + const { currentLocationProps, newLocationProps, type } = props if (newLocationProps != null) { return ( @@ -101,6 +101,9 @@ const buildLocArrowLoc = (props: InterventionInfoProps): JSX.Element => {
    ) } else { + console.error( + `InterventionInfo type is ${type}, but no newLocation was specified.` + ) return buildLoc(props) } } @@ -116,7 +119,7 @@ const buildLoc = ({ } const buildLocColonLoc = (props: InterventionInfoProps): JSX.Element => { - const { currentLocationProps, newLocationProps } = props + const { currentLocationProps, newLocationProps, type } = props if (newLocationProps != null) { return ( @@ -135,6 +138,9 @@ const buildLocColonLoc = (props: InterventionInfoProps): JSX.Element => {
    ) } else { + console.error( + `InterventionInfo type is ${type}, but no newLocation was specified.` + ) return buildLoc(props) } } diff --git a/app/src/organisms/Desktop/Devices/RunPreview/index.tsx b/app/src/organisms/Desktop/Devices/RunPreview/index.tsx index e3baabba5de..dd6c0e7beab 100644 --- a/app/src/organisms/Desktop/Devices/RunPreview/index.tsx +++ b/app/src/organisms/Desktop/Devices/RunPreview/index.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import { useMemo, useState, forwardRef, useRef } from 'react' import { css } from 'styled-components' import { useTranslation } from 'react-i18next' import { ViewportList } from 'react-viewport-list' @@ -28,7 +28,11 @@ import { useMostRecentCompletedAnalysis, useLastRunCommand, } from '/app/resources/runs' -import { CommandText, CommandIcon } from '/app/molecules/Command' +import { + CommandText, + CommandIcon, + getLabwareDefinitionsFromCommands, +} from '/app/molecules/Command' import { Divider } from '/app/atoms/structure' import { NAV_BAR_WIDTH } from '/app/App/constants' @@ -70,19 +74,27 @@ export const RunPreviewComponent = ( } ) const commandsFromQuery = commandsFromQueryResponse?.data - const viewPortRef = React.useRef(null) + const viewPortRef = useRef(null) const currentRunCommandKey = useLastRunCommand(runId, { refetchInterval: LIVE_RUN_COMMANDS_POLL_MS, })?.key const [ isCurrentCommandVisible, setIsCurrentCommandVisible, - ] = React.useState(true) + ] = useState(true) + + const isValidRobotSideAnalysis = robotSideAnalysis != null + const allRunDefs = useMemo( + () => + robotSideAnalysis != null + ? getLabwareDefinitionsFromCommands(robotSideAnalysis.commands) + : [], + [isValidRobotSideAnalysis] + ) if (robotSideAnalysis == null) { return null } - const commands = isRunTerminal ? commandsFromQuery : robotSideAnalysis.commands @@ -196,6 +208,7 @@ export const RunPreviewComponent = ( commandTextData={protocolDataFromAnalysisOrRun} robotType={robotType} color={COLORS.black90} + allRunDefs={allRunDefs} /> @@ -227,4 +240,4 @@ export const RunPreviewComponent = ( ) } -export const RunPreview = React.forwardRef(RunPreviewComponent) +export const RunPreview = forwardRef(RunPreviewComponent) diff --git a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx index bdc5848f03b..5af6922afce 100644 --- a/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx +++ b/app/src/organisms/Desktop/ProtocolDetails/AnnotatedSteps.tsx @@ -1,4 +1,6 @@ +import { useMemo } from 'react' import { css } from 'styled-components' + import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { ALIGN_CENTER, @@ -11,12 +13,18 @@ import { TYPOGRAPHY, OVERFLOW_AUTO, } from '@opentrons/components' -import { CommandIcon, CommandText } from '/app/molecules/Command' + +import { + CommandIcon, + CommandText, + getLabwareDefinitionsFromCommands, +} from '/app/molecules/Command' import type { CompletedProtocolAnalysis, ProtocolAnalysisOutput, RunTimeCommand, + LabwareDefinition2, } from '@opentrons/shared-data' interface AnnotatedStepsProps { @@ -32,6 +40,15 @@ export function AnnotatedSteps(props: AnnotatedStepsProps): JSX.Element { } ` + const isValidRobotSideAnalysis = analysis != null + const allRunDefs = useMemo( + () => + analysis != null + ? getLabwareDefinitionsFromCommands(analysis.commands) + : [], + [isValidRobotSideAnalysis] + ) + return ( ))} @@ -64,9 +82,15 @@ interface IndividualCommandProps { analysis: ProtocolAnalysisOutput | CompletedProtocolAnalysis stepNumber: string isHighlighted: boolean + allRunDefs: LabwareDefinition2[] } -function IndividualCommand(props: IndividualCommandProps): JSX.Element { - const { command, analysis, stepNumber, isHighlighted } = props +function IndividualCommand({ + command, + analysis, + stepNumber, + isHighlighted, + allRunDefs, +}: IndividualCommandProps): JSX.Element { const backgroundColor = isHighlighted ? COLORS.blue30 : COLORS.grey20 const iconColor = isHighlighted ? COLORS.blue60 : COLORS.grey50 return ( @@ -101,6 +125,7 @@ function IndividualCommand(props: IndividualCommandProps): JSX.Element { robotType={analysis?.robotType ?? FLEX_ROBOT_TYPE} color={COLORS.black90} commandTextData={analysis} + allRunDefs={allRunDefs} /> diff --git a/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx b/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx index ae9377a19a0..573893f096f 100644 --- a/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx +++ b/app/src/organisms/Desktop/ProtocolTimelineScrubber/CommandItem.tsx @@ -14,6 +14,7 @@ import { COMMAND_WIDTH_PX } from './index' import type { CompletedProtocolAnalysis, + LabwareDefinition2, ProtocolAnalysisOutput, RobotType, RunTimeCommand, @@ -26,17 +27,18 @@ interface CommandItemProps { setCurrentCommandIndex: (index: number) => void analysis: CompletedProtocolAnalysis | ProtocolAnalysisOutput robotType: RobotType + allRunDefs: LabwareDefinition2[] } -export function CommandItem(props: CommandItemProps): JSX.Element { +export function CommandItem({ + index, + command, + currentCommandIndex, + setCurrentCommandIndex, + analysis, + robotType, + allRunDefs, +}: CommandItemProps): JSX.Element { const [showDetails, setShowDetails] = useState(false) - const { - index, - command, - currentCommandIndex, - setCurrentCommandIndex, - analysis, - robotType, - } = props const params: RunTimeCommand['params'] = command.params ?? {} return ( {showDetails ? Object.entries(params).map(([key, value]) => ( diff --git a/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx b/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx index 619928fcd4b..d92bc62e695 100644 --- a/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx +++ b/app/src/organisms/Desktop/ProtocolTimelineScrubber/index.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import { useMemo, useState, useEffect, useRef } from 'react' import map from 'lodash/map' import reduce from 'lodash/reduce' import ViewportList from 'react-viewport-list' @@ -27,7 +27,9 @@ import { wellFillFromWellContents, } from './utils' import { CommandItem } from './CommandItem' +import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' +import type { ComponentProps } from 'react' import type { ViewportListRef } from 'react-viewport-list' import type { CompletedProtocolAnalysis, @@ -66,12 +68,10 @@ export function ProtocolTimelineScrubber( props: ProtocolTimelineScrubberProps ): JSX.Element { const { commands, analysis, robotType = FLEX_ROBOT_TYPE } = props - const wrapperRef = React.useRef(null) - const commandListRef = React.useRef(null) - const [currentCommandIndex, setCurrentCommandIndex] = React.useState( - 0 - ) - const [isPlaying, setIsPlaying] = React.useState(true) + const wrapperRef = useRef(null) + const commandListRef = useRef(null) + const [currentCommandIndex, setCurrentCommandIndex] = useState(0) + const [isPlaying, setIsPlaying] = useState(true) const currentCommandsSlice = commands.slice(0, currentCommandIndex + 1) const { frame, invariantContext } = getResultingTimelineFrameFromRunCommands( @@ -81,7 +81,7 @@ export function ProtocolTimelineScrubber( setIsPlaying(!isPlaying) } - React.useEffect(() => { + useEffect(() => { if (isPlaying) { const intervalId = setInterval(() => { setCurrentCommandIndex(prev => { @@ -123,6 +123,15 @@ export function ProtocolTimelineScrubber( liquid => liquid.displayColor ?? COLORS.blue50 ) + const isValidRobotSideAnalysis = analysis != null + const allRunDefs = useMemo( + () => + analysis != null + ? getLabwareDefinitionsFromCommands(analysis.commands) + : [], + [isValidRobotSideAnalysis] + ) + return ( ['innerProps'] => { + ): ComponentProps['innerProps'] => { if (moduleState.type === THERMOCYCLER_MODULE_TYPE) { let lidMotorState = 'unknown' if (moduleState.lidOpen === true) { @@ -282,6 +291,7 @@ export function ProtocolTimelineScrubber( setCurrentCommandIndex={setCurrentCommandIndex} analysis={analysis} robotType={robotType} + allRunDefs={allRunDefs} /> )} diff --git a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx index 66e1960f939..e9db153498b 100644 --- a/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx +++ b/app/src/organisms/Desktop/RunProgressMeter/hooks/useRunProgressCopy.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_IDLE, @@ -6,7 +8,10 @@ import type * as React from 'react' import { useTranslation } from 'react-i18next' import { getCommandTextData } from '/app/molecules/Command/utils/getCommandTextData' import { LegacyStyledText } from '@opentrons/components' -import { CommandText } from '/app/molecules/Command' +import { + CommandText, + getLabwareDefinitionsFromCommands, +} from '/app/molecules/Command' import { TERMINAL_RUN_STATUSES } from '../constants' import type { CommandDetail, RunStatus } from '@opentrons/api-client' @@ -52,6 +57,15 @@ export function useRunProgressCopy({ runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR) || runStatus === RUN_STATUS_IDLE + const isValidRobotSideAnalysis = analysis != null + const allRunDefs = useMemo( + () => + analysis != null + ? getLabwareDefinitionsFromCommands(analysis.commands) + : [], + [isValidRobotSideAnalysis] + ) + const currentStepContents = ((): JSX.Element | null => { if (runHasNotBeenStarted) { return {t('not_started_yet')} @@ -61,6 +75,7 @@ export function useRunProgressCopy({ commandTextData={getCommandTextData(analysis)} command={analysisCommands[(currentStepNumber as number) - 1]} robotType={robotType} + allRunDefs={allRunDefs} /> ) } else if ( @@ -73,6 +88,7 @@ export function useRunProgressCopy({ commandTextData={getCommandTextData(analysis)} command={runCommandDetails.data} robotType={robotType} + allRunDefs={allRunDefs} /> ) } else { diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index b42ffa9c9cd..cfe211f7f3e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -29,7 +29,7 @@ import { RecoveryInProgress } from './RecoveryInProgress' import { getErrorKind } from './utils' import { RECOVERY_MAP } from './constants' -import type { RobotType } from '@opentrons/shared-data' +import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' import type { RecoveryRoute, RouteStep, RecoveryContentProps } from './types' import type { ERUtilsResults, useRetainedFailedCommandBySource } from './hooks' import type { ErrorRecoveryFlowsProps } from '.' @@ -68,6 +68,7 @@ export type ErrorRecoveryWizardProps = ErrorRecoveryFlowsProps & isOnDevice: boolean analytics: UseRecoveryAnalyticsResult failedCommand: ReturnType + allRunDefs: LabwareDefinition2[] } export function ErrorRecoveryWizard( diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index 01cf1faa65b..aad0f670cd0 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -37,7 +37,7 @@ import { import { RecoveryInterventionModal, StepInfo } from './shared' import { useToaster } from '../ToasterOven' -import type { RobotType } from '@opentrons/shared-data' +import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '.' import type { ERUtilsResults, @@ -70,6 +70,7 @@ type RecoverySplashProps = ErrorRecoveryFlowsProps & resumePausedRecovery: boolean toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] analytics: UseRecoveryAnalyticsResult + allRunDefs: LabwareDefinition2[] } export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { const { diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index d2efa1cc218..c79e270bbed 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -92,4 +92,5 @@ export const mockRecoveryContentProps: RecoveryContentProps = { reportActionSelectedEvent: () => {}, reportActionSelectedResult: () => {}, }, + allRunDefs: [], } diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index f15eedefc2d..b4fda69bd13 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -8,6 +8,7 @@ import { RUN_STATUS_RUNNING, RUN_STATUS_STOP_REQUESTED, } from '@opentrons/api-client' +import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' @@ -32,6 +33,7 @@ vi.mock('/app/redux/config') vi.mock('../RecoverySplash') vi.mock('/app/redux-resources/analytics') vi.mock('@opentrons/react-api-client') +vi.mock('/app/molecules/Command') vi.mock('react-redux', async () => { const actual = await vi.importActual('react-redux') return { @@ -43,6 +45,7 @@ vi.mock('react-redux', async () => { describe('useErrorRecoveryFlows', () => { beforeEach(() => { vi.mocked(useCurrentlyRecoveringFrom).mockReturnValue('mockCommand' as any) + vi.mocked(getLabwareDefinitionsFromCommands).mockReturnValue([]) }) it('should have initial state of isERActive as false', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx index 08c51478491..7c6b3b74065 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryToasts.test.tsx @@ -34,6 +34,7 @@ const DEFAULT_PROPS: BuildToast = { selectedRecoveryOption: RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, commandTextData: { commands: [] } as any, robotType: FLEX_ROBOT_TYPE, + allRunDefs: [], } // Utility function for rendering with I18nextProvider @@ -208,6 +209,7 @@ describe('useRecoveryFullCommandText', () => { robotType: FLEX_ROBOT_TYPE, stepNumber: 0, commandTextData: { commands: [TEST_COMMAND] } as any, + allRunDefs: [], }) ) @@ -225,6 +227,7 @@ describe('useRecoveryFullCommandText', () => { robotType: FLEX_ROBOT_TYPE, stepNumber: 1, commandTextData: { commands: [] } as any, + allRunDefs: [], }) ) @@ -237,6 +240,7 @@ describe('useRecoveryFullCommandText', () => { robotType: FLEX_ROBOT_TYPE, stepNumber: '?', commandTextData: { commands: [] } as any, + allRunDefs: [], }) ) @@ -257,6 +261,7 @@ describe('useRecoveryFullCommandText', () => { commandTextData: { commands: [TC_COMMAND], } as any, + allRunDefs: [], }) ) expect(result.current).toBe('tc starting profile of 1231231 element steps') @@ -276,6 +281,7 @@ describe('useRecoveryFullCommandText', () => { commandTextData: { commands: [TC_COMMAND], } as any, + allRunDefs: [], }) ) expect(result.current).toBe('tc starting profile of 1231231 element steps') diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 38af12de4a7..155c534ba6f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -20,7 +20,7 @@ import { useShowDoorInfo } from './useShowDoorInfo' import { useCleanupRecoveryState } from './useCleanupRecoveryState' import { useFailedPipetteUtils } from './useFailedPipetteUtils' -import type { RobotType } from '@opentrons/shared-data' +import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' import type { IRecoveryMap, RouteStep, RecoveryRoute } from '../types' import type { ErrorRecoveryFlowsProps } from '..' import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' @@ -47,6 +47,7 @@ export type ERUtilsProps = Omit & { robotType: RobotType failedCommand: ReturnType showTakeover: boolean + allRunDefs: LabwareDefinition2[] } export interface ERUtilsResults { @@ -80,6 +81,7 @@ export function useERUtils({ robotType, runStatus, showTakeover, + allRunDefs, }: ERUtilsProps): ERUtilsResults { const { data: attachedInstruments } = useInstrumentsQuery() const { data: runRecord } = useNotifyRunQuery(runId) @@ -113,6 +115,7 @@ export function useERUtils({ isOnDevice, commandTextData: protocolAnalysis, robotType, + allRunDefs, }) const failedPipetteUtils = useFailedPipetteUtils({ diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index fce380ced9f..a447df2dafe 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useSelector } from 'react-redux' import { @@ -30,6 +30,7 @@ import { import type { RunStatus } from '@opentrons/api-client' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' import type { FailedCommand } from './types' +import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' const VALID_ER_RUN_STATUSES: RunStatus[] = [ RUN_STATUS_AWAITING_RECOVERY, @@ -125,6 +126,15 @@ export function ErrorRecoveryFlows( const robotType = protocolAnalysis?.robotType ?? OT2_ROBOT_TYPE const robotName = useHost()?.robotName ?? 'robot' + const isValidRobotSideAnalysis = protocolAnalysis != null + const allRunDefs = useMemo( + () => + protocolAnalysis != null + ? getLabwareDefinitionsFromCommands(protocolAnalysis.commands) + : [], + [isValidRobotSideAnalysis] + ) + const { showTakeover, isActiveUser, @@ -140,6 +150,7 @@ export function ErrorRecoveryFlows( robotType, showTakeover, failedCommand: failedCommandBySource, + allRunDefs, }) const renderWizard = @@ -164,6 +175,7 @@ export function ErrorRecoveryFlows( robotType={robotType} isOnDevice={isOnDevice} failedCommand={failedCommandBySource} + allRunDefs={allRunDefs} /> ) : null} {showSplash ? ( @@ -176,6 +188,7 @@ export function ErrorRecoveryFlows( toggleERWizAsActiveUser={toggleERWizAsActiveUser} failedCommand={failedCommandBySource} resumePausedRecovery={!renderWizard && !showTakeover} + allRunDefs={allRunDefs} /> ) : null} diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx index 27b5b596c37..b988c83971b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx @@ -22,7 +22,7 @@ import { InlineNotification } from '/app/atoms/InlineNotification' import { StepInfo } from './StepInfo' import { getErrorKind } from '../utils' -import type { RobotType } from '@opentrons/shared-data' +import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' import type { IconProps } from '@opentrons/components' import type { OddModalHeaderBaseProps } from '/app/molecules/OddModal/types' import type { ERUtilsResults, useRetainedFailedCommandBySource } from '../hooks' @@ -52,6 +52,7 @@ type ErrorDetailsModalProps = Omit< robotType: RobotType desktopType: DesktopSizeType failedCommand: ReturnType + allRunDefs: LabwareDefinition2[] } export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx index bad7b536dfe..a27a1adea04 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx @@ -8,6 +8,7 @@ export function FailedStepNextStep({ commandsAfterFailedCommand, protocolAnalysis, robotType, + allRunDefs, }: Pick< RecoveryContentProps, | 'stepCounts' @@ -15,6 +16,7 @@ export function FailedStepNextStep({ | 'commandsAfterFailedCommand' | 'protocolAnalysis' | 'robotType' + | 'allRunDefs' >): JSX.Element { const { t } = useTranslation('error_recovery') const failedCommandByAnalysis = failedCommand?.byAnalysis ?? null @@ -44,6 +46,7 @@ export function FailedStepNextStep({ return ( ['desktopStyle'] oddStyle?: React.ComponentProps['oddStyle'] } @@ -25,6 +26,7 @@ export function StepInfo({ failedCommand, robotType, protocolAnalysis, + allRunDefs, ...styleProps }: StepInfoProps): JSX.Element { const { t } = useTranslation('error_recovery') @@ -54,6 +56,7 @@ export function StepInfo({ modernStyledTextDefaults={true} desktopStyle={desktopStyleDefaulted} oddStyle={oddStyleDefaulted} + allRunDefs={allRunDefs} /> ) : null} diff --git a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx index e474d58ab11..f87f7cd71e9 100644 --- a/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx +++ b/app/src/organisms/ODD/RunningProtocol/CurrentRunningProtocolCommand.tsx @@ -30,6 +30,7 @@ import { useNotifyAllCommandsQuery } from '/app/resources/runs' import type { CompletedProtocolAnalysis, + LabwareDefinition2, RobotType, RunTimeCommand, } from '@opentrons/shared-data' @@ -123,6 +124,7 @@ interface CurrentRunningProtocolCommandProps { lastAnimatedCommand: string | null lastRunCommand: RunCommandSummary | null updateLastAnimatedCommand: (newCommandKey: string) => void + allRunDefs: LabwareDefinition2[] protocolName?: string currentRunCommandIndex?: number } @@ -143,6 +145,7 @@ export function CurrentRunningProtocolCommand({ lastRunCommand, lastAnimatedCommand, updateLastAnimatedCommand, + allRunDefs, }: CurrentRunningProtocolCommandProps): JSX.Element | null { const { t } = useTranslation('run_details') const { data: mostRecentCommandData } = useNotifyAllCommandsQuery(runId, { @@ -261,6 +264,7 @@ export function CurrentRunningProtocolCommand({ commandTextData={getCommandTextData(robotSideAnalysis)} robotType={robotType} isOnDevice={true} + allRunDefs={allRunDefs} /> ) : null} diff --git a/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx b/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx index f161fa77e05..3e928ed88b4 100644 --- a/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx +++ b/app/src/organisms/ODD/RunningProtocol/RunningProtocolCommandList.tsx @@ -31,6 +31,7 @@ import { ANALYTICS_PROTOCOL_RUN_ACTION } from '/app/redux/analytics' import type { ViewportListRef } from 'react-viewport-list' import type { CompletedProtocolAnalysis, + LabwareDefinition2, RobotType, } from '@opentrons/shared-data' import type { RunStatus } from '@opentrons/api-client' @@ -60,17 +61,6 @@ const COMMAND_ROW_STYLE = css` overflow: hidden; ` -// Note (kj:05/15/2023) -// This blur part will be fixed before the launch -// const BOTTOM_ROW_STYLE = css` -// position: ${POSITION_ABSOLUTE}; -// bottom: 0; -// width: 100%; -// height: 5rem; -// z-index: 6; -// backdrop-filter: blur(1.5px); -// ` - interface VisibleIndexRange { lowestVisibleIndex: number highestVisibleIndex: number @@ -87,6 +77,7 @@ interface RunningProtocolCommandListProps { robotAnalyticsData: RobotAnalyticsData | null protocolName?: string currentRunCommandIndex?: number + allRunDefs: LabwareDefinition2[] } export function RunningProtocolCommandList({ @@ -100,6 +91,7 @@ export function RunningProtocolCommandList({ robotAnalyticsData, protocolName, currentRunCommandIndex, + allRunDefs, }: RunningProtocolCommandListProps): JSX.Element { const { t } = useTranslation('run_details') const viewPortRef = useRef(null) @@ -249,6 +241,7 @@ export function RunningProtocolCommandList({ robotType={robotType} css={COMMAND_ROW_STYLE} isOnDevice={true} + allRunDefs={allRunDefs} /> diff --git a/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx b/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx index 8e19bb69491..54d241ff9af 100644 --- a/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx +++ b/app/src/organisms/ODD/RunningProtocol/__tests__/CurrentRunningProtocolCommand.test.tsx @@ -55,6 +55,7 @@ describe('CurrentRunningProtocolCommand', () => { updateLastAnimatedCommand: mockUpdateLastAnimatedCommand, robotType: FLEX_ROBOT_TYPE, runId: 'MOCK_RUN_ID', + allRunDefs: [], } vi.mocked(useNotifyAllCommandsQuery).mockReturnValue({} as any) diff --git a/app/src/organisms/ODD/RunningProtocol/__tests__/RunningProtocolCommandList.test.tsx b/app/src/organisms/ODD/RunningProtocol/__tests__/RunningProtocolCommandList.test.tsx index f506dce8404..199ae940c3b 100644 --- a/app/src/organisms/ODD/RunningProtocol/__tests__/RunningProtocolCommandList.test.tsx +++ b/app/src/organisms/ODD/RunningProtocol/__tests__/RunningProtocolCommandList.test.tsx @@ -36,6 +36,7 @@ describe('RunningProtocolCommandList', () => { protocolName: 'mockRunningProtocolName', currentRunCommandIndex: 0, robotType: FLEX_ROBOT_TYPE, + allRunDefs: [], } }) it('should render text and buttons', () => { diff --git a/app/src/pages/ODD/RunningProtocol/index.tsx b/app/src/pages/ODD/RunningProtocol/index.tsx index bc87e0e4934..b75284386b8 100644 --- a/app/src/pages/ODD/RunningProtocol/index.tsx +++ b/app/src/pages/ODD/RunningProtocol/index.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo } from 'react' import { useParams } from 'react-router-dom' import styled, { css } from 'styled-components' import { useSelector } from 'react-redux' @@ -57,6 +57,7 @@ import { useErrorRecoveryFlows, ErrorRecoveryFlows, } from '/app/organisms/ErrorRecoveryFlows' +import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command' import type { OnDeviceRouteParams } from '/app/App/types' @@ -152,6 +153,15 @@ export function RunningProtocol(): JSX.Element { } }, [currentOption, swipeType, setSwipeType]) + const isValidRobotSideAnalysis = robotSideAnalysis != null + const allRunDefs = useMemo( + () => + robotSideAnalysis != null + ? getLabwareDefinitionsFromCommands(robotSideAnalysis.commands) + : [], + [isValidRobotSideAnalysis] + ) + return ( <> {isERActive ? ( @@ -228,6 +238,7 @@ export function RunningProtocol(): JSX.Element { updateLastAnimatedCommand={(newCommandKey: string) => (lastAnimatedCommand.current = newCommandKey) } + allRunDefs={allRunDefs} /> ) : ( <> @@ -242,6 +253,7 @@ export function RunningProtocol(): JSX.Element { robotAnalyticsData={robotAnalyticsData} currentRunCommandIndex={currentRunCommandIndex} robotSideAnalysis={robotSideAnalysis} + allRunDefs={allRunDefs} /> Date: Tue, 15 Oct 2024 09:53:27 -0400 Subject: [PATCH 056/101] fix(components): fix logic for whether TC is selected in DeckConfigurator (#16476) In our `DeckConfigurator` component, there is a leaky equality check for selected cutout ID with configured thermocycler cutout. The situation arises when a thermocycler is configured in cutout A1 and the selected cutout ID is B1, or vice versa. These point to the thermocycler being configured in the same physical location, so we should check whether both selected and configured cutout IDs are included in thermocycler cutouts ([cutoutA1, cutoutB1]). --- .../hardware-sim/DeckConfigurator/index.tsx | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/components/src/hardware-sim/DeckConfigurator/index.tsx b/components/src/hardware-sim/DeckConfigurator/index.tsx index f6c88acc2bd..374131e2232 100644 --- a/components/src/hardware-sim/DeckConfigurator/index.tsx +++ b/components/src/hardware-sim/DeckConfigurator/index.tsx @@ -14,6 +14,7 @@ import { MAGNETIC_BLOCK_V1_FIXTURE, ABSORBANCE_READER_V1_FIXTURE, STAGING_AREA_SLOT_WITH_MAGNETIC_BLOCK_V1_FIXTURE, + THERMOCYCLER_MODULE_CUTOUTS, } from '@opentrons/shared-data' import { COLORS } from '../../helix-design-system' @@ -228,18 +229,26 @@ export function DeckConfigurator(props: DeckConfiguratorProps): JSX.Element { } /> ))} - {thermocyclerFixtures.map(({ cutoutId, cutoutFixtureId }) => ( - - ))} + {thermocyclerFixtures.map(({ cutoutId, cutoutFixtureId }) => { + return ( + + ) + })} {absorbanceReaderFixtures.map(({ cutoutId, cutoutFixtureId }) => ( Date: Tue, 15 Oct 2024 10:40:29 -0400 Subject: [PATCH 057/101] refactor(api): Delete dead PipetteStore code and type nozzle maps as non-Optional (#16481) --- .../protocol_engine/state/pipettes.py | 32 ++++-------- .../protocol_engine/state/update_types.py | 50 +++++++++++++------ .../core/engine/test_instrument_core.py | 7 +-- .../state/test_pipette_store.py | 45 +++++++++++++---- .../state/test_pipette_view.py | 2 +- .../protocol_engine/state/test_tip_state.py | 8 +-- 6 files changed, 90 insertions(+), 54 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 26563b08ced..ced8b6076f7 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -96,7 +96,7 @@ class StaticPipetteConfig: nozzle_offset_z: float pipette_bounding_box_offsets: PipetteBoundingBoxOffsets bounding_nozzle_offsets: BoundingNozzlesOffsets - default_nozzle_map: NozzleMap + default_nozzle_map: NozzleMap # todo(mm, 2024-10-14): unused, remove? lld_settings: Optional[Dict[str, Dict[str, float]]] @@ -104,6 +104,9 @@ class StaticPipetteConfig: class PipetteState: """Basic pipette data state and getter methods.""" + # todo(mm, 2024-10-14): It's getting difficult to ensure that all of these + # attributes are populated at the appropriate times. Refactor to a + # single dict-of-many-things instead of many dicts-of-single-things. pipettes_by_id: Dict[str, LoadedPipette] aspirated_volume_by_id: Dict[str, Optional[float]] current_location: Optional[CurrentPipetteLocation] @@ -112,7 +115,7 @@ class PipetteState: movement_speed_by_id: Dict[str, Optional[float]] static_config_by_id: Dict[str, StaticPipetteConfig] flow_rates_by_id: Dict[str, FlowRates] - nozzle_configuration_by_id: Dict[str, Optional[NozzleMap]] + nozzle_configuration_by_id: Dict[str, NozzleMap] liquid_presence_detection_by_id: Dict[str, bool] @@ -167,11 +170,6 @@ def _set_load_pipette(self, state_update: update_types.StateUpdate) -> None: self._state.aspirated_volume_by_id[pipette_id] = None self._state.movement_speed_by_id[pipette_id] = None self._state.attached_tip_by_id[pipette_id] = None - static_config = self._state.static_config_by_id.get(pipette_id) - if static_config: - self._state.nozzle_configuration_by_id[ - pipette_id - ] = static_config.default_nozzle_map def _update_tip_state(self, state_update: update_types.StateUpdate) -> None: if state_update.pipette_tip_state != update_types.NO_CHANGE: @@ -632,31 +630,23 @@ def get_plunger_axis(self, pipette_id: str) -> MotorAxis: def get_nozzle_layout_type(self, pipette_id: str) -> NozzleConfigurationType: """Get the current set nozzle layout configuration.""" - nozzle_map_for_pipette = self._state.nozzle_configuration_by_id.get(pipette_id) - if nozzle_map_for_pipette: - return nozzle_map_for_pipette.configuration - else: - return NozzleConfigurationType.FULL + nozzle_map_for_pipette = self._state.nozzle_configuration_by_id[pipette_id] + return nozzle_map_for_pipette.configuration def get_is_partially_configured(self, pipette_id: str) -> bool: """Determine if the provided pipette is partially configured.""" return self.get_nozzle_layout_type(pipette_id) != NozzleConfigurationType.FULL - def get_primary_nozzle(self, pipette_id: str) -> Optional[str]: + def get_primary_nozzle(self, pipette_id: str) -> str: """Get the primary nozzle, if any, related to the given pipette's nozzle configuration.""" - nozzle_map = self._state.nozzle_configuration_by_id.get(pipette_id) - return nozzle_map.starting_nozzle if nozzle_map else None + nozzle_map = self._state.nozzle_configuration_by_id[pipette_id] + return nozzle_map.starting_nozzle def _get_critical_point_offset_without_tip( self, pipette_id: str, critical_point: Optional[CriticalPoint] ) -> Point: """Get the offset of the specified critical point from pipette's mount position.""" - nozzle_map = self._state.nozzle_configuration_by_id.get(pipette_id) - # Nozzle map is unavailable only when there's no pipette loaded - # so this is merely for satisfying the type checker - assert ( - nozzle_map is not None - ), "Error getting critical point offset. Nozzle map not found." + nozzle_map = self._state.nozzle_configuration_by_id[pipette_id] match critical_point: case CriticalPoint.INSTRUMENT_XY_CENTER: return nozzle_map.instrument_xy_center_offset diff --git a/api/src/opentrons/protocol_engine/state/update_types.py b/api/src/opentrons/protocol_engine/state/update_types.py index 0bf00cfdd86..5d941d33933 100644 --- a/api/src/opentrons/protocol_engine/state/update_types.py +++ b/api/src/opentrons/protocol_engine/state/update_types.py @@ -66,9 +66,10 @@ class AddressableArea: @dataclasses.dataclass class PipetteLocationUpdate: - """Represents an update to perform on a pipette's location.""" + """An update to a pipette's location.""" pipette_id: str + """The ID of the already-loaded pipette.""" new_location: Well | AddressableArea | None | NoChangeType """The pipette's new logical location. @@ -82,19 +83,30 @@ class PipetteLocationUpdate: @dataclasses.dataclass class LabwareLocationUpdate: - """Represents an update to perform on a labware's location.""" + """An update to a labware's location.""" labware_id: str + """The ID of the already-loaded labware.""" new_location: LabwareLocation - """The labware's new logical location.""" + """The labware's new location.""" offset_id: typing.Optional[str] + """The ID of the labware's new offset, for its new location.""" @dataclasses.dataclass -class LoadedLabwareUpdate(LabwareLocationUpdate): - """Update loaded labware.""" +class LoadedLabwareUpdate: + """An update that loads a new labware.""" + + labware_id: str + """The unique ID of the new labware.""" + + new_location: LabwareLocation + """The labware's initial location.""" + + offset_id: typing.Optional[str] + """The ID of the labware's offset.""" display_name: typing.Optional[str] @@ -103,9 +115,15 @@ class LoadedLabwareUpdate(LabwareLocationUpdate): @dataclasses.dataclass class LoadPipetteUpdate: - """Update loaded pipette.""" + """An update that loads a new pipette. + + NOTE: Currently, if this is provided, a PipetteConfigUpdate must always be + provided alongside it to fully initialize everything. + """ pipette_id: str + """The unique ID of the new pipette.""" + pipette_name: PipetteNameType mount: MountType liquid_presence_detection: typing.Optional[bool] @@ -113,10 +131,14 @@ class LoadPipetteUpdate: @dataclasses.dataclass class PipetteConfigUpdate: - """Update pipette config.""" + """An update to a pipette's config.""" pipette_id: str + """The ID of the already-loaded pipette.""" + + # todo(mm, 2024-10-14): Does serial_number belong in LoadPipetteUpdate? serial_number: str + config: pipette_data_provider.LoadedStaticPipetteData @@ -237,7 +259,7 @@ def set_labware_location( new_location: LabwareLocation, new_offset_id: str | None, ) -> None: - """Set labware location.""" + """Set a labware's location. See `LabwareLocationUpdate`.""" self.labware_location = LabwareLocationUpdate( labware_id=labware_id, new_location=new_location, @@ -252,7 +274,7 @@ def set_loaded_labware( display_name: typing.Optional[str], location: LabwareLocation, ) -> None: - """Add loaded labware to state.""" + """Add a new labware to state. See `LoadedLabwareUpdate`.""" self.loaded_labware = LoadedLabwareUpdate( definition=definition, labware_id=labware_id, @@ -268,7 +290,7 @@ def set_load_pipette( mount: MountType, liquid_presence_detection: typing.Optional[bool], ) -> None: - """Add loaded pipette to state.""" + """Add a new pipette to state. See `LoadPipetteUpdate`.""" self.loaded_pipette = LoadPipetteUpdate( pipette_id=pipette_id, pipette_name=pipette_name, @@ -282,13 +304,13 @@ def update_pipette_config( config: pipette_data_provider.LoadedStaticPipetteData, serial_number: str, ) -> None: - """Update pipette config.""" + """Update a pipette's config. See `PipetteConfigUpdate`.""" self.pipette_config = PipetteConfigUpdate( pipette_id=pipette_id, config=config, serial_number=serial_number ) def update_pipette_nozzle(self, pipette_id: str, nozzle_map: NozzleMap) -> None: - """Update pipette nozzle map.""" + """Update a pipette's nozzle map. See `PipetteNozzleMapUpdate`.""" self.pipette_nozzle_map = PipetteNozzleMapUpdate( pipette_id=pipette_id, nozzle_map=nozzle_map ) @@ -296,7 +318,7 @@ def update_pipette_nozzle(self, pipette_id: str, nozzle_map: NozzleMap) -> None: def update_pipette_tip_state( self, pipette_id: str, tip_geometry: typing.Optional[TipGeometry] ) -> None: - """Update tip state.""" + """Update a pipette's tip state. See `PipetteTipStateUpdate`.""" self.pipette_tip_state = PipetteTipStateUpdate( pipette_id=pipette_id, tip_geometry=tip_geometry ) @@ -304,7 +326,7 @@ def update_pipette_tip_state( def mark_tips_as_used( self, pipette_id: str, labware_id: str, well_name: str ) -> None: - """Mark tips in a tip rack as used. See `MarkTipsUsedState`.""" + """Mark tips in a tip rack as used. See `TipsUsedUpdate`.""" self.tips_used = TipsUsedUpdate( pipette_id=pipette_id, labware_id=labware_id, well_name=well_name ) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index bd3cebe94d7..cb68b77a96e 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -1,5 +1,5 @@ """Test for the ProtocolEngine-based instrument API core.""" -from typing import cast, Optional, Union +from typing import cast, Optional from opentrons_shared_data.errors.exceptions import PipetteLiquidNotFoundError import pytest @@ -1227,17 +1227,14 @@ def test_configure_nozzle_layout( argnames=["pipette_channels", "nozzle_layout", "primary_nozzle", "expected_result"], argvalues=[ (96, NozzleConfigurationType.FULL, "A1", True), - (96, NozzleConfigurationType.FULL, None, True), (96, NozzleConfigurationType.ROW, "A1", True), (96, NozzleConfigurationType.COLUMN, "A1", True), (96, NozzleConfigurationType.COLUMN, "A12", True), (96, NozzleConfigurationType.SINGLE, "H12", True), (96, NozzleConfigurationType.SINGLE, "A1", True), (8, NozzleConfigurationType.FULL, "A1", True), - (8, NozzleConfigurationType.FULL, None, True), (8, NozzleConfigurationType.SINGLE, "H1", True), (8, NozzleConfigurationType.SINGLE, "A1", True), - (1, NozzleConfigurationType.FULL, None, True), ], ) def test_is_tip_tracking_available( @@ -1246,7 +1243,7 @@ def test_is_tip_tracking_available( subject: InstrumentCore, pipette_channels: int, nozzle_layout: NozzleConfigurationType, - primary_nozzle: Union[str, None], + primary_nozzle: str, expected_result: bool, ) -> None: """It should return whether tip tracking is available based on nozzle configuration.""" diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index 81ddf13f5d8..caab429e26b 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -191,25 +191,52 @@ def test_location_state_update(subject: PipetteStore) -> None: ) -def test_handles_load_pipette(subject: PipetteStore) -> None: +def test_handles_load_pipette( + subject: PipetteStore, + supported_tip_fixture: pipette_definition.SupportedTipsDefinition, +) -> None: """It should add the pipette data to the state.""" - command = create_load_pipette_command( + dummy_command = create_succeeded_command() + + load_pipette_update = update_types.LoadPipetteUpdate( pipette_id="pipette-id", pipette_name=PipetteNameType.P300_SINGLE, mount=MountType.LEFT, + liquid_presence_detection=None, + ) + + config = LoadedStaticPipetteData( + model="pipette-model", + display_name="pipette name", + min_volume=1.23, + max_volume=4.56, + channels=7, + flow_rates=FlowRates( + default_aspirate={"a": 1}, + default_dispense={"b": 2}, + default_blow_out={"c": 3}, + ), + tip_configuration_lookup_table={4: supported_tip_fixture}, + nominal_tip_overlap={"default": 5}, + home_position=8.9, + nozzle_offset_z=10.11, + nozzle_map=get_default_nozzle_map(PipetteNameType.P300_SINGLE), + back_left_corner_offset=Point(x=1, y=2, z=3), + front_right_corner_offset=Point(x=4, y=5, z=6), + pipette_lld_settings={}, + ) + config_update = update_types.PipetteConfigUpdate( + pipette_id="pipette-id", + config=config, + serial_number="pipette-serial", ) subject.handle_action( SucceedCommandAction( private_result=None, - command=command, + command=dummy_command, state_update=update_types.StateUpdate( - loaded_pipette=update_types.LoadPipetteUpdate( - pipette_id="pipette-id", - pipette_name=PipetteNameType.P300_SINGLE, - mount=MountType.LEFT, - liquid_presence_detection=None, - ) + loaded_pipette=load_pipette_update, pipette_config=config_update ), ) ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py index 27bee5f1d15..3b4d04bd967 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_view.py @@ -65,7 +65,7 @@ def get_pipette_view( movement_speed_by_id: Optional[Dict[str, Optional[float]]] = None, static_config_by_id: Optional[Dict[str, StaticPipetteConfig]] = None, flow_rates_by_id: Optional[Dict[str, FlowRates]] = None, - nozzle_layout_by_id: Optional[Dict[str, Optional[NozzleMap]]] = None, + nozzle_layout_by_id: Optional[Dict[str, NozzleMap]] = None, liquid_presence_detection_by_id: Optional[Dict[str, bool]] = None, ) -> PipetteView: """Get a pipette view test subject with the specified state.""" diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index fb07e4696ff..e0f0fd15669 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -1371,24 +1371,24 @@ def _reconfigure_nozzle_layout(start: str, back_l: str, front_r: str) -> NozzleM return nozzle_map map = _reconfigure_nozzle_layout("A1", "A1", "A1") - for x in range(96): + for _ in range(96): _get_next_and_pickup(map) assert _get_next_and_pickup(map) is None subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) map = _reconfigure_nozzle_layout("A12", "A12", "A12") - for x in range(96): + for _ in range(96): _get_next_and_pickup(map) assert _get_next_and_pickup(map) is None subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) map = _reconfigure_nozzle_layout("H1", "H1", "H1") - for x in range(96): + for _ in range(96): _get_next_and_pickup(map) assert _get_next_and_pickup(map) is None subject.handle_action(actions.ResetTipsAction(labware_id="cool-labware")) map = _reconfigure_nozzle_layout("H12", "H12", "H12") - for x in range(96): + for _ in range(96): _get_next_and_pickup(map) assert _get_next_and_pickup(map) is None From b0e5188472edf055f5b31436f1e1e10ca940cb19 Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Tue, 15 Oct 2024 11:16:49 -0400 Subject: [PATCH 058/101] feat(protocol-designer): keyboard hot key display (#16477) closes AUTH-881 --- .../assets/localization/en/feature_flags.json | 4 ++ .../localization/en/starting_deck_state.json | 3 + .../src/feature-flags/reducers.ts | 2 + .../src/feature-flags/selectors.ts | 4 ++ protocol-designer/src/feature-flags/types.ts | 2 + .../__tests__/ProtocolSteps.test.tsx | 25 ++++++--- .../pages/Designer/ProtocolSteps/index.tsx | 16 +++++- .../Settings/__tests__/Settings.test.tsx | 6 +- .../src/pages/Settings/index.tsx | 55 +++++++++++++------ 9 files changed, 88 insertions(+), 29 deletions(-) diff --git a/protocol-designer/src/assets/localization/en/feature_flags.json b/protocol-designer/src/assets/localization/en/feature_flags.json index 9fe53d8f802..f83f09e345c 100644 --- a/protocol-designer/src/assets/localization/en/feature_flags.json +++ b/protocol-designer/src/assets/localization/en/feature_flags.json @@ -31,5 +31,9 @@ "OT_PD_ENABLE_RETURN_TIP": { "title": "Enable return tip", "description": "You can choose which tip to pick up and where to drop tip." + }, + "OT_PD_ENABLE_HOT_KEYS_DISPLAY": { + "title": "Timeline editing tips", + "description": "Show tips for working with steps next to the protocol timeline" } } diff --git a/protocol-designer/src/assets/localization/en/starting_deck_state.json b/protocol-designer/src/assets/localization/en/starting_deck_state.json index 8fb6ecb1e73..1e284990c62 100644 --- a/protocol-designer/src/assets/localization/en/starting_deck_state.json +++ b/protocol-designer/src/assets/localization/en/starting_deck_state.json @@ -17,6 +17,7 @@ "deck_hardware": "Deck hardware", "define_liquid": "Define a liquid", "done": "Done", + "double_click_to_edit": "Double-click to edit", "duplicate": "Duplicate labware", "edit_hw_lw": "Edit hardware/labware", "edit_labware": "Edit labware", @@ -36,9 +37,11 @@ "onDeck": "On deck", "one_item": "No more than 1 {{hardware}} allowed on the deck at one time", "only_display_rec": "Only display recommended labware", + "command_click_to_multi_select": "Command + Click for multi-select", "protocol_starting_deck": "Protocol starting deck", "rename_lab": "Rename labware", "reservoir": "Reservoir", + "shift_click_to_select_all": "Shift + Click to select all", "starting_deck_state": "Starting deck state", "tc_slots_occupied_flex": "The Thermocycler needs slots A1 and B1. Slot A1 is occupied", "tc_slots_occupied_ot2": "The Thermocycler needs slots 7, 8, 10, and 11. One or more of those slots is occupied", diff --git a/protocol-designer/src/feature-flags/reducers.ts b/protocol-designer/src/feature-flags/reducers.ts index b8d0a695867..bcb586acf9a 100644 --- a/protocol-designer/src/feature-flags/reducers.ts +++ b/protocol-designer/src/feature-flags/reducers.ts @@ -29,6 +29,8 @@ const initialFlags: Flags = { OT_PD_ENABLE_MOAM: process.env.OT_PD_ENABLE_MOAM === '1' || false, OT_PD_ENABLE_COMMENT: process.env.OT_PD_ENABLE_COMMENT === '1' || false, OT_PD_ENABLE_RETURN_TIP: process.env.OT_PD_ENABLE_RETURN_TIP === '1' || false, + OT_PD_ENABLE_HOT_KEYS_DISPLAY: + process.env.OT_PD_ENABLE_HOT_KEYS_DISPLAY === '1' || true, } // @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 7c5e57cf985..a4c3baf05be 100644 --- a/protocol-designer/src/feature-flags/selectors.ts +++ b/protocol-designer/src/feature-flags/selectors.ts @@ -45,3 +45,7 @@ export const getEnableReturnTip: Selector = createSelector( getFeatureFlagData, flags => flags.OT_PD_ENABLE_RETURN_TIP ?? false ) +export const getEnableHotKeysDisplay: Selector = createSelector( + getFeatureFlagData, + flags => flags.OT_PD_ENABLE_HOT_KEYS_DISPLAY ?? false +) diff --git a/protocol-designer/src/feature-flags/types.ts b/protocol-designer/src/feature-flags/types.ts index eda8f7182fe..f37d77bc814 100644 --- a/protocol-designer/src/feature-flags/types.ts +++ b/protocol-designer/src/feature-flags/types.ts @@ -34,10 +34,12 @@ export type FlagTypes = | 'OT_PD_ENABLE_MOAM' | 'OT_PD_ENABLE_COMMENT' | 'OT_PD_ENABLE_RETURN_TIP' + | 'OT_PD_ENABLE_HOT_KEYS_DISPLAY' // flags that are not in this list only show in prerelease mode export const userFacingFlags: FlagTypes[] = [ 'OT_PD_DISABLE_MODULE_RESTRICTIONS', 'OT_PD_ALLOW_ALL_TIPRACKS', + 'OT_PD_ENABLE_HOT_KEYS_DISPLAY', ] export const allFlags: FlagTypes[] = [ ...userFacingFlags, diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx index db393cc693e..06e662ebd78 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx @@ -1,9 +1,11 @@ import { describe, it, vi, beforeEach, expect } from 'vitest' import '@testing-library/jest-dom/vitest' import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../../assets/localization' import { renderWithProviders } from '../../../../__testing-utils__' import { getUnsavedForm } from '../../../../step-forms/selectors' import { getSelectedSubstep } from '../../../../ui/steps/selectors' +import { getEnableHotKeysDisplay } from '../../../../feature-flags/selectors' import { DeckSetupContainer } from '../../DeckSetup' import { OffDeck } from '../../Offdeck' import { ProtocolSteps } from '..' @@ -15,15 +17,12 @@ vi.mock('../../../../ui/steps/selectors') vi.mock('../StepForm') vi.mock('../../DeckSetup') vi.mock('../Timeline') - -vi.mock('../../../../assets/localization', () => ({ - t: vi.fn().mockReturnValue({ - t: (key: string) => key, - }), -})) +vi.mock('../../../../feature-flags/selectors') const render = () => { - return renderWithProviders()[0] + return renderWithProviders(, { + i18nInstance: i18n, + })[0] } describe('ProtocolSteps', () => { @@ -36,6 +35,7 @@ describe('ProtocolSteps', () => { vi.mocked(getUnsavedForm).mockReturnValue(null) vi.mocked(getSelectedSubstep).mockReturnValue(null) vi.mocked(SubstepsToolbox).mockReturnValue(
    mock SubstepsToolbox
    ) + vi.mocked(getEnableHotKeysDisplay).mockReturnValue(true) }) it('renders each component in ProtocolSteps', () => { @@ -47,7 +47,7 @@ describe('ProtocolSteps', () => { it('renders the toggle when formData is null', () => { render() screen.getByText('mock DeckSetupContainer') - fireEvent.click(screen.getByText('offDeck')) + fireEvent.click(screen.getByText('Off-deck')) screen.getByText('mock OffDeck') }) @@ -57,7 +57,7 @@ describe('ProtocolSteps', () => { id: 'mockId', }) render() - expect(screen.queryByText('offDeck')).not.toBeInTheDocument() + expect(screen.queryByText('Off-deck')).not.toBeInTheDocument() }) it('renders the substepToolbox when selectedSubstep is not null', () => { @@ -65,4 +65,11 @@ describe('ProtocolSteps', () => { render() screen.getByText('mock SubstepsToolbox') }) + + it('renders the hot keys display', () => { + render() + screen.getByText('Double-click to edit') + screen.getByText('Shift + Click to select all') + screen.getByText('Command + Click for multi-select') + }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx index f2e02876b52..7ff29ec1c30 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx @@ -3,24 +3,29 @@ import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { ALIGN_CENTER, + Box, COLORS, DIRECTION_COLUMN, Flex, JUSTIFY_CENTER, + POSITION_FIXED, SPACING, + Tag, ToggleGroup, } from '@opentrons/components' import { getUnsavedForm } from '../../../step-forms/selectors' import { getSelectedSubstep } from '../../../ui/steps/selectors' +import { getEnableHotKeysDisplay } from '../../../feature-flags/selectors' import { DeckSetupContainer } from '../DeckSetup' import { OffDeck } from '../Offdeck' import { TimelineToolbox, SubstepsToolbox } from './Timeline' import { StepForm } from './StepForm' export function ProtocolSteps(): JSX.Element { - const { t } = useTranslation(['starting_deck_state']) + const { t } = useTranslation('starting_deck_state') const formData = useSelector(getUnsavedForm) const selectedSubstep = useSelector(getSelectedSubstep) + const enableHoyKeyDisplay = useSelector(getEnableHotKeysDisplay) const leftString = t('onDeck') const rightString = t('offDeck') const [deckView, setDeckView] = useState< @@ -67,6 +72,15 @@ export function ProtocolSteps(): JSX.Element { ) : ( )} + {enableHoyKeyDisplay ? ( + + + + + + + + ) : null}
    ) diff --git a/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx b/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx index d9713d7ae88..cb62cfe7d62 100644 --- a/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx +++ b/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx @@ -46,6 +46,10 @@ describe('Settings', () => { screen.getByText('User settings') screen.getByText('Hints') screen.getByText('Reset all hints and tips notifications') + screen.getByText('Timeline editing tips') + screen.getByText( + 'Show tips for working with steps next to the protocol timeline' + ) screen.getByText('Reset hints') screen.getByText('Privacy') screen.getByText('Share sessions with Opentrons') @@ -87,7 +91,7 @@ describe('Settings', () => { screen.getByText( 'Turn off all restrictions on module placement and related pipette crash guidance.' ) - fireEvent.click(screen.getByTestId('btn_PRERELEASE_MODE')) + fireEvent.click(screen.getByLabelText('Settings_PRERELEASE_MODE')) expect(vi.mocked(setFeatureFlags)).toHaveBeenCalled() }) }) diff --git a/protocol-designer/src/pages/Settings/index.tsx b/protocol-designer/src/pages/Settings/index.tsx index abf449347f0..624cc4fa104 100644 --- a/protocol-designer/src/pages/Settings/index.tsx +++ b/protocol-designer/src/pages/Settings/index.tsx @@ -24,11 +24,14 @@ import { actions as tutorialActions, selectors as tutorialSelectors, } from '../../tutorial' +import { ToggleButton } from '../../atoms/ToggleButton' import { BUTTON_LINK_STYLE } from '../../atoms' import { actions as featureFlagActions } from '../../feature-flags' import { getFeatureFlagData } from '../../feature-flags/selectors' import type { FlagTypes } from '../../feature-flags' +const HOT_KEY_FLAG = 'OT_PD_ENABLE_HOT_KEYS_DISPLAY' + export function Settings(): JSX.Element { const dispatch = useDispatch() const { t } = useTranslation(['feature_flags', 'shared']) @@ -61,10 +64,6 @@ export function Settings(): JSX.Element { } const toFlagRow = (flagName: FlagTypes): JSX.Element => { - const iconName = Boolean(flags[flagName]) - ? 'ot-toggle-input-on' - : 'ot-toggle-input-off' - return ( @@ -75,29 +74,22 @@ export function Settings(): JSX.Element { {getDescription(flagName)} - { setFeatureFlags({ [flagName as string]: !flags[flagName], }) }} - > - - + /> ) } - const prereleaseFlagRows = allFlags.map(toFlagRow) + const prereleaseFlagRows = allFlags + .filter(flag => flag !== 'OT_PD_ENABLE_HOT_KEYS_DISPLAY') + .map(toFlagRow) return ( <> @@ -199,6 +191,33 @@ export function Settings(): JSX.Element {
    + + + + {t('OT_PD_ENABLE_HOT_KEYS_DISPLAY.title')} + + + + {t('OT_PD_ENABLE_HOT_KEYS_DISPLAY.description')} + + + + { + setFeatureFlags({ + OT_PD_ENABLE_HOT_KEYS_DISPLAY: !flags[HOT_KEY_FLAG], + }) + }} + /> + From 3402d2083132a81e1d5b481adc62d337112110bf Mon Sep 17 00:00:00 2001 From: koji Date: Tue, 15 Oct 2024 12:21:00 -0400 Subject: [PATCH 059/101] feat(protocol-designer): introduce react-lottie for animations in PD (#16472) * feat(protocol-designer): introduce react-lottie for animations in PD --- protocol-designer/package.json | 2 ++ yarn.lock | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/protocol-designer/package.json b/protocol-designer/package.json index b0a4cdc6fa6..045082d63be 100755 --- a/protocol-designer/package.json +++ b/protocol-designer/package.json @@ -27,6 +27,7 @@ "@opentrons/components": "link:../components", "@opentrons/step-generation": "link:../step-generation", "@opentrons/shared-data": "link:../shared-data", + "@types/react-lottie": "^1.2.10", "@types/redux-actions": "2.6.1", "@types/styled-components": "^5.1.26", "@types/ua-parser-js": "0.7.36", @@ -51,6 +52,7 @@ "react-dom": "18.2.0", "react-hook-form": "7.49.3", "react-i18next": "14.0.0", + "react-lottie": "^1.2.4", "react-redux": "8.1.2", "redux": "4.0.5", "redux-actions": "2.2.1", diff --git a/yarn.lock b/yarn.lock index 98d25011f2e..9d0bbe3f6c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5683,6 +5683,13 @@ dependencies: "@types/react" "*" +"@types/react-lottie@^1.2.10": + version "1.2.10" + resolved "https://registry.yarnpkg.com/@types/react-lottie/-/react-lottie-1.2.10.tgz#220f68a2dfa0d4b131ab4930e8bf166b9442c68c" + integrity sha512-rCd1p3US4ELKJlqwVnP0h5b24zt5p9OCvKUoNpYExLqwbFZMWEiJ6EGLMmH7nmq5V7KomBIbWO2X/XRFsL0vCA== + dependencies: + "@types/react" "*" + "@types/react-redux@7.1.32": version "7.1.32" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.32.tgz#bf162289e0c69e44a649dfcadb30f7f7c4cb00e4" @@ -7159,7 +7166,7 @@ babel-plugin-unassert@^3.0.1: resolved "https://registry.yarnpkg.com/babel-plugin-unassert/-/babel-plugin-unassert-3.2.0.tgz#4ea8f65709905cc540627baf4ce4c837281a317d" integrity sha512-dNeuFtaJ1zNDr59r24NjjIm4SsXXm409iNOVMIERp6ePciII+rTrdwsWcHDqDFUKpOoBNT4ZS63nPEbrANW7DQ== -babel-runtime@6.x.x: +babel-runtime@6.x.x, babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g== @@ -14965,6 +14972,11 @@ lost@^8.3.1: object-assign "^4.1.1" postcss "7.0.14" +lottie-web@^5.1.3: + version "5.12.2" + resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.12.2.tgz#579ca9fe6d3fd9e352571edd3c0be162492f68e5" + integrity sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg== + loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" @@ -18593,6 +18605,14 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-lottie@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.4.tgz#999ccabff8afc82074588bc50bd75be6f8945161" + integrity sha512-kBGxI+MIZGBf4wZhNCWwHkMcVP+kbpmrLWH/SkO0qCKc7D7eSPcxQbfpsmsCo8v2KCBYjuGSou+xTqK44D/jMg== + dependencies: + babel-runtime "^6.26.0" + lottie-web "^5.1.3" + react-markdown@9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.1.tgz#c05ddbff67fd3b3f839f8c648e6fb35d022397d1" From a3826db0f7a9ccff4bcd72d59b94bd5a8b4e11b0 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Tue, 15 Oct 2024 12:46:26 -0400 Subject: [PATCH 060/101] feat(app): Wire up door status affordances for gripper error flows (#16487) Closes EXEC-723 This commit closes out the gripper flows by adding the expected gripper behavior given the door status. These affordances include proper jaw release while the door is open, ensuring the door is closed before z-homing the gripper, implicitly z-homing the gripper if the door is already closed after releasing labware. There's some minor refactoring here as well. Copy for gripper flows was recently updated, so some views are refactored to handle different copy depending on the selectedRecoveryOption. The major challenge here is thinking through all the permutations of gripper/door state behavior. One of the trickier aspects is implicitly executing fixit commands, since most commands up to this point are directly tied to a CTA. We have a semi-pattern for this useInitialPipette. If we do more implicit fixit command behavior, it will probably be worth spending the time to think through how to elucidate this implicit behavior a bit better, since the control flow is dense. --- .../localization/en/error_recovery.json | 10 +- .../utils/getLabwareDisplayLocation.ts | 1 + .../getShowGenericRunHeaderBanners.ts | 4 +- .../ErrorRecoveryWizard.tsx | 13 +- .../ErrorRecoveryFlows/RecoveryInProgress.tsx | 77 ++++++--- .../RecoveryOptions/ManualMoveLwAndSkip.tsx | 3 + .../ManualReplaceLwAndRetry.tsx | 3 + .../__tests__/ManualMoveLwAndSkip.test.tsx | 8 + .../ManualReplaceLwAndRetry.test.tsx | 8 + .../ErrorRecoveryFlows/RecoverySplash.tsx | 1 + .../__tests__/ErrorRecoveryWizard.test.tsx | 25 ++- .../__tests__/RecoveryInProgress.test.tsx | 155 ++++++++++++------ .../organisms/ErrorRecoveryFlows/constants.ts | 35 +++- .../__tests__/useFailedLabwareUtils.test.ts | 70 ++++++++ .../__tests__/useHomeGripperZAxis.test.ts | 122 ++++++++++++++ .../__tests__/useRecoveryCommands.test.ts | 23 ++- .../hooks/__tests__/useShowDoorInfo.test.ts | 69 ++++++-- .../ErrorRecoveryFlows/hooks/index.ts | 1 + .../ErrorRecoveryFlows/hooks/useERUtils.ts | 7 +- .../hooks/useFailedLabwareUtils.ts | 87 +++++++++- .../hooks/useHomeGripperZAxis.ts | 44 +++++ .../hooks/useRecoveryCommands.ts | 28 +++- .../hooks/useRouteUpdateActions.ts | 12 +- .../hooks/useShowDoorInfo.ts | 12 +- .../shared/LeftColumnLabwareInfo.tsx | 25 +-- .../shared/RecoveryDoorOpenSpecial.tsx | 146 +++++++++++++++++ .../shared/TwoColLwInfoAndDeck.tsx | 18 +- .../__tests__/GripperReleaseLabware.test.tsx | 2 +- .../__tests__/LeftColumnLabwareInfo.test.tsx | 81 +++++---- .../RecoveryDoorOpenSpecial.test.tsx | 110 +++++++++++++ .../shared/__tests__/SelectTips.test.tsx | 2 + .../__tests__/TwoColLwInfoAndDeck.test.tsx | 134 +++++++++++++++ .../ErrorRecoveryFlows/shared/index.ts | 1 + shared-data/command/types/unsafe.ts | 13 ++ 34 files changed, 1179 insertions(+), 171 deletions(-) create mode 100644 app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts create mode 100644 app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx create mode 100644 app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index 63c259ce1f3..c5e5537ca83 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -12,12 +12,14 @@ "change_tip_pickup_location": "Change tip pick-up location", "choose_a_recovery_action": "Choose a recovery action", "close_door_to_resume": "Close robot door to resume", + "close_robot_door": "Close the robot door", "close_the_robot_door": "Close the robot door, and then resume the recovery action.", "confirm": "Confirm", "continue": "Continue", "continue_run_now": "Continue run now", "continue_to_drop_tip": "Continue to drop tip", - "ensure_lw_is_accurately_placed": "Ensure labware is accurately placed in the slot to prevent further errors", + "door_open_gripper_home": "The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.", + "ensure_lw_is_accurately_placed": "Ensure labware is accurately placed in the slot to prevent further errors.", "error": "Error", "error_details": "Error details", "error_on_robot": "Error on {{robot}}", @@ -38,7 +40,7 @@ "ignore_error_and_skip": "Ignore error and skip to next step", "ignore_only_this_error": "Ignore only this error", "ignore_similar_errors_later_in_run": "Ignore similar errors later in the run?", - "labware_released_from_current_height": "The labware will be released from its current height", + "labware_released_from_current_height": "The labware will be released from its current height.", "launch_recovery_mode": "Launch Recovery Mode", "manually_fill_liquid_in_well": "Manually fill liquid in well {{well}}", "manually_fill_well_and_skip": "Manually fill well and skip to next step", @@ -63,8 +65,8 @@ "remove_any_attached_tips": "Remove any attached tips", "replace_tips_and_select_loc_partial_tip": "Replace tips and select the last location used for partial tip pickup.", "replace_tips_and_select_location": "It's best to replace tips and select the last location used for tip pickup.", - "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in slot {{slot}}", - "replace_with_new_tip_rack": "Replace with new tip rack in slot {{slot}}", + "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in Slot {{slot}}", + "replace_with_new_tip_rack": "Replace with new tip rack in Slot {{slot}}", "resume": "Resume", "retry_now": "Retry now", "retry_step": "Retry step", diff --git a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts b/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts index 724775fcc9e..60b03609c79 100644 --- a/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts +++ b/app/src/molecules/Command/utils/getLabwareDisplayLocation.ts @@ -17,6 +17,7 @@ import type { } from '@opentrons/shared-data' import type { CommandTextData } from '../types' +// TODO(jh, 10-14-24): Refactor this util and related copy utils out of Command. export function getLabwareDisplayLocation( commandTextData: Omit, allRunDefs: LabwareDefinition2[], diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts index e21853738d7..dde83ddb02d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderBannerContainer/getShowGenericRunHeaderBanners.ts @@ -1,5 +1,6 @@ import { RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_STOPPED, } from '@opentrons/api-client' @@ -32,7 +33,8 @@ export function getShowGenericRunHeaderBanners({ isDoorOpen && runStatus !== RUN_STATUS_BLOCKED_BY_OPEN_DOOR && runStatus !== RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR && - isCancellableStatus(runStatus) + runStatus !== RUN_STATUS_AWAITING_RECOVERY_PAUSED + isCancellableStatus(runStatus) const showDoorOpenDuringRunBanner = runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index cfe211f7f3e..e763766ccb9 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -24,16 +24,18 @@ import { useErrorDetailsModal, ErrorDetailsModal, RecoveryInterventionModal, + RecoveryDoorOpenSpecial, } from './shared' import { RecoveryInProgress } from './RecoveryInProgress' import { getErrorKind } from './utils' import { RECOVERY_MAP } from './constants' +import { useHomeGripperZAxis } from './hooks' import type { LabwareDefinition2, RobotType } from '@opentrons/shared-data' import type { RecoveryRoute, RouteStep, RecoveryContentProps } from './types' -import type { ERUtilsResults, useRetainedFailedCommandBySource } from './hooks' import type { ErrorRecoveryFlowsProps } from '.' import type { UseRecoveryAnalyticsResult } from '/app/redux-resources/analytics' +import type { ERUtilsResults, useRetainedFailedCommandBySource } from './hooks' export interface UseERWizardResult { hasLaunchedRecovery: boolean @@ -88,6 +90,8 @@ export function ErrorRecoveryWizard( routeUpdateActions, }) + useHomeGripperZAxis(props) + return } @@ -136,7 +140,6 @@ export function ErrorRecoveryComponent( ) - // TODO(jh, 07-29-24): Make RecoveryDoorOpen render logic equivalent to RecoveryTakeover. Do not nest it in RecoveryWizard. const buildInterventionContent = (): JSX.Element => { if (isProhibitedDoorOpen) { return @@ -233,6 +236,10 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element { return } + const buildRecoveryDoorOpenSpecial = (): JSX.Element => { + return + } + switch (props.recoveryMap.route) { case RECOVERY_MAP.OPTION_SELECTION.ROUTE: return buildSelectRecoveryOption() @@ -260,6 +267,8 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element { return buildManualMoveLwAndSkip() case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: return buildManualReplaceLwAndRetry() + case RECOVERY_MAP.ROBOT_DOOR_OPEN_SPECIAL.ROUTE: + return buildRecoveryDoorOpenSpecial() case RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE: case RECOVERY_MAP.ROBOT_RESUMING.ROUTE: case RECOVERY_MAP.ROBOT_RETRYING_STEP.ROUTE: diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx index dc06fd1979f..3a176942a74 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx @@ -20,6 +20,8 @@ export function RecoveryInProgress({ recoveryMap, recoveryCommands, routeUpdateActions, + doorStatusUtils, + currentRecoveryOptionUtils, }: RecoveryContentProps): JSX.Element { const { ROBOT_CANCELING, @@ -37,6 +39,8 @@ export function RecoveryInProgress({ recoveryMap, recoveryCommands, routeUpdateActions, + doorStatusUtils, + currentRecoveryOptionUtils, }) const buildDescription = (): RobotMovingRoute => { @@ -76,47 +80,78 @@ export function RecoveryInProgress({ ) } -const GRIPPER_RELEASE_COUNTDOWN_S = 5 +export const GRIPPER_RELEASE_COUNTDOWN_S = 3 type UseGripperReleaseProps = Pick< RecoveryContentProps, - 'recoveryMap' | 'recoveryCommands' | 'routeUpdateActions' + | 'currentRecoveryOptionUtils' + | 'recoveryCommands' + | 'routeUpdateActions' + | 'doorStatusUtils' + | 'recoveryMap' > // Handles the gripper release copy and action, which operates on an interval. At T=0, release the labware then proceed -// to the next step in the active route. +// to the next step in the active route if the door is open (which should be a route to handle the door), or to the next +// CTA route if the door is closed. export function useGripperRelease({ - recoveryMap, + currentRecoveryOptionUtils, recoveryCommands, routeUpdateActions, + doorStatusUtils, + recoveryMap, }: UseGripperReleaseProps): number { const { releaseGripperJaws } = recoveryCommands + const { selectedRecoveryOption } = currentRecoveryOptionUtils const { proceedToRouteAndStep, proceedNextStep, handleMotionRouting, - stashedMap, } = routeUpdateActions + const { isDoorOpen } = doorStatusUtils const { MANUAL_MOVE_AND_SKIP, MANUAL_REPLACE_AND_RETRY } = RECOVERY_MAP const [countdown, setCountdown] = useState(GRIPPER_RELEASE_COUNTDOWN_S) const proceedToValidNextStep = (): void => { - switch (stashedMap?.route) { - case MANUAL_MOVE_AND_SKIP.ROUTE: - void proceedToRouteAndStep( - MANUAL_MOVE_AND_SKIP.ROUTE, - MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE - ) - break - case MANUAL_REPLACE_AND_RETRY.ROUTE: - void proceedToRouteAndStep( - MANUAL_REPLACE_AND_RETRY.ROUTE, - MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE - ) - break - default: - console.error('Unhandled post grip-release routing.') - void proceedNextStep() + if (isDoorOpen) { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: + void proceedToRouteAndStep( + MANUAL_MOVE_AND_SKIP.ROUTE, + MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + break + case MANUAL_REPLACE_AND_RETRY.ROUTE: + void proceedToRouteAndStep( + MANUAL_REPLACE_AND_RETRY.ROUTE, + MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + break + default: { + console.error( + 'Unhandled post grip-release routing when door is open.' + ) + void proceedToRouteAndStep(RECOVERY_MAP.OPTION_SELECTION.ROUTE) + } + } + } else { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: + void proceedToRouteAndStep( + MANUAL_MOVE_AND_SKIP.ROUTE, + MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + break + case MANUAL_REPLACE_AND_RETRY.ROUTE: + void proceedToRouteAndStep( + MANUAL_REPLACE_AND_RETRY.ROUTE, + MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE + ) + break + default: + console.error('Unhandled post grip-release routing.') + void proceedNextStep() + } } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx index 391674b54f1..123493480f7 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx @@ -4,6 +4,7 @@ import { GripperReleaseLabware, SkipStepInfo, TwoColLwInfoAndDeck, + RecoveryDoorOpenSpecial, } from '../shared' import { SelectRecoveryOption } from './SelectRecoveryOption' @@ -20,6 +21,8 @@ export function ManualMoveLwAndSkip(props: RecoveryContentProps): JSX.Element { return case MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE: return + case MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME: + return case MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE: return case MANUAL_MOVE_AND_SKIP.STEPS.SKIP: diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx index 01d9f7fb282..11ffe783d42 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx @@ -4,6 +4,7 @@ import { GripperReleaseLabware, TwoColLwInfoAndDeck, RetryStepInfo, + RecoveryDoorOpenSpecial, } from '../shared' import { SelectRecoveryOption } from './SelectRecoveryOption' @@ -22,6 +23,8 @@ export function ManualReplaceLwAndRetry( return case MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE: return + case MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME: + return case MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE: return case MANUAL_REPLACE_AND_RETRY.STEPS.RETRY: diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx index 863b406e1c5..48f8615cf81 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualMoveLwAndSkip.test.tsx @@ -15,6 +15,7 @@ vi.mock('../../shared', () => ({ GripperReleaseLabware: vi.fn(() =>
    MOCK_GRIPPER_RELEASE_LABWARE
    ), TwoColLwInfoAndDeck: vi.fn(() =>
    MOCK_TWO_COL_LW_INFO_AND_DECK
    ), SkipStepInfo: vi.fn(() =>
    MOCK_SKIP_STEP_INFO
    ), + RecoveryDoorOpenSpecial: vi.fn(() =>
    MOCK_DOOR_OPEN_SPECIAL
    ), })) vi.mock('../SelectRecoveryOption', () => ({ @@ -51,6 +52,13 @@ describe('ManualMoveLwAndSkip', () => { screen.getByText('MOCK_GRIPPER_RELEASE_LABWARE') }) + it(`renders RecoveryDoorOpenSpecial for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME} step`, () => { + props.recoveryMap.step = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + render(props) + screen.getByText('MOCK_DOOR_OPEN_SPECIAL') + }) + it(`renders TwoColLwInfoAndDeck for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE} step`, () => { props.recoveryMap.step = RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE render(props) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx index 12fc8e5151c..fb47ccb5f2f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManualReplaceLwAndRetry.test.tsx @@ -15,6 +15,7 @@ vi.mock('../../shared', () => ({ GripperReleaseLabware: vi.fn(() =>
    MOCK_GRIPPER_RELEASE_LABWARE
    ), TwoColLwInfoAndDeck: vi.fn(() =>
    MOCK_TWO_COL_LW_INFO_AND_DECK
    ), RetryStepInfo: vi.fn(() =>
    MOCK_RETRY_STEP_INFO
    ), + RecoveryDoorOpenSpecial: vi.fn(() =>
    MOCK_DOOR_OPEN_SPECIAL
    ), })) vi.mock('../SelectRecoveryOption', () => ({ @@ -54,6 +55,13 @@ describe('ManualReplaceLwAndRetry', () => { screen.getByText('MOCK_GRIPPER_RELEASE_LABWARE') }) + it(`renders RecoveryDoorOpenSpecial for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME} step`, () => { + props.recoveryMap.step = + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + render(props) + screen.getByText('MOCK_DOOR_OPEN_SPECIAL') + }) + it(`renders TwoColLwInfoAndDeck for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE} step`, () => { props.recoveryMap.step = RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx index aad0f670cd0..c9006f5d552 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoverySplash.tsx @@ -148,6 +148,7 @@ export function RecoverySplash(props: RecoverySplashProps): JSX.Element | null { const isDisabled = (): boolean => { switch (runStatus) { case RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR: + case RUN_STATUS_AWAITING_RECOVERY_PAUSED: return true default: return false diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx index ceaea85e58c..62fb2849753 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx @@ -29,7 +29,11 @@ import { import { RecoveryInProgress } from '../RecoveryInProgress' import { RecoveryError } from '../RecoveryError' import { RecoveryDoorOpen } from '../RecoveryDoorOpen' -import { useErrorDetailsModal, ErrorDetailsModal } from '../shared' +import { + useErrorDetailsModal, + ErrorDetailsModal, + RecoveryDoorOpenSpecial, +} from '../shared' import type { Mock } from 'vitest' @@ -37,12 +41,14 @@ vi.mock('../RecoveryOptions') vi.mock('../RecoveryInProgress') vi.mock('../RecoveryError') vi.mock('../RecoveryDoorOpen') +vi.mock('../hooks') vi.mock('../shared', async importOriginal => { const actual = await importOriginal() return { ...actual, useErrorDetailsModal: vi.fn(), ErrorDetailsModal: vi.fn(), + RecoveryDoorOpenSpecial: vi.fn(), } }) describe('useERWizard', () => { @@ -181,6 +187,7 @@ describe('ErrorRecoveryContent', () => { DROP_TIP_FLOWS, ERROR_WHILE_RECOVERING, ROBOT_DOOR_OPEN, + ROBOT_DOOR_OPEN_SPECIAL, ROBOT_RELEASING_LABWARE, MANUAL_REPLACE_AND_RETRY, MANUAL_MOVE_AND_SKIP, @@ -218,6 +225,9 @@ describe('ErrorRecoveryContent', () => {
    MOCK_IGNORE_ERROR_SKIP_STEP
    ) vi.mocked(RecoveryDoorOpen).mockReturnValue(
    MOCK_DOOR_OPEN
    ) + vi.mocked(RecoveryDoorOpenSpecial).mockReturnValue( +
    MOCK_DOOR_OPEN_SPECIAL
    + ) }) it(`returns SelectRecoveryOption when the route is ${OPTION_SELECTION.ROUTE}`, () => { @@ -485,6 +495,19 @@ describe('ErrorRecoveryContent', () => { screen.getByText('MOCK_DOOR_OPEN') }) + + it(`returns RecoveryDoorOpenSpecial when the route is ${ROBOT_DOOR_OPEN_SPECIAL.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + route: ROBOT_DOOR_OPEN_SPECIAL.ROUTE, + }, + } + renderRecoveryContent(props) + + screen.getByText('MOCK_DOOR_OPEN_SPECIAL') + }) }) describe('useInitialPipetteHome', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx index 0eb61b8f5b0..c3005c10cda 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryInProgress.test.tsx @@ -5,7 +5,11 @@ import { act, renderHook, screen } from '@testing-library/react' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' import { mockRecoveryContentProps } from '../__fixtures__' -import { RecoveryInProgress, useGripperRelease } from '../RecoveryInProgress' +import { + RecoveryInProgress, + useGripperRelease, + GRIPPER_RELEASE_COUNTDOWN_S, +} from '../RecoveryInProgress' import { RECOVERY_MAP } from '../constants' const render = (props: React.ComponentProps) => { @@ -124,7 +128,7 @@ describe('RecoveryInProgress', () => { } render(props) - screen.getByText('Gripper will release labware in 5 seconds') + screen.getByText('Gripper will release labware in 3 seconds') }) it('updates countdown for gripper release', () => { @@ -138,16 +142,16 @@ describe('RecoveryInProgress', () => { } render(props) - screen.getByText('Gripper will release labware in 5 seconds') + screen.getByText('Gripper will release labware in 3 seconds') act(() => { vi.advanceTimersByTime(1000) }) - screen.getByText('Gripper will release labware in 4 seconds') + screen.getByText('Gripper will release labware in 2 seconds') act(() => { - vi.advanceTimersByTime(4000) + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000 - 1000) }) screen.getByText('Gripper releasing labware') @@ -171,6 +175,10 @@ describe('useGripperRelease', () => { route: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, }, }, + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, + doorStatusUtils: { isDoorOpen: false }, } as any beforeEach(() => { @@ -181,70 +189,111 @@ describe('useGripperRelease', () => { vi.useRealTimers() }) - it('counts down from 5 seconds', () => { + it('counts down from 3 seconds', () => { const { result } = renderHook(() => useGripperRelease(mockProps)) - expect(result.current).toBe(5) + expect(result.current).toBe(3) act(() => { vi.advanceTimersByTime(1000) }) - expect(result.current).toBe(4) + expect(result.current).toBe(2) act(() => { - vi.advanceTimersByTime(4000) + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000 - 1000) }) expect(result.current).toBe(0) }) - it('releases gripper jaws and proceeds to next step after countdown', async () => { - renderHook(() => useGripperRelease(mockProps)) - - act(() => { - vi.advanceTimersByTime(5000) + const IS_DOOR_OPEN = [false, true] + + IS_DOOR_OPEN.forEach(doorStatus => { + it(`releases gripper jaws and proceeds to next step after countdown for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE} when the isDoorOpen is ${doorStatus}`, async () => { + renderHook(() => + useGripperRelease({ + ...mockProps, + doorStatusUtils: { isDoorOpen: doorStatus }, + }) + ) + + act(() => { + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000) + }) + + await vi.runAllTimersAsync() + + expect(mockProps.recoveryCommands.releaseGripperJaws).toHaveBeenCalled() + expect( + mockProps.routeUpdateActions.handleMotionRouting + ).toHaveBeenCalledWith(false) + if (!doorStatus) { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + } else { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + } }) - - await vi.runAllTimersAsync() - - expect(mockProps.recoveryCommands.releaseGripperJaws).toHaveBeenCalled() - expect( - mockProps.routeUpdateActions.handleMotionRouting - ).toHaveBeenCalledWith(false) - expect( - mockProps.routeUpdateActions.proceedToRouteAndStep - ).toHaveBeenCalledWith( - RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, - RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE - ) }) - it('handles MANUAL_REPLACE_AND_RETRY route', async () => { - const modifiedProps = { - ...mockProps, - routeUpdateActions: { - ...mockProps.routeUpdateActions, - stashedMap: { - route: RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + IS_DOOR_OPEN.forEach(doorStatus => { + it(`releases gripper jaws and proceeds to next step after countdown for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE} when the isDoorOpen is ${doorStatus}`, async () => { + const modifiedProps = { + ...mockProps, + routeUpdateActions: { + ...mockProps.routeUpdateActions, + stashedMap: { + route: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, }, - }, - } - - renderHook(() => useGripperRelease(modifiedProps)) - - act(() => { - vi.advanceTimersByTime(5000) + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, + } + + renderHook(() => + useGripperRelease({ + ...modifiedProps, + doorStatusUtils: { isDoorOpen: doorStatus }, + }) + ) + + act(() => { + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000) + }) + + await vi.runAllTimersAsync() + + expect(mockProps.recoveryCommands.releaseGripperJaws).toHaveBeenCalled() + expect( + mockProps.routeUpdateActions.handleMotionRouting + ).toHaveBeenCalledWith(false) + if (!doorStatus) { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + } else { + expect( + mockProps.routeUpdateActions.proceedToRouteAndStep + ).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME + ) + } }) - - await vi.runAllTimersAsync() - - expect( - modifiedProps.routeUpdateActions.proceedToRouteAndStep - ).toHaveBeenCalledWith( - RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, - RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE - ) }) it('calls proceedNextStep for unhandled routes', async () => { @@ -256,12 +305,16 @@ describe('useGripperRelease', () => { route: 'UNHANDLED_ROUTE', }, }, + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + }, + doorStatusUtils: { isDoorOpen: false }, } renderHook(() => useGripperRelease(modifiedProps)) act(() => { - vi.advanceTimersByTime(5000) + vi.advanceTimersByTime(GRIPPER_RELEASE_COUNTDOWN_S * 1000) }) await vi.runAllTimersAsync() diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index b32416220b9..4923ceca53e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -10,7 +10,7 @@ import { TEXT_ALIGN_CENTER, } from '@opentrons/components' -import type { RecoveryRouteStepMetadata, StepOrder } from './types' +import type { RecoveryRouteStepMetadata, RouteStep, StepOrder } from './types' // Server-defined error types. // (Values for the .error.errorType property of a run command.) @@ -101,6 +101,12 @@ export const RECOVERY_MAP = { DOOR_OPEN: 'door-open', }, }, + ROBOT_DOOR_OPEN_SPECIAL: { + ROUTE: 'door-special', + STEPS: { + DOOR_OPEN: 'door-open', + }, + }, // Recovery options below OPTION_SELECTION: { ROUTE: 'option-selection', @@ -126,6 +132,7 @@ export const RECOVERY_MAP = { STEPS: { GRIPPER_HOLDING_LABWARE: 'gripper-holding-labware', GRIPPER_RELEASE_LABWARE: 'gripper-release-labware', + CLOSE_DOOR_GRIPPER_Z_HOME: 'close-robot-door', MANUAL_MOVE: 'manual-move', SKIP: 'skip', }, @@ -135,6 +142,7 @@ export const RECOVERY_MAP = { STEPS: { GRIPPER_HOLDING_LABWARE: 'gripper-holding-labware', GRIPPER_RELEASE_LABWARE: 'gripper-release-labware', + CLOSE_DOOR_GRIPPER_Z_HOME: 'close-robot-door', MANUAL_REPLACE: 'manual-replace', RETRY: 'retry', }, @@ -187,6 +195,7 @@ const { ROBOT_RETRYING_STEP, ROBOT_SKIPPING_STEP, ROBOT_DOOR_OPEN, + ROBOT_DOOR_OPEN_SPECIAL, DROP_TIP_FLOWS, REFILL_AND_RESUME, IGNORE_AND_SKIP, @@ -229,6 +238,7 @@ export const STEP_ORDER: StepOrder = { [ROBOT_RETRYING_STEP.ROUTE]: [ROBOT_RETRYING_STEP.STEPS.RETRYING], [ROBOT_SKIPPING_STEP.ROUTE]: [ROBOT_SKIPPING_STEP.STEPS.SKIPPING], [ROBOT_DOOR_OPEN.ROUTE]: [ROBOT_DOOR_OPEN.STEPS.DOOR_OPEN], + [ROBOT_DOOR_OPEN_SPECIAL.ROUTE]: [ROBOT_DOOR_OPEN_SPECIAL.STEPS.DOOR_OPEN], [DROP_TIP_FLOWS.ROUTE]: [ DROP_TIP_FLOWS.STEPS.BEGIN_REMOVAL, DROP_TIP_FLOWS.STEPS.BEFORE_BEGINNING, @@ -245,12 +255,14 @@ export const STEP_ORDER: StepOrder = { [MANUAL_MOVE_AND_SKIP.ROUTE]: [ MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_HOLDING_LABWARE, MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE, + MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME, MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE, MANUAL_MOVE_AND_SKIP.STEPS.SKIP, ], [MANUAL_REPLACE_AND_RETRY.ROUTE]: [ MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_HOLDING_LABWARE, MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE, + MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME, MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE, MANUAL_REPLACE_AND_RETRY.STEPS.RETRY, ], @@ -316,6 +328,9 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { [ROBOT_DOOR_OPEN.ROUTE]: { [ROBOT_DOOR_OPEN.STEPS.DOOR_OPEN]: { allowDoorOpen: false }, }, + [ROBOT_DOOR_OPEN_SPECIAL.ROUTE]: { + [ROBOT_DOOR_OPEN_SPECIAL.STEPS.DOOR_OPEN]: { allowDoorOpen: true }, + }, [OPTION_SELECTION.ROUTE]: { [OPTION_SELECTION.STEPS.SELECT]: { allowDoorOpen: false }, }, @@ -340,6 +355,9 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { [MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE]: { allowDoorOpen: true, }, + [MANUAL_MOVE_AND_SKIP.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME]: { + allowDoorOpen: true, + }, [MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE]: { allowDoorOpen: true }, [MANUAL_MOVE_AND_SKIP.STEPS.SKIP]: { allowDoorOpen: true }, }, @@ -350,6 +368,9 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { [MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE]: { allowDoorOpen: true, }, + [MANUAL_REPLACE_AND_RETRY.STEPS.CLOSE_DOOR_GRIPPER_Z_HOME]: { + allowDoorOpen: true, + }, [MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE]: { allowDoorOpen: true }, [MANUAL_REPLACE_AND_RETRY.STEPS.RETRY]: { allowDoorOpen: true }, }, @@ -387,6 +408,18 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { }, } as const +/** + * Special step groupings + */ + +export const GRIPPER_MOVE_STEPS: RouteStep[] = [ + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE, + RECOVERY_MAP.ROBOT_RELEASING_LABWARE.STEPS.RELEASING_LABWARE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE, +] + export const INVALID = 'INVALID' as const /** diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts index b20ab13a1cd..a98818b6efd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest' +import { renderHook } from '@testing-library/react' import { getRelevantWellName, getRelevantFailedLabwareCmdFrom, + useRelevantFailedLwLocations, } from '../useFailedLabwareUtils' import { DEFINED_ERROR_TYPES } from '../../constants' @@ -120,6 +122,22 @@ describe('getRelevantFailedLabwareCmdFrom', () => { expect(result).toBe(pickUpTipCommand) }) }) + + it('should return the failedCommand for GRIPPER_ERROR error kind', () => { + const failedGripperCommand = { + ...failedCommand, + commandType: 'moveLabware', + error: { + isDefined: true, + errorType: DEFINED_ERROR_TYPES.GRIPPER_MOVEMENT, + }, + } + const result = getRelevantFailedLabwareCmdFrom({ + failedCommandByRunRecord: failedGripperCommand, + }) + expect(result).toEqual(failedGripperCommand) + }) + it('should return null for GENERAL_ERROR error kind', () => { const result = getRelevantFailedLabwareCmdFrom({ failedCommandByRunRecord: { @@ -140,3 +158,55 @@ describe('getRelevantFailedLabwareCmdFrom', () => { expect(result).toBeNull() }) }) + +// TODO(jh 10-15-24): This testing will can more useful once translation is refactored out of this function. +describe('useRelevantFailedLwLocations', () => { + const mockProtocolAnalysis = {} as any + const mockAllRunDefs = [] as any + const mockFailedLabware = { + location: { slot: 'D1' }, + } as any + + it('should return current location for non-moveLabware commands', () => { + const mockFailedCommand = { + commandType: 'aspirate', + } as any + + const { result } = renderHook(() => + useRelevantFailedLwLocations({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + protocolAnalysis: mockProtocolAnalysis, + allRunDefs: mockAllRunDefs, + }) + ) + + expect(result.current).toEqual({ + currentLoc: '', + newLoc: null, + }) + }) + + it('should return current and new location for moveLabware commands', () => { + const mockFailedCommand = { + commandType: 'moveLabware', + params: { + newLocation: { slot: 'C2' }, + }, + } as any + + const { result } = renderHook(() => + useRelevantFailedLwLocations({ + failedLabware: mockFailedLabware, + failedCommandByRunRecord: mockFailedCommand, + protocolAnalysis: mockProtocolAnalysis, + allRunDefs: mockAllRunDefs, + }) + ) + + expect(result.current).toEqual({ + currentLoc: '', + newLoc: '', + }) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts new file mode 100644 index 00000000000..197dfbfd3e7 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useHomeGripperZAxis.test.ts @@ -0,0 +1,122 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { useHomeGripperZAxis } from '../useHomeGripperZAxis' +import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' + +describe('useHomeGripperZAxis', () => { + const mockRecoveryCommands = { + homeGripperZAxis: vi.fn().mockResolvedValue(undefined), + } + + const mockRouteUpdateActions = { + handleMotionRouting: vi.fn().mockResolvedValue(undefined), + goBackPrevStep: vi.fn(), + } + + const mockRecoveryMap = { + step: RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE, + } + + const mockDoorStatusUtils = { + isDoorOpen: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should home gripper Z axis when in manual gripper step and door is closed', async () => { + renderHook(() => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: mockRecoveryMap, + doorStatusUtils: mockDoorStatusUtils, + } as any) + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( + true + ) + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalled() + expect(mockRouteUpdateActions.handleMotionRouting).toHaveBeenCalledWith( + false + ) + }) + + it('should go back to previous step when door is open', () => { + renderHook(() => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: mockRecoveryMap, + doorStatusUtils: { ...mockDoorStatusUtils, isDoorOpen: true }, + } as any) + }) + + expect(mockRouteUpdateActions.goBackPrevStep).toHaveBeenCalled() + expect(mockRecoveryCommands.homeGripperZAxis).not.toHaveBeenCalled() + }) + + it('should not home again if already homed once', async () => { + const { rerender } = renderHook(() => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap: mockRecoveryMap, + doorStatusUtils: mockDoorStatusUtils, + } as any) + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + + rerender() + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + }) + + it('should reset hasHomedOnce when step changes to non-manual gripper step and back', async () => { + const { rerender } = renderHook( + ({ recoveryMap }) => { + useHomeGripperZAxis({ + recoveryCommands: mockRecoveryCommands, + routeUpdateActions: mockRouteUpdateActions, + recoveryMap, + doorStatusUtils: mockDoorStatusUtils, + } as any) + }, + { + initialProps: { recoveryMap: mockRecoveryMap }, + } + ) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(1) + + rerender({ recoveryMap: { step: 'SOME_OTHER_STEP' } as any }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + rerender({ recoveryMap: mockRecoveryMap }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(mockRecoveryCommands.homeGripperZAxis).toHaveBeenCalledTimes(2) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index 8df2c3ec86b..016e38be69d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -11,8 +11,10 @@ import { useChainRunCommands } from '/app/resources/runs' import { useRecoveryCommands, HOME_PIPETTE_Z_AXES, + RELEASE_GRIPPER_JAW, buildPickUpTips, buildIgnorePolicyRules, + HOME_GRIPPER_Z_AXIS, } from '../useRecoveryCommands' import { RECOVERY_MAP } from '../../constants' @@ -252,14 +254,27 @@ describe('useRecoveryCommands', () => { it('should call releaseGripperJaws and resolve the promise', async () => { const { result } = renderHook(() => useRecoveryCommands(props)) - const consoleLogSpy = vi.spyOn(console, 'log') - await act(async () => { await result.current.releaseGripperJaws() }) - expect(consoleLogSpy).toHaveBeenCalledWith('PLACEHOLDER RELEASE THE JAWS') - consoleLogSpy.mockRestore() + expect(mockChainRunCommands).toHaveBeenCalledWith( + [RELEASE_GRIPPER_JAW], + false + ) + }) + + it('should call homeGripperZAxis and resolve the promise', async () => { + const { result } = renderHook(() => useRecoveryCommands(props)) + + await act(async () => { + await result.current.homeGripperZAxis() + }) + + expect(mockChainRunCommands).toHaveBeenCalledWith( + [HOME_GRIPPER_Z_AXIS], + false + ) }) it('should call skipFailedCommand and show success toast on success', async () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts index 753823f2ec6..1ebb6e1d018 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useShowDoorInfo.test.ts @@ -1,19 +1,24 @@ -import { describe, it, expect, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { useShowDoorInfo } from '../useShowDoorInfo' +import { describe, it, expect, beforeEach } from 'vitest' + import { + RUN_STATUS_AWAITING_RECOVERY, RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, RUN_STATUS_AWAITING_RECOVERY_PAUSED, - RUN_STATUS_AWAITING_RECOVERY, } from '@opentrons/api-client' -import { RECOVERY_MAP } from '../../constants' +import { useShowDoorInfo } from '../useShowDoorInfo' +import { + RECOVERY_MAP, + GRIPPER_MOVE_STEPS, +} from '/app/organisms/ErrorRecoveryFlows/constants' -import type { IRecoveryMap } from '../../types' +import type { IRecoveryMap, RouteStep } from '../../types' describe('useShowDoorInfo', () => { let initialProps: Parameters[0] let mockRecoveryMap: IRecoveryMap + let initialStep: RouteStep beforeEach(() => { initialProps = RUN_STATUS_AWAITING_RECOVERY @@ -21,11 +26,12 @@ describe('useShowDoorInfo', () => { route: RECOVERY_MAP.OPTION_SELECTION.ROUTE, step: RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT, } as IRecoveryMap + initialStep = RECOVERY_MAP.OPTION_SELECTION.STEPS.SELECT }) it('should return false values initially', () => { const { result } = renderHook(() => - useShowDoorInfo(initialProps, mockRecoveryMap) + useShowDoorInfo(initialProps, mockRecoveryMap, initialStep) ) expect(result.current).toEqual({ isDoorOpen: false, @@ -36,7 +42,9 @@ describe('useShowDoorInfo', () => { it(`should return true values when runStatus is ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR}`, () => { const props = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR - const { result } = renderHook(() => useShowDoorInfo(props, mockRecoveryMap)) + const { result } = renderHook(() => + useShowDoorInfo(props, mockRecoveryMap, initialStep) + ) expect(result.current).toEqual({ isDoorOpen: true, isProhibitedDoorOpen: true, @@ -46,7 +54,9 @@ describe('useShowDoorInfo', () => { it(`should return true values when runStatus is ${RUN_STATUS_AWAITING_RECOVERY_PAUSED}`, () => { const props = RUN_STATUS_AWAITING_RECOVERY_PAUSED - const { result } = renderHook(() => useShowDoorInfo(props, mockRecoveryMap)) + const { result } = renderHook(() => + useShowDoorInfo(props, mockRecoveryMap, initialStep) + ) expect(result.current).toEqual({ isDoorOpen: true, isProhibitedDoorOpen: true, @@ -55,9 +65,14 @@ describe('useShowDoorInfo', () => { it(`should keep returning true values when runStatus changes from ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR} to ${RUN_STATUS_AWAITING_RECOVERY_PAUSED}`, () => { const { result, rerender } = renderHook( - ({ runStatus, recoveryMap }) => useShowDoorInfo(runStatus, recoveryMap), + ({ runStatus, recoveryMap, currentStep }) => + useShowDoorInfo(runStatus, recoveryMap, currentStep), { - initialProps: { runStatus: initialProps, recoveryMap: mockRecoveryMap }, + initialProps: { + runStatus: initialProps, + recoveryMap: mockRecoveryMap, + currentStep: initialStep, + }, } ) @@ -65,6 +80,7 @@ describe('useShowDoorInfo', () => { rerender({ runStatus: RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }) }) expect(result.current).toEqual({ @@ -76,6 +92,7 @@ describe('useShowDoorInfo', () => { rerender({ runStatus: RUN_STATUS_AWAITING_RECOVERY_PAUSED, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }) }) expect(result.current).toEqual({ @@ -86,11 +103,13 @@ describe('useShowDoorInfo', () => { it('should return false values when runStatus changes to a non-door open status', () => { const { result, rerender } = renderHook( - ({ runStatus, recoveryMap }) => useShowDoorInfo(runStatus, recoveryMap), + ({ runStatus, recoveryMap, currentStep }) => + useShowDoorInfo(runStatus, recoveryMap, currentStep), { initialProps: { runStatus: RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }, } ) @@ -104,6 +123,7 @@ describe('useShowDoorInfo', () => { rerender({ runStatus: RUN_STATUS_AWAITING_RECOVERY as any, recoveryMap: mockRecoveryMap, + currentStep: initialStep, }) }) expect(result.current).toEqual({ @@ -116,12 +136,31 @@ describe('useShowDoorInfo', () => { const props = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR const { result } = renderHook(() => - useShowDoorInfo(props, { - route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, - step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.MANUAL_FILL, - }) + useShowDoorInfo( + props, + { + route: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, + step: RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.MANUAL_FILL, + }, + RECOVERY_MAP.MANUAL_FILL_AND_SKIP.STEPS.MANUAL_FILL + ) ) expect(result.current.isProhibitedDoorOpen).toEqual(false) }) + + it('should return false for prohibited door if the current step is in GRIPPER_MOVE_STEPS', () => { + const props = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR + + GRIPPER_MOVE_STEPS.forEach(step => { + const { result } = renderHook(() => + useShowDoorInfo(props, mockRecoveryMap, step) + ) + + expect(result.current).toEqual({ + isDoorOpen: true, + isProhibitedDoorOpen: false, + }) + }) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts index 2411c95c30e..da85e8b770e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts @@ -6,6 +6,7 @@ export { useRouteUpdateActions } from './useRouteUpdateActions' export { useERUtils } from './useERUtils' export { useRecoveryTakeover } from './useRecoveryTakeover' export { useRetainedFailedCommandBySource } from './useRetainedFailedCommandBySource' +export { useHomeGripperZAxis } from './useHomeGripperZAxis' export type { ERUtilsProps } from './useERUtils' export type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 155c534ba6f..365bf01de36 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -107,7 +107,11 @@ export function useERUtils({ ...subMapUtils } = useRecoveryRouting() - const doorStatusUtils = useShowDoorInfo(runStatus, recoveryMap) + const doorStatusUtils = useShowDoorInfo( + runStatus, + recoveryMap, + recoveryMap.step + ) const recoveryToastUtils = useRecoveryToasts({ currentStepCount: stepCounts.currentStepNumber, @@ -147,6 +151,7 @@ export function useERUtils({ failedPipetteInfo, runRecord, runCommands, + allRunDefs, }) const recoveryCommands = useRecoveryCommands({ diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index e1c15a9e264..ba86e77c553 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -1,7 +1,9 @@ import { useMemo, useState } from 'react' import without from 'lodash/without' +import { useTranslation } from 'react-i18next' import { + FLEX_ROBOT_TYPE, getAllLabwareDefs, getLabwareDisplayName, getLoadedLabwareDefinitionsByUri, @@ -10,7 +12,9 @@ import { import { ERROR_KINDS } from '../constants' import { getErrorKind } from '../utils' import { getLoadedLabware } from '/app/molecules/Command/utils/accessors' +import { getLabwareDisplayLocation } from '/app/molecules/Command' +import type { TFunction } from 'i18next' import type { WellGroup } from '@opentrons/components' import type { CommandsData, PipetteData, Run } from '@opentrons/api-client' import type { @@ -20,6 +24,8 @@ import type { AspirateRunTimeCommand, DispenseRunTimeCommand, LiquidProbeRunTimeCommand, + MoveLabwareRunTimeCommand, + LabwareLocation, } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '..' import type { ERUtilsProps } from './useERUtils' @@ -28,10 +34,16 @@ interface UseFailedLabwareUtilsProps { failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null + allRunDefs: LabwareDefinition2[] runCommands?: CommandsData runRecord?: Run } +interface RelevantFailedLabwareLocations { + currentLoc: string + newLoc: string | null +} + export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { /* The name of the labware relevant to the failed command, if any. */ failedLabwareName: string | null @@ -41,6 +53,7 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { relevantWellName: string | null /* The user-content nickname of the failed labware, if any */ failedLabwareNickname: string | null + failedLabwareLocations: RelevantFailedLabwareLocations } /** Utils for labware relating to the failedCommand. @@ -55,6 +68,7 @@ export function useFailedLabwareUtils({ failedPipetteInfo, runCommands, runRecord, + allRunDefs, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = useMemo( () => @@ -87,12 +101,20 @@ export function useFailedLabwareUtils({ recentRelevantFailedLabwareCmd ) + const failedLabwareLocations = useRelevantFailedLwLocations({ + failedLabware, + failedCommandByRunRecord, + protocolAnalysis, + allRunDefs, + }) + return { ...tipSelectionUtils, failedLabwareName: failedLabwareDetails?.name ?? null, failedLabware, relevantWellName, failedLabwareNickname: failedLabwareDetails?.nickname ?? null, + failedLabwareLocations, } } @@ -101,6 +123,7 @@ type FailedCommandRelevantLabware = | Omit | Omit | Omit + | Omit | null interface RelevantFailedLabwareCmd { @@ -122,6 +145,8 @@ export function getRelevantFailedLabwareCmdFrom({ case ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING: case ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING: return getRelevantPickUpTipCommand(failedCommandByRunRecord, runCommands) + case ERROR_KINDS.GRIPPER_ERROR: + return failedCommandByRunRecord as MoveLabwareRunTimeCommand case ERROR_KINDS.GENERAL_ERROR: return null default: @@ -177,11 +202,8 @@ interface UseTipSelectionUtilsResult { areTipsSelected: boolean } -// TODO(jh, 06-18-24): Enforce failure/warning when accessing tipSelectionUtils -// if used when the relevant labware -// is NOT relevant to tip pick up. - // Utils for initializing and interacting with the Tip Selector component. +// Note: if the relevant failed labware command is not associated with tips, these utils effectively return `null`. function useTipSelectionUtils( recentRelevantFailedLabwareCmd: FailedCommandRelevantLabware ): UseTipSelectionUtilsResult { @@ -293,7 +315,11 @@ export function getRelevantWellName( failedPipetteInfo: UseFailedLabwareUtilsProps['failedPipetteInfo'], recentRelevantPickUpTipCmd: FailedCommandRelevantLabware ): string { - if (failedPipetteInfo == null || recentRelevantPickUpTipCmd == null) { + if ( + failedPipetteInfo == null || + recentRelevantPickUpTipCmd == null || + recentRelevantPickUpTipCmd.commandType === 'moveLabware' + ) { return '' } @@ -309,3 +335,54 @@ export function getRelevantWellName( return wellName } } + +type GetRelevantLwLocationsParams = Pick< + UseFailedLabwareUtilsProps, + 'protocolAnalysis' | 'failedCommandByRunRecord' | 'allRunDefs' +> & { + failedLabware: UseFailedLabwareUtilsResult['failedLabware'] +} + +export function useRelevantFailedLwLocations({ + failedLabware, + failedCommandByRunRecord, + protocolAnalysis, + allRunDefs, +}: GetRelevantLwLocationsParams): RelevantFailedLabwareLocations { + const { t } = useTranslation('protocol_command_text') + const canGetDisplayLocation = + protocolAnalysis != null && failedLabware != null + + const buildLocationCopy = useMemo(() => { + return (location: LabwareLocation | undefined): string | null => { + return canGetDisplayLocation && location != null + ? getLabwareDisplayLocation( + protocolAnalysis, + allRunDefs, + location, + t as TFunction, + FLEX_ROBOT_TYPE, + false // Always return the "full" copy, which is the desktop copy. + ) + : null + } + }, [canGetDisplayLocation, allRunDefs]) + + const currentLocation = useMemo(() => { + return buildLocationCopy(failedLabware?.location) ?? '' + }, [canGetDisplayLocation]) + + const newLocation = useMemo(() => { + switch (failedCommandByRunRecord?.commandType) { + case 'moveLabware': + return buildLocationCopy(failedCommandByRunRecord.params.newLocation) + default: + return null + } + }, [canGetDisplayLocation, failedCommandByRunRecord?.key]) + + return { + currentLoc: currentLocation, + newLoc: newLocation, + } +} diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts new file mode 100644 index 00000000000..649fb801d44 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useHomeGripperZAxis.ts @@ -0,0 +1,44 @@ +import { useLayoutEffect, useState } from 'react' +import { RECOVERY_MAP } from '/app/organisms/ErrorRecoveryFlows/constants' + +import type { ErrorRecoveryWizardProps } from '/app/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard' + +// Home the gripper z-axis implicitly. Because the z-home is not tied to a CTA, it must be handled here. +export function useHomeGripperZAxis({ + recoveryCommands, + routeUpdateActions, + recoveryMap, + doorStatusUtils, +}: ErrorRecoveryWizardProps): void { + const { step } = recoveryMap + const { isDoorOpen } = doorStatusUtils + const [hasHomedOnce, setHasHomedOnce] = useState(false) + + const isManualGripperStep = + step === RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE || + step === RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + + useLayoutEffect(() => { + const { handleMotionRouting, goBackPrevStep } = routeUpdateActions + const { homeGripperZAxis } = recoveryCommands + + if (!hasHomedOnce) { + if (isManualGripperStep) { + if (isDoorOpen) { + void goBackPrevStep() + } else { + void handleMotionRouting(true) + .then(() => homeGripperZAxis()) + .then(() => { + setHasHomedOnce(true) + }) + .finally(() => handleMotionRouting(false)) + } + } + } else { + if (!isManualGripperStep) { + setHasHomedOnce(false) + } + } + }, [step, hasHomedOnce, isDoorOpen, isManualGripperStep]) +} diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index f463d4dd107..fd78a62bcf6 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -58,7 +58,9 @@ export interface UseRecoveryCommandsResult { /* A non-terminal recovery command */ pickUpTips: () => Promise /* A non-terminal recovery command */ - releaseGripperJaws: () => Promise + releaseGripperJaws: () => Promise + /* A non-terminal recovery command */ + homeGripperZAxis: () => Promise } // TODO(jh, 07-24-24): Create tighter abstractions for terminal vs. non-terminal commands. @@ -215,10 +217,13 @@ export function useRecoveryCommands({ failedCommandByRunRecord?.commandType, ]) - const releaseGripperJaws = useCallback((): Promise => { - console.log('PLACEHOLDER RELEASE THE JAWS') - return Promise.resolve() - }, []) + const releaseGripperJaws = useCallback((): Promise => { + return chainRunRecoveryCommands([RELEASE_GRIPPER_JAW]) + }, [chainRunRecoveryCommands]) + + const homeGripperZAxis = useCallback((): Promise => { + return chainRunRecoveryCommands([HOME_GRIPPER_Z_AXIS]) + }, [chainRunRecoveryCommands]) return { resumeRun, @@ -227,6 +232,7 @@ export function useRecoveryCommands({ homePipetteZAxes, pickUpTips, releaseGripperJaws, + homeGripperZAxis, skipFailedCommand, ignoreErrorKindThisRun, } @@ -238,6 +244,18 @@ export const HOME_PIPETTE_Z_AXES: CreateCommand = { intent: 'fixit', } +export const RELEASE_GRIPPER_JAW: CreateCommand = { + commandType: 'unsafe/ungripLabware', + params: {}, + intent: 'fixit', +} + +export const HOME_GRIPPER_Z_AXIS: CreateCommand = { + commandType: 'home', + params: { axes: ['extensionZ'] }, + intent: 'fixit', +} + export const buildPickUpTips = ( tipGroup: WellGroup | null, failedCommand: FailedCommand | null, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts index faf6ddf7a4a..09ef7b3dd47 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRouteUpdateActions.ts @@ -4,7 +4,12 @@ import last from 'lodash/last' import head from 'lodash/head' -import { INVALID, RECOVERY_MAP, STEP_ORDER } from '../constants' +import { + INVALID, + RECOVERY_MAP, + STEP_ORDER, + GRIPPER_MOVE_STEPS, +} from '../constants' import type { IRecoveryMap, RecoveryRoute, @@ -14,11 +19,6 @@ import type { import type { UseRecoveryTakeoverResult } from './useRecoveryTakeover' import type { UseShowDoorInfoResult } from './useShowDoorInfo' -const GRIPPER_MOVE_STEPS: RouteStep[] = [ - RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.GRIPPER_RELEASE_LABWARE, - RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.GRIPPER_RELEASE_LABWARE, -] - export interface GetRouteUpdateActionsParams { hasLaunchedRecovery: boolean toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts index 61b9131b15e..fc8569c02d8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useShowDoorInfo.ts @@ -3,11 +3,11 @@ import { RUN_STATUS_AWAITING_RECOVERY_PAUSED, } from '@opentrons/api-client' -import { RECOVERY_MAP_METADATA } from '../constants' +import { GRIPPER_MOVE_STEPS, RECOVERY_MAP_METADATA } from '../constants' import type { RunStatus } from '@opentrons/api-client' import type { ErrorRecoveryFlowsProps } from '../index' -import type { IRecoveryMap } from '../types' +import type { IRecoveryMap, RouteStep } from '../types' const DOOR_OPEN_STATUSES: RunStatus[] = [ RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, @@ -24,13 +24,17 @@ export interface UseShowDoorInfoResult { // Whether the door is open and not permitted to be open or the user has not yet resumed the run after a door open event. export function useShowDoorInfo( runStatus: ErrorRecoveryFlowsProps['runStatus'], - recoveryMap: IRecoveryMap + recoveryMap: IRecoveryMap, + currentStep: RouteStep ): UseShowDoorInfoResult { // TODO(jh, 07-16-24): "recovery paused" is only used for door status and therefore // a valid way to ensure all apps show the door open prompt, however this could be problematic in the future. // Consider restructuring this check once the takeover modals are added. const isDoorOpen = runStatus != null && DOOR_OPEN_STATUSES.includes(runStatus) - const isProhibitedDoorOpen = isDoorOpen && !isDoorPermittedOpen(recoveryMap) + const isProhibitedDoorOpen = + isDoorOpen && + !isDoorPermittedOpen(recoveryMap) && + !GRIPPER_MOVE_STEPS.includes(currentStep) return { isDoorOpen, isProhibitedDoorOpen } } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx index 80c0422a940..ad1e7b0bc4a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/LeftColumnLabwareInfo.tsx @@ -1,7 +1,6 @@ -import type * as React from 'react' - import { InterventionContent } from '/app/molecules/InterventionModal/InterventionContent' +import type * as React from 'react' import type { RecoveryContentProps } from '../types' type LeftColumnLabwareInfoProps = RecoveryContentProps & { @@ -20,22 +19,15 @@ export function LeftColumnLabwareInfo({ }: LeftColumnLabwareInfoProps): JSX.Element | null { const { failedLabwareName, - failedLabware, failedLabwareNickname, + failedLabwareLocations, } = failedLabwareUtils + const { newLoc, currentLoc } = failedLabwareLocations - const buildLabwareLocationSlotName = (): string => { - const location = failedLabware?.location - if ( - location != null && - typeof location === 'object' && - 'slotName' in location - ) { - return location.slotName - } else { - return '' - } - } + const buildNewLocation = (): React.ComponentProps< + typeof InterventionContent + >['infoProps']['newLocationProps'] => + newLoc != null ? { deckLabel: newLoc.toUpperCase() } : undefined return ( { + setIsLoading(true) + void resumeRecovery() + } + + const buildSubtext = (): string => { + switch (selectedRecoveryOption) { + case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: + case RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE: + return t('door_open_gripper_home') + default: { + console.error( + `Unhandled special-cased door open subtext on route ${selectedRecoveryOption}.` + ) + return t('close_the_robot_door') + } + } + } + + if (!doorStatusUtils.isDoorOpen) { + const { proceedToRouteAndStep } = routeUpdateActions + switch (selectedRecoveryOption) { + case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: + void proceedToRouteAndStep( + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE + ) + break + case RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE: + void proceedToRouteAndStep( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + break + default: { + console.error( + `Unhandled special-cased door open on route ${selectedRecoveryOption}.` + ) + void proceedToRouteAndStep(RECOVERY_MAP.OPTION_SELECTION.ROUTE) + } + } + } + + return ( + + + + + + {t('close_robot_door')} + + + {buildSubtext()} + + + + + + + + ) +} + +const TEXT_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing8}; + align-items: ${ALIGN_CENTER}; + text-align: ${TEXT_ALIGN_CENTER}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing4}; + } +` + +const ICON_STYLE = css` + height: ${SPACING.spacing40}; + width: ${SPACING.spacing40}; + color: ${COLORS.yellow50}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: ${SPACING.spacing60}; + width: ${SPACING.spacing60}; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx index aab38a1aee0..b480c9614f2 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx @@ -9,8 +9,8 @@ import { getSlotNameAndLwLocFrom } from '../hooks/useDeckMapUtils' import { RECOVERY_MAP } from '../constants' import type { RecoveryContentProps } from '../types' - -// TODO(jh, 10-09-24): Add testing for this component. +import type * as React from 'react' +import type { InterventionContent } from '/app/molecules/InterventionModal/InterventionContent' export function TwoColLwInfoAndDeck( props: RecoveryContentProps @@ -88,13 +88,25 @@ export function TwoColLwInfoAndDeck( } } + const buildType = (): React.ComponentProps< + typeof InterventionContent + >['infoProps']['type'] => { + switch (selectedRecoveryOption) { + case MANUAL_MOVE_AND_SKIP.ROUTE: + case MANUAL_REPLACE_AND_RETRY.ROUTE: + return 'location-arrow-location' + default: + return 'location' + } + } + return ( diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx index 9a501e51459..9eff4a09ba4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/GripperReleaseLabware.test.tsx @@ -38,7 +38,7 @@ describe('GripperReleaseLabware', () => { screen.getByText( 'Take any necessary precautions before positioning yourself to stabilize or catch the labware. Once confirmed, a countdown will begin before the gripper releases.' ) - screen.getByText('The labware will be released from its current height') + screen.getByText('The labware will be released from its current height.') }) it('clicking the primary button has correct behavior', () => { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx index c714c0bc8a2..e2e6c268ef8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/LeftColumnLabwareInfo.test.tsx @@ -6,11 +6,9 @@ import { renderWithProviders } from '/app/__testing-utils__' import { mockRecoveryContentProps } from '../../__fixtures__' import { i18n } from '/app/i18n' import { LeftColumnLabwareInfo } from '../LeftColumnLabwareInfo' -import { InterventionInfo } from '/app/molecules/InterventionModal/InterventionContent/InterventionInfo' -import { InlineNotification } from '/app/atoms/InlineNotification' +import { InterventionContent } from '/app/molecules/InterventionModal/InterventionContent' -vi.mock('/app/molecules/InterventionModal/InterventionContent/InterventionInfo') -vi.mock('/app/atoms/InlineNotification') +vi.mock('/app/molecules/InterventionModal/InterventionContent') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -27,60 +25,83 @@ describe('LeftColumnLabwareInfo', () => { title: 'MOCK_TITLE', failedLabwareUtils: { failedLabwareName: 'MOCK_LW_NAME', - failedLabware: { - location: { slotName: 'A1' }, + failedLabwareNickname: 'MOCK_LW_NICKNAME', + failedLabwareLocations: { + currentLoc: 'slot A1', + newLoc: 'slot B2', }, } as any, type: 'location', bannerText: 'MOCK_BANNER_TEXT', } - vi.mocked(InterventionInfo).mockReturnValue(
    MOCK_MOVE
    ) - vi.mocked(InlineNotification).mockReturnValue( -
    MOCK_INLINE_NOTIFICATION
    + vi.mocked(InterventionContent).mockReturnValue( +
    MOCK_INTERVENTION_CONTENT
    ) }) - it('renders the title, InterventionInfo component, and InlineNotification when bannerText is provided', () => { + it('renders the InterventionContent component with correct props', () => { render(props) - screen.getByText('MOCK_TITLE') - screen.getByText('MOCK_MOVE') - expect(vi.mocked(InterventionInfo)).toHaveBeenCalledWith( + screen.getByText('MOCK_INTERVENTION_CONTENT') + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( expect.objectContaining({ - type: 'location', - labwareName: 'MOCK_LW_NAME', - currentLocationProps: { deckLabel: 'A1' }, + headline: 'MOCK_TITLE', + infoProps: { + type: 'location', + labwareName: 'MOCK_LW_NAME', + labwareNickname: 'MOCK_LW_NICKNAME', + currentLocationProps: { deckLabel: 'SLOT A1' }, + newLocationProps: { deckLabel: 'SLOT B2' }, + }, + notificationProps: { + type: 'alert', + heading: 'MOCK_BANNER_TEXT', + }, }), {} ) - screen.getByText('MOCK_INLINE_NOTIFICATION') - expect(vi.mocked(InlineNotification)).toHaveBeenCalledWith( + }) + + it('does not include notificationProps when bannerText is not provided', () => { + props.bannerText = undefined + render(props) + + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( expect.objectContaining({ - type: 'alert', - heading: 'MOCK_BANNER_TEXT', + notificationProps: undefined, }), {} ) }) - it('does not render the InlineNotification when bannerText is not provided', () => { - props.bannerText = undefined + it('does not include newLocationProps when newLoc is not provided', () => { + props.failedLabwareUtils.failedLabwareLocations.newLoc = null render(props) - screen.getByText('MOCK_TITLE') - screen.getByText('MOCK_MOVE') - expect(screen.queryByText('MOCK_INLINE_NOTIFICATION')).toBeNull() + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( + expect.objectContaining({ + infoProps: expect.not.objectContaining({ + newLocationProps: expect.anything(), + }), + }), + {} + ) }) - it('returns an empty string for slotName when failedLabware location is not an object with slotName', () => { - // @ts-expect-error yeah this is ok - props.failedLabwareUtils.failedLabware.location = 'offDeck' + it('converts location labels to uppercase', () => { + props.failedLabwareUtils.failedLabwareLocations = { + currentLoc: 'slot A1', + newLoc: 'slot B2', + } render(props) - expect(vi.mocked(InterventionInfo)).toHaveBeenCalledWith( + expect(vi.mocked(InterventionContent)).toHaveBeenCalledWith( expect.objectContaining({ - currentLocationProps: { deckLabel: '' }, + infoProps: expect.objectContaining({ + currentLocationProps: { deckLabel: 'SLOT A1' }, + newLocationProps: { deckLabel: 'SLOT B2' }, + }), }), {} ) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx new file mode 100644 index 00000000000..423f75396c0 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RecoveryDoorOpenSpecial.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, vi, expect, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY, +} from '@opentrons/api-client' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { RecoveryDoorOpenSpecial } from '../RecoveryDoorOpenSpecial' +import { RECOVERY_MAP } from '../../constants' + +import type * as React from 'react' +import { clickButtonLabeled } from '/app/organisms/ErrorRecoveryFlows/__tests__/util' + +describe('RecoveryDoorOpenSpecial', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + }, + runStatus: RUN_STATUS_AWAITING_RECOVERY, + recoveryActionMutationUtils: { + resumeRecovery: vi.fn(), + }, + routeUpdateActions: { + proceedToRouteAndStep: vi.fn(), + }, + doorStatusUtils: { + isDoorOpen: true, + }, + } as any + }) + + const render = ( + props: React.ComponentProps + ) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] + } + + it('calls resumeRecovery when primary button is clicked', async () => { + render(props) + + clickButtonLabeled('Continue') + + expect(props.recoveryActionMutationUtils.resumeRecovery).toHaveBeenCalled() + }) + + it(`disables primary button when runStatus is ${RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR}`, () => { + props.runStatus = RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR + render(props) + + const btn = screen.getAllByRole('button', { name: 'Continue' })[0] + + expect(btn).toBeDisabled() + }) + + it(`renders correct copy for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + render(props) + screen.getByText('Close the robot door') + screen.getByText( + 'The robot door must be closed for the gripper to home its Z-axis before you can continue manually moving labware.' + ) + }) + + it('renders default subtext for unhandled recovery option', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = 'UNHANDLED_OPTION' as any + render(props) + screen.getByText('Close the robot door') + screen.getByText( + 'Close the robot door, and then resume the recovery action.' + ) + }) + + it(`calls proceedToRouteAndStep when door is closed for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE}`, () => { + props.doorStatusUtils.isDoorOpen = false + render(props) + expect(props.routeUpdateActions.proceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.STEPS.MANUAL_REPLACE + ) + }) + + it(`calls proceedToRouteAndStep when door is closed for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE + props.doorStatusUtils.isDoorOpen = false + render(props) + expect(props.routeUpdateActions.proceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE + ) + }) + + it('calls proceedToRouteAndStep with OPTION_SELECTION for unhandled recovery option when door is closed', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = 'UNHANDLED_OPTION' as any + props.doorStatusUtils.isDoorOpen = false + render(props) + expect(props.routeUpdateActions.proceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.OPTION_SELECTION.ROUTE + ) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx index 8b8ab83d9f9..9a8fc10f5d6 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/SelectTips.test.tsx @@ -53,6 +53,7 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: { A1: null }, areTipsSelected: true, + failedLabwareLocations: { newLoc: null, currentLoc: 'A1' }, } as any, } @@ -160,6 +161,7 @@ describe('SelectTips', () => { failedLabwareUtils: { selectedTipLocations: null, areTipsSelected: false, + failedLabwareLocations: { newLoc: null, currentLoc: '' }, } as any, } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx new file mode 100644 index 00000000000..f2206c8f010 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/TwoColLwInfoAndDeck.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, vi, expect, beforeEach } from 'vitest' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { clickButtonLabeled } from '/app/organisms/ErrorRecoveryFlows/__tests__/util' +import { TwoColLwInfoAndDeck } from '../TwoColLwInfoAndDeck' +import { RECOVERY_MAP } from '../../constants' +import { LeftColumnLabwareInfo } from '../LeftColumnLabwareInfo' +import { getSlotNameAndLwLocFrom } from '../../hooks/useDeckMapUtils' + +import type * as React from 'react' +import type { Mock } from 'vitest' + +vi.mock('../LeftColumnLabwareInfo') +vi.mock('../../hooks/useDeckMapUtils') + +let mockProceedNextStep: Mock + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('TwoColLwInfoAndDeck', () => { + let props: React.ComponentProps + + beforeEach(() => { + mockProceedNextStep = vi.fn() + + props = { + routeUpdateActions: { + proceedNextStep: mockProceedNextStep, + }, + failedPipetteUtils: { + failedPipetteInfo: { data: { channels: 8 } }, + isPartialTipConfigValid: false, + }, + failedLabwareUtils: { + relevantWellName: 'A1', + failedLabware: { location: 'C1' }, + }, + deckMapUtils: {}, + currentRecoveryOptionUtils: { + selectedRecoveryOption: RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, + }, + } as any + + vi.mocked(LeftColumnLabwareInfo).mockReturnValue( + vi.fn(() =>
    ) as any + ) + vi.mocked(getSlotNameAndLwLocFrom).mockReturnValue(['C1'] as any) + }) + + it('calls proceedNextStep when primary button is clicked', () => { + render(props) + clickButtonLabeled('Continue') + expect(mockProceedNextStep).toHaveBeenCalled() + }) + + it(`passes correct title to LeftColumnLabwareInfo for ${RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE}`, () => { + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Manually move labware on deck', + type: 'location-arrow-location', + bannerText: + 'Ensure labware is accurately placed in the slot to prevent further errors.', + }), + expect.anything() + ) + }) + + it(`passes correct title to LeftColumnLabwareInfo for ${RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Manually replace labware on deck', + type: 'location-arrow-location', + bannerText: + 'Ensure labware is accurately placed in the slot to prevent further errors.', + }), + expect.anything() + ) + }) + + it(`passes correct title to LeftColumnLabwareInfo for ${RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE}`, () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Replace used tips in rack location A1 in Slot C1', + type: 'location', + bannerText: + "It's best to replace tips and select the last location used for tip pickup.", + }), + expect.anything() + ) + }) + + it('passes correct title to LeftColumnLabwareInfo for 96-channel pipette', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE + // @ts-expect-error This is a test. It's always defined. + props.failedPipetteUtils.failedPipetteInfo.data.channels = 96 + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Replace with new tip rack in Slot C1', + type: 'location', + bannerText: + "It's best to replace tips and select the last location used for tip pickup.", + }), + expect.anything() + ) + }) + + it('passes correct title to LeftColumnLabwareInfo for partial tip config', () => { + props.currentRecoveryOptionUtils.selectedRecoveryOption = + RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE + props.failedPipetteUtils.isPartialTipConfigValid = true + render(props) + expect(vi.mocked(LeftColumnLabwareInfo)).toHaveBeenCalledWith( + expect.objectContaining({ + bannerText: + 'Replace tips and select the last location used for partial tip pickup.', + }), + expect.anything() + ) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/index.ts b/app/src/organisms/ErrorRecoveryFlows/shared/index.ts index 4e6b2708c12..0c9df1d9553 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/shared/index.ts @@ -19,5 +19,6 @@ export { GripperReleaseLabware } from './GripperReleaseLabware' export { RetryStepInfo } from './RetryStepInfo' export { SkipStepInfo } from './SkipStepInfo' export { GripperIsHoldingLabware } from './GripperIsHoldingLabware' +export { RecoveryDoorOpenSpecial } from './RecoveryDoorOpenSpecial' export type { RecoveryInterventionModalProps } from './RecoveryInterventionModal' diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index fd460f573b2..d24a6f8e054 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -6,12 +6,14 @@ export type UnsafeRunTimeCommand = | UnsafeDropTipInPlaceRunTimeCommand | UnsafeUpdatePositionEstimatorsRunTimeCommand | UnsafeEngageAxesRunTimeCommand + | UnsafeUngripLabwareRunTimeCommand export type UnsafeCreateCommand = | UnsafeBlowoutInPlaceCreateCommand | UnsafeDropTipInPlaceCreateCommand | UnsafeUpdatePositionEstimatorsCreateCommand | UnsafeEngageAxesCreateCommand + | UnsafeUngripLabwareCreateCommand export interface UnsafeBlowoutInPlaceParams { pipetteId: string @@ -72,3 +74,14 @@ export interface UnsafeEngageAxesRunTimeCommand UnsafeEngageAxesCreateCommand { result?: any } + +export interface UnsafeUngripLabwareCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/ungripLabware' + params: {} +} +export interface UnsafeUngripLabwareRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafeUngripLabwareCreateCommand { + result?: any +} From 80176bafedc7886ef9bb01d6779f0774cd7a71f1 Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Tue, 15 Oct 2024 13:44:32 -0400 Subject: [PATCH 061/101] feat(protocol-designer): foundation for batch edit and multi-select (#16482) closes AUTH-819 --- .../localization/en/protocol_steps.json | 5 + .../BatchEditToolbox/BatchEditMixTools.tsx | 3 + .../BatchEditMoveLiquidTools.tsx | 3 + .../ProtocolSteps/BatchEditToolbox/index.tsx | 90 +++++++++++++ .../ProtocolSteps/BatchEditToolbox/utils.ts | 48 +++++++ .../Timeline/ConnectedStepInfo.tsx | 71 ++++++++++- .../Timeline/StepOverflowMenu.tsx | 119 ++++++++++++++---- .../Timeline/TerminalItemStep.tsx | 11 +- .../__tests__/StepOverflowMenu.test.tsx | 17 ++- .../Designer/ProtocolSteps/Timeline/utils.ts | 99 +++++++++++++++ .../pages/Designer/ProtocolSteps/index.tsx | 8 +- 11 files changed, 443 insertions(+), 31 deletions(-) create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 3b00d2f7d5c..24e42424355 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -1,10 +1,15 @@ { "add_details": "Add step details", "aspirated": "Aspirated", + "batch_edit_steps": "Batch edit steps", + "batch_edit": "Batch edit", + "batch_edits_saved": "Batch edits saved", "change_tips": "Change tips", "default_tip_option": "Default - get next tip", + "delete_steps": "Delete steps", "delete": "Delete step", "dispensed": "Dispensed", + "duplicate_steps": "Duplicate steps", "duplicate": "Duplicate step", "edit_step": "Edit step", "engage_height": "Engage height", diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx new file mode 100644 index 00000000000..29a7080f76c --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMixTools.tsx @@ -0,0 +1,3 @@ +export function BatchEditMixTools(): JSX.Element { + return
    Todo: wire this up
    +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx new file mode 100644 index 00000000000..58f3e9d8c26 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/BatchEditMoveLiquidTools.tsx @@ -0,0 +1,3 @@ +export function BatchEditMoveLiquidTools(): JSX.Element { + return
    Todo: wire this up
    +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx new file mode 100644 index 00000000000..0f66e7d21f0 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { Icon, PrimaryButton, StyledText, Toolbox } from '@opentrons/components' +import { + getBatchEditSelectedStepTypes, + getMultiSelectDisabledFields, + getMultiSelectFieldValues, + getMultiSelectItemIds, +} from '../../../../ui/steps/selectors' +import { useKitchen } from '../../../../organisms/Kitchen/hooks' +import { deselectAllSteps } from '../../../../ui/steps/actions/actions' +import { + // changeBatchEditField, + resetBatchEditFieldChanges, + saveStepFormsMulti, +} from '../../../../step-forms/actions' +import { BatchEditMoveLiquidTools } from './BatchEditMoveLiquidTools' +import { BatchEditMixTools } from './BatchEditMixTools' +// import { maskField } from '../../../../steplist/fieldLevel' + +// import type { StepFieldName } from '../../../../steplist/fieldLevel' +import type { ThunkDispatch } from 'redux-thunk' +import type { BaseState } from '../../../../types' + +export const BatchEditToolbox = (): JSX.Element | null => { + const { t } = useTranslation(['tooltip', 'protocol_steps', 'shared']) + const { makeSnackbar } = useKitchen() + const dispatch = useDispatch>() + const fieldValues = useSelector(getMultiSelectFieldValues) + const stepTypes = useSelector(getBatchEditSelectedStepTypes) + const disabledFields = useSelector(getMultiSelectDisabledFields) + const selectedStepIds = useSelector(getMultiSelectItemIds) + + // const handleChangeFormInput = (name: StepFieldName, value: unknown): void => { + // const maskedValue = maskField(name, value) + // dispatch(changeBatchEditField({ [name]: maskedValue })) + // } + + const handleSave = (): void => { + dispatch(saveStepFormsMulti(selectedStepIds)) + makeSnackbar(t('batch_edits_saved') as string) + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + } + + const handleCancel = (): void => { + dispatch(resetBatchEditFieldChanges()) + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + } + + const stepType = stepTypes.length === 1 ? stepTypes[0] : null + + if (stepType !== null && fieldValues !== null && disabledFields !== null) { + // const propsForFields = makeBatchEditFieldProps( + // fieldValues, + // disabledFields, + // handleChangeFormInput, + // t + // ) + if (stepType === 'moveLiquid' || stepType === 'mix') { + return ( + + {t('protocol_steps:batch_edit')} + + } + childrenPadding="0" + onCloseClick={handleCancel} + closeButton={} + confirmButton={ + + {t('shared:save')} + + } + > + {stepType === 'moveLiquid' ? ( + + ) : ( + + )} + + ) + } else { + return null + } + } else { + return null + } +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts new file mode 100644 index 00000000000..4d8f581acea --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/utils.ts @@ -0,0 +1,48 @@ +import noop from 'lodash/noop' +import { + getFieldDefaultTooltip, + getFieldIndeterminateTooltip, +} from '../StepForm/utils' +import type { + DisabledFields, + MultiselectFieldValues, +} from '../../../../ui/steps/selectors' +import type { StepFieldName } from '../../../../form-types' +import type { FieldPropsByName } from '../StepForm/types' +export const makeBatchEditFieldProps = ( + fieldValues: MultiselectFieldValues, + disabledFields: DisabledFields, + handleChangeFormInput: (name: string, value: unknown) => void, + t: any +): FieldPropsByName => { + const fieldNames: StepFieldName[] = Object.keys(fieldValues) + return fieldNames.reduce((acc, name) => { + const defaultTooltip = getFieldDefaultTooltip(name, t) + const isIndeterminate = fieldValues[name].isIndeterminate + const indeterminateTooltip = getFieldIndeterminateTooltip(name, t) + let tooltipContent = defaultTooltip // Default to the default content (or blank) + + if (isIndeterminate && indeterminateTooltip) { + tooltipContent = indeterminateTooltip + } + + if (name in disabledFields) { + tooltipContent = disabledFields[name] // Use disabled content if field is disabled, override indeterminate tooltip if applicable + } + + acc[name] = { + disabled: name in disabledFields, + name, + updateValue: value => { + handleChangeFormInput(name, value) + }, + value: fieldValues[name].value, + errorToShow: null, + onFieldBlur: noop, + onFieldFocus: noop, + isIndeterminate: isIndeterminate, + tooltipContent: tooltipContent, + } + return acc + }, {}) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx index 318fa72e1ea..1e533b2bfd3 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/ConnectedStepInfo.tsx @@ -10,6 +10,8 @@ import { getHoveredSubstep, getMultiSelectItemIds, getSelectedStepId, + getMultiSelectLastSelected, + getIsMultiSelectMode, } from '../../../../ui/steps' import { selectors as fileDataSelectors } from '../../../../file-data' import { @@ -24,9 +26,19 @@ import { } from '../../../../ui/steps/actions/actions' import { getOrderedStepIds } from '../../../../step-forms/selectors' import { StepContainer } from './StepContainer' +import { + getMetaSelectedSteps, + getMouseClickKeyInfo, + getShiftSelectedSteps, + nonePressed, +} from './utils' +import type * as React from 'react' import type { ThunkDispatch } from 'redux-thunk' -import type { HoverOnStepAction } from '../../../../ui/steps' +import type { + HoverOnStepAction, + SelectMultipleStepsAction, +} from '../../../../ui/steps' import type { StepIdType } from '../../../../form-types' import type { BaseState, ThunkAction } from '../../../../types' import type { DeleteModalType } from '../../../../components/modals/ConfirmDeleteModal' @@ -65,6 +77,9 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { const hoveredStep = useSelector(getHoveredStepId) const selectedStepId = useSelector(getSelectedStepId) const multiSelectItemIds = useSelector(getMultiSelectItemIds) + const orderedStepIds = useSelector(stepFormSelectors.getOrderedStepIds) + const lastMultiSelectedStepId = useSelector(getMultiSelectLastSelected) + const isMultiSelectMode = useSelector(getIsMultiSelectMode) const selected: boolean = multiSelectItemIds?.length ? multiSelectItemIds.includes(stepId) : selectedStepId === stepId @@ -74,6 +89,15 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { const singleEditFormHasUnsavedChanges = useSelector( stepFormSelectors.getCurrentFormHasUnsavedChanges ) + const batchEditFormHasUnsavedChanges = useSelector( + stepFormSelectors.getBatchEditFormHasUnsavedChanges + ) + const selectMultipleSteps = ( + steps: StepIdType[], + lastSelected: StepIdType + ): ThunkAction => + dispatch(stepsActions.selectMultipleSteps(steps, lastSelected)) + const selectStep = (): ThunkAction => dispatch(stepsActions.resetSelectStep(stepId)) const selectStepOnDoubleClick = (): ThunkAction => @@ -82,15 +106,51 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { dispatch(stepsActions.hoverOnStep(stepId)) const unhighlightStep = (): HoverOnStepAction => dispatch(stepsActions.hoverOnStep(null)) - const handleSelectStep = (): void => { - selectStep() + const handleSelectStep = (event: React.MouseEvent): void => { if (selectedStep !== stepId) { dispatch(toggleViewSubstep(null)) dispatch(hoverOnStep(null)) } + const { isShiftKeyPressed, isMetaKeyPressed } = getMouseClickKeyInfo(event) + let stepsToSelect: StepIdType[] = [] + + // if user clicked on the last multi-selected step, shift/meta keys don't matter + const toggledLastSelected = stepId === lastMultiSelectedStepId + const noModifierKeys = + nonePressed([isShiftKeyPressed, isMetaKeyPressed]) || toggledLastSelected + + if (noModifierKeys) { + selectStep() + } else if ( + (isMetaKeyPressed || isShiftKeyPressed) && + currentFormIsPresaved + ) { + // current form is presaved, enter batch edit mode with only the clicked + stepsToSelect = [stepId] + } else { + if (isShiftKeyPressed) { + stepsToSelect = getShiftSelectedSteps( + selectedStepId, + orderedStepIds, + stepId, + multiSelectItemIds, + lastMultiSelectedStepId + ) + } else if (isMetaKeyPressed) { + stepsToSelect = getMetaSelectedSteps( + multiSelectItemIds, + stepId, + selectedStepId + ) + } + } + if (stepsToSelect.length > 0) { + selectMultipleSteps(stepsToSelect, stepId) + } } const handleSelectDoubleStep = (): void => { selectStepOnDoubleClick() + if (selectedStep !== stepId) { dispatch(toggleViewSubstep(null)) dispatch(hoverOnStep(null)) @@ -105,9 +165,12 @@ export function ConnectedStepInfo(props: ConnectedStepInfoProps): JSX.Element { handleSelectDoubleStep, currentFormIsPresaved || singleEditFormHasUnsavedChanges ) + const { confirm, showConfirmation, cancel } = useConditionalConfirm( handleSelectStep, - currentFormIsPresaved || singleEditFormHasUnsavedChanges + isMultiSelectMode + ? batchEditFormHasUnsavedChanges + : currentFormIsPresaved || singleEditFormHasUnsavedChanges ) const getModalType = (): DeleteModalType => { diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx index 0191ab9969d..5078ff4c0e5 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/StepOverflowMenu.tsx @@ -15,24 +15,33 @@ import { useConditionalConfirm, } from '@opentrons/components' import { actions as steplistActions } from '../../../../steplist' -import { actions as stepsActions } from '../../../../ui/steps' import { + getMultiSelectItemIds, + actions as stepsActions, +} from '../../../../ui/steps' +import { + CLOSE_BATCH_EDIT_FORM, CLOSE_STEP_FORM_WITH_CHANGES, CLOSE_UNSAVED_STEP_FORM, ConfirmDeleteModal, + DELETE_MULTIPLE_STEP_FORMS, DELETE_STEP_FORM, } from '../../../../components/modals/ConfirmDeleteModal' import { hoverOnStep, toggleViewSubstep, populateForm, + deselectAllSteps, } from '../../../../ui/steps/actions/actions' import { + getBatchEditFormHasUnsavedChanges, getCurrentFormHasUnsavedChanges, getCurrentFormIsPresaved, getSavedStepForms, getUnsavedForm, } from '../../../../step-forms/selectors' +import { deleteMultipleSteps } from '../../../../steplist/actions' +import { duplicateMultipleSteps } from '../../../../ui/steps/actions/thunks' import type * as React from 'react' import type { ThunkDispatch } from 'redux-thunk' import type { BaseState } from '../../../../types' @@ -49,6 +58,7 @@ interface StepOverflowMenuProps { export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { const { stepId, menuRootRef, top, setStepOverflowMenu } = props const { t } = useTranslation('protocol_steps') + const multiSelectItemIds = useSelector(getMultiSelectItemIds) const dispatch = useDispatch>() const deleteStep = (stepId: StepIdType): void => { dispatch(steplistActions.deleteStep(stepId)) @@ -59,6 +69,9 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { const singleEditFormHasUnsavedChanges = useSelector( getCurrentFormHasUnsavedChanges ) + const batchEditFormHasUnsavedChanges = useSelector( + getBatchEditFormHasUnsavedChanges + ) const duplicateStep = ( stepId: StepIdType ): ReturnType => @@ -77,11 +90,44 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { ) } } + const onDuplicateClickAction = (): void => { + if (multiSelectItemIds) { + dispatch(duplicateMultipleSteps(multiSelectItemIds)) + } else { + console.warn( + 'something went wrong, you cannot duplicate multiple steps if none are selected' + ) + } + } + const onDeleteClickAction = (): void => { + if (multiSelectItemIds) { + dispatch(deleteMultipleSteps(multiSelectItemIds)) + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + } else { + console.warn( + 'something went wrong, you cannot delete multiple steps if none are selected' + ) + } + } const { confirm, showConfirmation, cancel } = useConditionalConfirm( handleStepItemSelection, currentFormIsPresaved || singleEditFormHasUnsavedChanges ) + const { + confirm: confirmDuplicate, + showConfirmation: showDuplicateConfirmation, + cancel: cancelDuplicate, + } = useConditionalConfirm( + onDuplicateClickAction, + batchEditFormHasUnsavedChanges + ) + + const { + confirm: confirmMultiDelete, + showConfirmation: showMultiDeleteConfirmation, + cancel: cancelMultiDelete, + } = useConditionalConfirm(onDeleteClickAction, true) const { confirm: confirmDelete, @@ -112,6 +158,22 @@ export function StepOverflowMenu(props: StepOverflowMenuProps): JSX.Element { /> )} {/* TODO: update this modal */} + {showDuplicateConfirmation && ( + + )} + {/* TODO: update this modal */} + {showMultiDeleteConfirmation && ( + + )} + {/* TODO: update this modal */} {showDeleteConfirmation && ( - {formData != null ? null : ( - {t('edit_step')} + {multiSelectItemIds != null && multiSelectItemIds.length > 0 ? ( + <> + + {t('duplicate_steps')} + + + {t('delete_steps')} + + + ) : ( + <> + {formData != null ? null : ( + {t('edit_step')} + )} + {isPipetteStep || isThermocyclerStep ? ( + { + dispatch(hoverOnStep(stepId)) + dispatch(toggleViewSubstep(stepId)) + }} + > + {t('view_details')} + + ) : null} + { + duplicateStep(stepId) + }} + > + {t('duplicate')} + + + {t('delete')} + )} - {isPipetteStep || isThermocyclerStep ? ( - { - dispatch(hoverOnStep(stepId)) - dispatch(toggleViewSubstep(stepId)) - }} - > - {t('view_details')} - - ) : null} - { - duplicateStep(stepId) - }} - > - {t('duplicate')} - - - {t('delete')} ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx index 7104e43e5c1..f9c80080dad 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TerminalItemStep.tsx @@ -16,6 +16,7 @@ import { ConfirmDeleteModal, } from '../../../../components/modals/ConfirmDeleteModal' import { + deselectAllSteps, hoverOnStep, toggleViewSubstep, } from '../../../../ui/steps/actions/actions' @@ -26,6 +27,7 @@ import type { HoverOnTerminalItemAction, } from '../../../../ui/steps' import type { TerminalItemId } from '../../../../steplist' +import type { ThunkDispatch } from '../../../../types' export interface TerminalItemStepProps { id: TerminalItemId @@ -40,7 +42,7 @@ export function TerminalItemStep(props: TerminalItemStepProps): JSX.Element { const formHasChanges = useSelector(getCurrentFormHasUnsavedChanges) const isMultiSelectMode = useSelector(getIsMultiSelectMode) - const dispatch = useDispatch() + const dispatch = useDispatch>() const selectItem = (): SelectTerminalItemAction => dispatch(stepsActions.selectTerminalItem(id)) @@ -58,7 +60,12 @@ export function TerminalItemStep(props: TerminalItemStepProps): JSX.Element { currentFormIsPresaved || formHasChanges ) - const onClick = isMultiSelectMode ? () => null : confirm + const onClick = isMultiSelectMode + ? () => { + dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) + handleConfirm() + } + : confirm return ( <> diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx index 3defbe61099..f37d2114c74 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/__tests__/StepOverflowMenu.test.tsx @@ -3,7 +3,10 @@ import '@testing-library/jest-dom/vitest' import { fireEvent, screen } from '@testing-library/react' import { renderWithProviders } from '../../../../../__testing-utils__' import { i18n } from '../../../../../assets/localization' -import { duplicateStep } from '../../../../../ui/steps/actions/thunks' +import { + getMultiSelectItemIds, + actions as stepsActions, +} from '../../../../../ui/steps' import { StepOverflowMenu } from '../StepOverflowMenu' import { getCurrentFormHasUnsavedChanges, @@ -21,6 +24,7 @@ import type * as OpentronsComponents from '@opentrons/components' const mockConfirm = vi.fn() const mockCancel = vi.fn() +vi.mock('../../../../../ui/steps') vi.mock('../../../../../step-forms/selectors') vi.mock('../../../../../ui/steps/actions/actions') vi.mock('../../../../../ui/steps/actions/thunks') @@ -53,6 +57,7 @@ describe('StepOverflowMenu', () => { menuRootRef: { current: null }, setStepOverflowMenu: vi.fn(), } + vi.mocked(getMultiSelectItemIds).mockReturnValue(null) vi.mocked(getCurrentFormIsPresaved).mockReturnValue(false) vi.mocked(getCurrentFormHasUnsavedChanges).mockReturnValue(false) vi.mocked(getUnsavedForm).mockReturnValue(null) @@ -71,11 +76,19 @@ describe('StepOverflowMenu', () => { fireEvent.click(screen.getByText('delete step')) expect(mockConfirm).toHaveBeenCalled() fireEvent.click(screen.getByText('Duplicate step')) - expect(vi.mocked(duplicateStep)).toHaveBeenCalled() + expect(vi.mocked(stepsActions.duplicateStep)).toHaveBeenCalled() fireEvent.click(screen.getByText('Edit step')) expect(mockConfirm).toHaveBeenCalled() fireEvent.click(screen.getByText('View details')) expect(vi.mocked(hoverOnStep)).toHaveBeenCalled() expect(vi.mocked(toggleViewSubstep)).toHaveBeenCalled() }) + + it('renders the multi select overflow menu', () => { + vi.mocked(getMultiSelectItemIds).mockReturnValue(['1', '2']) + render(props) + screen.getByText('Duplicate steps') + screen.getByText('Delete steps') + screen.getByText('Delete multiple steps') + }) }) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts index ef502c2a53f..c7f6f812dc2 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/utils.ts @@ -1,6 +1,9 @@ import round from 'lodash/round' import omitBy from 'lodash/omitBy' +import uniq from 'lodash/uniq' +import { UAParser } from 'ua-parser-js' import type { WellIngredientVolumeData } from '../../../../steplist' +import type { StepIdType } from '../../../../form-types' export const capitalizeFirstLetterAfterNumber = (title: string): string => title.replace( @@ -50,3 +53,99 @@ export const compactPreIngreds = ( return typeof ingred?.volume === 'number' && ingred.volume <= 0 }) } + +export const getMetaSelectedSteps = ( + multiSelectItemIds: StepIdType[] | null, + stepId: StepIdType, + selectedStepId: StepIdType | null +): StepIdType[] => { + let stepsToSelect: StepIdType[] + if (multiSelectItemIds?.length) { + // already have a selection, add/remove the meta-clicked item + stepsToSelect = multiSelectItemIds.includes(stepId) + ? multiSelectItemIds.filter(id => id !== stepId) + : [...multiSelectItemIds, stepId] + } else if (selectedStepId && selectedStepId === stepId) { + // meta-clicked on the selected single step + stepsToSelect = [selectedStepId] + } else if (selectedStepId) { + // meta-clicked on a different step, multi-select both + stepsToSelect = [selectedStepId, stepId] + } else { + // meta-clicked on a step when a terminal item was selected + stepsToSelect = [stepId] + } + return stepsToSelect +} + +export const getShiftSelectedSteps = ( + selectedStepId: StepIdType | null, + orderedStepIds: StepIdType[], + stepId: StepIdType, + multiSelectItemIds: StepIdType[] | null, + lastMultiSelectedStepId: StepIdType | null +): StepIdType[] => { + let stepsToSelect: StepIdType[] + if (selectedStepId) { + stepsToSelect = getOrderedStepsInRange( + selectedStepId, + stepId, + orderedStepIds + ) + } else if (multiSelectItemIds?.length && lastMultiSelectedStepId) { + const potentialStepsToSelect = getOrderedStepsInRange( + lastMultiSelectedStepId, + stepId, + orderedStepIds + ) + + const allSelected: boolean = potentialStepsToSelect + .slice(1) + .every(stepId => multiSelectItemIds.includes(stepId)) + + if (allSelected) { + // if they're all selected, deselect them all + if (multiSelectItemIds.length - potentialStepsToSelect.length > 0) { + stepsToSelect = multiSelectItemIds.filter( + (id: StepIdType) => !potentialStepsToSelect.includes(id) + ) + } else { + // unless deselecting them all results in none being selected + stepsToSelect = [potentialStepsToSelect[0]] + } + } else { + stepsToSelect = uniq([...multiSelectItemIds, ...potentialStepsToSelect]) + } + } else { + stepsToSelect = [stepId] + } + return stepsToSelect +} + +const getOrderedStepsInRange = ( + lastSelectedStepId: StepIdType, + stepId: StepIdType, + orderedStepIds: StepIdType[] +): StepIdType[] => { + const prevIndex: number = orderedStepIds.indexOf(lastSelectedStepId) + const currentIndex: number = orderedStepIds.indexOf(stepId) + + const [startIndex, endIndex] = [prevIndex, currentIndex].sort((a, b) => a - b) + const orderedSteps = orderedStepIds.slice(startIndex, endIndex + 1) + return orderedSteps +} + +export const nonePressed = (keysPressed: boolean[]): boolean => + keysPressed.every(keyPress => keyPress === false) + +export const getMouseClickKeyInfo = ( + event: React.MouseEvent +): { isShiftKeyPressed: boolean; isMetaKeyPressed: boolean } => { + const isMac: boolean = getUserOS() === 'Mac OS' + const isShiftKeyPressed: boolean = event.shiftKey + const isMetaKeyPressed: boolean = + (isMac && event.metaKey) || (!isMac && event.ctrlKey) + return { isShiftKeyPressed, isMetaKeyPressed } +} + +const getUserOS = (): string | undefined => new UAParser().getOS().name diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx index 7ff29ec1c30..800f1115633 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx @@ -14,16 +14,21 @@ import { ToggleGroup, } from '@opentrons/components' import { getUnsavedForm } from '../../../step-forms/selectors' -import { getSelectedSubstep } from '../../../ui/steps/selectors' import { getEnableHotKeysDisplay } from '../../../feature-flags/selectors' +import { + getIsMultiSelectMode, + getSelectedSubstep, +} from '../../../ui/steps/selectors' import { DeckSetupContainer } from '../DeckSetup' import { OffDeck } from '../Offdeck' import { TimelineToolbox, SubstepsToolbox } from './Timeline' import { StepForm } from './StepForm' +import { BatchEditToolbox } from './BatchEditToolbox' export function ProtocolSteps(): JSX.Element { const { t } = useTranslation('starting_deck_state') const formData = useSelector(getUnsavedForm) + const isMultiSelectMode = useSelector(getIsMultiSelectMode) const selectedSubstep = useSelector(getSelectedSubstep) const enableHoyKeyDisplay = useSelector(getEnableHotKeysDisplay) const leftString = t('onDeck') @@ -54,6 +59,7 @@ export function ProtocolSteps(): JSX.Element { justifyContent={JUSTIFY_CENTER} > + {isMultiSelectMode ? : null} {formData == null || formType === 'moveLabware' ? ( Date: Tue, 15 Oct 2024 16:26:04 -0400 Subject: [PATCH 062/101] fix(app): ER skipping to the next step during a gripper error fails (#16491) --- .../hooks/useRecoveryCommands.ts | 33 +++++++++++++++++++ .../shared/SkipStepInfo.tsx | 9 ++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index fd78a62bcf6..777294da62d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -19,6 +19,7 @@ import type { DispenseInPlaceRunTimeCommand, DropTipInPlaceRunTimeCommand, PrepareToAspirateRunTimeCommand, + MoveLabwareParams, } from '@opentrons/shared-data' import type { CommandData, @@ -61,6 +62,8 @@ export interface UseRecoveryCommandsResult { releaseGripperJaws: () => Promise /* A non-terminal recovery command */ homeGripperZAxis: () => Promise + /* A non-terminal recovery command */ + moveLabwareWithoutPause: () => Promise } // TODO(jh, 07-24-24): Create tighter abstractions for terminal vs. non-terminal commands. @@ -225,6 +228,17 @@ export function useRecoveryCommands({ return chainRunRecoveryCommands([HOME_GRIPPER_Z_AXIS]) }, [chainRunRecoveryCommands]) + const moveLabwareWithoutPause = useCallback((): Promise => { + const moveLabwareCmd = buildMoveLabwareWithoutPause( + failedCommandByRunRecord + ) + if (moveLabwareCmd == null) { + return Promise.reject(new Error('Invalid use of MoveLabware command')) + } else { + return chainRunRecoveryCommands([moveLabwareCmd]) + } + }, [chainRunRecoveryCommands, failedCommandByRunRecord]) + return { resumeRun, cancelRun, @@ -233,6 +247,7 @@ export function useRecoveryCommands({ pickUpTips, releaseGripperJaws, homeGripperZAxis, + moveLabwareWithoutPause, skipFailedCommand, ignoreErrorKindThisRun, } @@ -256,6 +271,24 @@ export const HOME_GRIPPER_Z_AXIS: CreateCommand = { intent: 'fixit', } +const buildMoveLabwareWithoutPause = ( + failedCommand: FailedCommand | null +): CreateCommand | null => { + if (failedCommand == null) { + return null + } + const moveLabwareParams = failedCommand.params as MoveLabwareParams + return { + commandType: 'moveLabware', + params: { + labwareId: moveLabwareParams.labwareId, + newLocation: moveLabwareParams.newLocation, + strategy: 'manualMoveWithoutPause', + }, + intent: 'fixit', + } +} + export const buildPickUpTips = ( tipGroup: WellGroup | null, failedCommand: FailedCommand | null, diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx index e7c50396ff3..1caed4f583b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/SkipStepInfo.tsx @@ -20,13 +20,20 @@ export function SkipStepInfo(props: RecoveryContentProps): JSX.Element { } = RECOVERY_MAP const { selectedRecoveryOption } = currentRecoveryOptionUtils const { skipFailedCommand } = recoveryCommands + const { moveLabwareWithoutPause } = recoveryCommands const { handleMotionRouting } = routeUpdateActions const { ROBOT_SKIPPING_STEP } = RECOVERY_MAP const { t } = useTranslation('error_recovery') const primaryBtnOnClick = (): Promise => { return handleMotionRouting(true, ROBOT_SKIPPING_STEP.ROUTE).then(() => { - skipFailedCommand() + if (selectedRecoveryOption === MANUAL_MOVE_AND_SKIP.ROUTE) { + void moveLabwareWithoutPause().then(() => { + skipFailedCommand() + }) + } else { + skipFailedCommand() + } }) } From d9b0e237819b83bf2b6e3ae30908f9ec0aa83d4d Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Tue, 15 Oct 2024 17:31:22 -0400 Subject: [PATCH 063/101] feat(app): Plate reader in LPC and CommandText, filter out plate reader lid as labware (#16489) fix PLAT-483, PLAT-525, PLAT-526, PLAT-547, RQA-3310 --- .../en/protocol_command_text.json | 11 +++- .../hooks/useCommandTextString/index.tsx | 12 +++- .../utils/getAbsorbanceReaderCommandText.ts | 53 ++++++++++++++++++ .../hooks/useCommandTextString/utils/index.ts | 1 + .../Desktop/ProtocolDetails/index.tsx | 5 +- .../IntroScreen/getPrepCommands.ts | 35 +++++++++++- .../LabwarePositionCheck/useLaunchLPC.tsx | 14 ++--- .../utils/getProbeBasedLPCSteps.ts | 12 ++-- .../ModuleCard/AbsorbanceReaderData.tsx | 2 +- .../analysis/getProtocolModulesInfo.ts | 4 +- .../getLabwareSetupItemGroups.ts | 8 ++- .../src/hardware-sim/BaseDeck/BaseDeck.tsx | 5 +- .../AbsorbanceReaderFixture.tsx | 2 +- .../hardware-sim/DeckConfigurator/index.tsx | 3 +- .../ProtocolDeck/utils/getModulesInSlots.ts | 4 +- shared-data/command/types/module.ts | 56 ++++++++++++++++++- shared-data/js/constants.ts | 6 ++ shared-data/js/types.ts | 7 ++- 18 files changed, 206 insertions(+), 34 deletions(-) create mode 100644 app/src/molecules/Command/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index c78aef13785..5640f3306a5 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -1,4 +1,8 @@ { + "absorbance_reader_open_lid": "Opening Absorbance Reader lid", + "absorbance_reader_close_lid": "Closing Absorbance Reader lid", + "absorbance_reader_initialize": "Initializing Absorbance Reader to perform {{mode}} measurement at {{wavelengths}}", + "absorbance_reader_read": "Reading plate in Absorbance Reader", "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in {{slot}}", "adapter_in_slot": "{{adapter}} in {{slot}}", "aspirate": "Aspirating {{volume}} µL from well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", @@ -49,6 +53,7 @@ "move_to_coordinates": "Moving to (X: {{x}}, Y: {{y}}, Z: {{z}})", "move_to_slot": "Moving to Slot {{slot_name}}", "move_to_well": "Moving to well {{well_name}} of {{labware}} in {{labware_location}}", + "multiple": "multiple", "notes": "notes", "off_deck": "off deck", "offdeck": "offdeck", @@ -66,9 +71,10 @@ "setting_temperature_module_temp": "Setting Temperature Module to {{temp}} (rounded to nearest integer)", "setting_thermocycler_block_temp": "Setting Thermocycler block temperature to {{temp}} with hold time of {{hold_time_seconds}} seconds after target reached", "setting_thermocycler_lid_temp": "Setting Thermocycler lid temperature to {{temp}}", + "single": "single", + "slot": "Slot {{slot_name}}", "turning_rail_lights_off": "Turning rail lights off", "turning_rail_lights_on": "Turning rail lights on", - "slot": "Slot {{slot_name}}", "target_temperature": "target temperature", "tc_awaiting_for_duration": "Waiting for Thermocycler profile to complete", "tc_run_profile_steps": "Temperature: {{celsius}}°C, hold time: {{duration}}", @@ -84,5 +90,6 @@ "waiting_for_tc_block_to_reach": "Waiting for Thermocycler block to reach target temperature and holding for specified time", "waiting_for_tc_lid_to_reach": "Waiting for Thermocycler lid to reach target temperature", "waiting_to_reach_temp_module": "Waiting for Temperature Module to reach {{temp}}", - "waste_chute": "Waste Chute" + "waste_chute": "Waste Chute", + "with_reference_of": "with reference of {{wavelength}} nm" } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx index 3d7f50a477b..d203595e112 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/index.tsx +++ b/app/src/molecules/Command/hooks/useCommandTextString/index.tsx @@ -127,7 +127,17 @@ export function useCommandTextString( command, }), } - + case 'absorbanceReader/openLid': + case 'absorbanceReader/closeLid': + case 'absorbanceReader/initialize': + case 'absorbanceReader/read': + return { + kind: 'generic', + commandText: utils.getAbsorbanceReaderCommandText({ + ...fullParams, + command, + }), + } case 'thermocycler/runProfile': return utils.getTCRunProfileCommandText({ ...fullParams, command }) diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts new file mode 100644 index 00000000000..b9e7107b569 --- /dev/null +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getAbsorbanceReaderCommandText.ts @@ -0,0 +1,53 @@ +import type { + AbsorbanceReaderOpenLidRunTimeCommand, + AbsorbanceReaderCloseLidRunTimeCommand, + AbsorbanceReaderInitializeRunTimeCommand, + AbsorbanceReaderReadRunTimeCommand, + RunTimeCommand, +} from '@opentrons/shared-data' +import type { HandlesCommands } from './types' + +export type AbsorbanceCreateCommand = + | AbsorbanceReaderOpenLidRunTimeCommand + | AbsorbanceReaderCloseLidRunTimeCommand + | AbsorbanceReaderInitializeRunTimeCommand + | AbsorbanceReaderReadRunTimeCommand + +const KEYS_BY_COMMAND_TYPE: { + [commandType in AbsorbanceCreateCommand['commandType']]: string +} = { + 'absorbanceReader/openLid': 'absorbance_reader_open_lid', + 'absorbanceReader/closeLid': 'absorbance_reader_close_lid', + 'absorbanceReader/initialize': 'absorbance_reader_initialize', + 'absorbanceReader/read': 'absorbance_reader_read', +} + +type HandledCommands = Extract< + RunTimeCommand, + { commandType: keyof typeof KEYS_BY_COMMAND_TYPE } +> + +type GetAbsorbanceReaderCommandText = HandlesCommands + +export const getAbsorbanceReaderCommandText = ({ + command, + t, +}: GetAbsorbanceReaderCommandText): string => { + if (command.commandType === 'absorbanceReader/initialize') { + const wavelengths = command.params.sampleWavelengths.join(' nm, ') + ` nm` + const mode = + command.params.measureMode === 'multi' ? t('multiple') : t('single') + + return `${t('absorbance_reader_initialize', { + mode, + wavelengths, + })} ${ + command.params.referenceWavelength != null + ? t('with_reference_of', { + wavelength: command.params.referenceWavelength, + }) + : '' + }` + } + return t(KEYS_BY_COMMAND_TYPE[command.commandType]) +} diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts index 590824e558d..76659ca1222 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/index.ts @@ -23,3 +23,4 @@ export { getUnknownCommandText } from './getUnknownCommandText' export { getPipettingCommandText } from './getPipettingCommandText' export { getLiquidProbeCommandText } from './getLiquidProbeCommandText' export { getRailLightsCommandText } from './getRailLightsCommandText' +export { getAbsorbanceReaderCommandText } from './getAbsorbanceReaderCommandText' diff --git a/app/src/organisms/Desktop/ProtocolDetails/index.tsx b/app/src/organisms/Desktop/ProtocolDetails/index.tsx index 3bdf4be672e..a54115a00f9 100644 --- a/app/src/organisms/Desktop/ProtocolDetails/index.tsx +++ b/app/src/organisms/Desktop/ProtocolDetails/index.tsx @@ -47,6 +47,7 @@ import { parseInitialLoadedLabwareBySlot, parseInitialLoadedModulesBySlot, parseInitialPipetteNamesByMount, + NON_USER_ADDRESSABLE_LABWARE, } from '@opentrons/shared-data' import { getTopPortalEl } from '/app/App/portal' @@ -284,7 +285,9 @@ export function ProtocolDetails( : [] ), }).filter( - labware => labware.result?.definition?.parameters?.format !== 'trash' + labware => + labware.result?.definition?.parameters?.format !== 'trash' && + !NON_USER_ADDRESSABLE_LABWARE.includes(labware?.params?.loadName) ) : [] diff --git a/app/src/organisms/LabwarePositionCheck/IntroScreen/getPrepCommands.ts b/app/src/organisms/LabwarePositionCheck/IntroScreen/getPrepCommands.ts index f2ce105fd73..8c2831115c9 100644 --- a/app/src/organisms/LabwarePositionCheck/IntroScreen/getPrepCommands.ts +++ b/app/src/organisms/LabwarePositionCheck/IntroScreen/getPrepCommands.ts @@ -2,6 +2,8 @@ import { getModuleType, HEATERSHAKER_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, + ABSORBANCE_READER_TYPE, + NON_USER_ADDRESSABLE_LABWARE, } from '@opentrons/shared-data' import type { @@ -13,6 +15,7 @@ import type { RunTimeCommand, SetupRunTimeCommand, TCOpenLidCreateCommand, + AbsorbanceReaderOpenLidCreateCommand, } from '@opentrons/shared-data' type LPCPrepCommand = @@ -21,6 +24,7 @@ type LPCPrepCommand = | TCOpenLidCreateCommand | HeaterShakerDeactivateShakerCreateCommand | HeaterShakerCloseLatchCreateCommand + | AbsorbanceReaderOpenLidCreateCommand export function getPrepCommands( protocolData: CompletedProtocolAnalysis @@ -45,7 +49,8 @@ export function getPrepCommands( return [...acc, loadWithPipetteId] } else if ( command.commandType === 'loadLabware' && - command.result?.labwareId != null + command.result?.labwareId != null && + !NON_USER_ADDRESSABLE_LABWARE.includes(command.params.loadName) ) { // load all labware off-deck so that LPC can move them on individually later return [ @@ -97,6 +102,26 @@ export function getPrepCommands( [] ) + const AbsorbanceCommands = protocolData.modules.reduce( + (acc, module) => { + if (getModuleType(module.model) === ABSORBANCE_READER_TYPE) { + return [ + ...acc, + { + commandType: 'home', + params: {}, + }, + { + commandType: 'absorbanceReader/openLid', + params: { moduleId: module.id }, + }, + ] + } + return acc + }, + [] + ) + const HSCommands = protocolData.modules.reduce< HeaterShakerCloseLatchCreateCommand[] >((acc, module) => { @@ -116,7 +141,13 @@ export function getPrepCommands( params: {}, } // prepCommands will be run when a user starts LPC - return [...loadCommands, ...TCCommands, ...HSCommands, homeCommand] + return [ + ...loadCommands, + ...TCCommands, + ...AbsorbanceCommands, + ...HSCommands, + homeCommand, + ] } function isLoadCommand( diff --git a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx index 695d79a6733..fad314f7af3 100644 --- a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx +++ b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx @@ -15,11 +15,6 @@ import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/ import type { RobotType } from '@opentrons/shared-data' -const filteredLabware = [ - 'opentrons_tough_pcr_auto_sealing_lid', - 'opentrons_flex_lid_absorbance_plate_reader_module', -] - export function useLaunchLPC( runId: string, robotType: RobotType, @@ -67,11 +62,10 @@ export function useLaunchLPC( getLabwareDefinitionsFromCommands( mostRecentAnalysis?.commands ?? [] ).map(def => { - if (!filteredLabware.includes(def.parameters.loadName)) - createLabwareDefinition({ - maintenanceRunId: maintenanceRun?.data?.id, - labwareDef: def, - }) + createLabwareDefinition({ + maintenanceRunId: maintenanceRun?.data?.id, + labwareDef: def, + }) }) ).then(() => { setMaintenanceRunId(maintenanceRun.data.id) diff --git a/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts b/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts index 80e2650760c..0f03ad0e92b 100644 --- a/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts +++ b/app/src/organisms/LabwarePositionCheck/utils/getProbeBasedLPCSteps.ts @@ -58,7 +58,10 @@ function getAllCheckSectionSteps( const labwareDef = labwareDefinitions.find( def => getLabwareDefURI(def) === labwareLocationCombo.definitionUri ) - if ((labwareDef?.allowedRoles ?? []).includes('adapter')) { + if ( + (labwareDef?.allowedRoles ?? []).includes('adapter') || + (labwareDef?.allowedRoles ?? []).includes('lid') + ) { return acc } // remove duplicate definitionUri in same location @@ -73,12 +76,7 @@ function getAllCheckSectionSteps( [] ) - // HACK: Remove LPC for plate reader to unblock science. - const filteredLabwareLocations = labwareLocations.filter(labware => { - return labware.location?.moduleModel !== 'absorbanceReaderV1' - }) - - return filteredLabwareLocations.map( + return labwareLocations.map( ({ location, labwareId, moduleId, adapterId, definitionUri }) => ({ section: SECTIONS.CHECK_POSITIONS, labwareId: labwareId, diff --git a/app/src/organisms/ModuleCard/AbsorbanceReaderData.tsx b/app/src/organisms/ModuleCard/AbsorbanceReaderData.tsx index 1779be43a41..e6d23255176 100644 --- a/app/src/organisms/ModuleCard/AbsorbanceReaderData.tsx +++ b/app/src/organisms/ModuleCard/AbsorbanceReaderData.tsx @@ -46,7 +46,7 @@ export const AbsorbanceReaderData = ( data-testid="abs_module_data" > {t('abs_reader_lid_status', { - status: moduleData.lidStatus === 'on' ? 'open' : 'closed', + status: moduleData.lidStatus === 'on' ? 'closed' : 'open', })} diff --git a/app/src/transformations/analysis/getProtocolModulesInfo.ts b/app/src/transformations/analysis/getProtocolModulesInfo.ts index 8a268c2694b..ee1da1a2392 100644 --- a/app/src/transformations/analysis/getProtocolModulesInfo.ts +++ b/app/src/transformations/analysis/getProtocolModulesInfo.ts @@ -3,6 +3,7 @@ import { getModuleDef2, getLoadedLabwareDefinitionsByUri, getPositionFromSlotId, + NON_USER_ADDRESSABLE_LABWARE, } from '@opentrons/shared-data' import { getModuleInitialLoadInfo } from '../commands' import type { @@ -38,7 +39,8 @@ export const getProtocolModulesInfo = ( protocolData.commands .filter( (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' + command.commandType === 'loadLabware' && + !NON_USER_ADDRESSABLE_LABWARE.includes(command.params.loadName) ) .find( (command: LoadLabwareRunTimeCommand) => diff --git a/app/src/transformations/commands/transformations/getLabwareSetupItemGroups.ts b/app/src/transformations/commands/transformations/getLabwareSetupItemGroups.ts index ebc31e37ccc..30c79281649 100644 --- a/app/src/transformations/commands/transformations/getLabwareSetupItemGroups.ts +++ b/app/src/transformations/commands/transformations/getLabwareSetupItemGroups.ts @@ -1,5 +1,8 @@ import partition from 'lodash/partition' -import { getLabwareDisplayName } from '@opentrons/shared-data' +import { + getLabwareDisplayName, + NON_USER_ADDRESSABLE_LABWARE, +} from '@opentrons/shared-data' import type { LabwareDefinition2, @@ -43,7 +46,8 @@ export function getLabwareSetupItemGroups( commands.reduce((acc, c) => { if ( c.commandType === 'loadLabware' && - c.result?.definition?.metadata?.displayCategory !== 'trash' + c.result?.definition?.metadata?.displayCategory !== 'trash' && + !NON_USER_ADDRESSABLE_LABWARE.includes(c.params?.loadName) ) { const { location, displayName } = c.params const { definition } = c.result ?? {} diff --git a/components/src/hardware-sim/BaseDeck/BaseDeck.tsx b/components/src/hardware-sim/BaseDeck/BaseDeck.tsx index 9158cfe360b..fcf71c57ea1 100644 --- a/components/src/hardware-sim/BaseDeck/BaseDeck.tsx +++ b/components/src/hardware-sim/BaseDeck/BaseDeck.tsx @@ -168,7 +168,10 @@ export function BaseDeck(props: BaseDeckProps): JSX.Element { color={COLORS.black90} show4thColumn={ stagingAreaFixtures.length > 0 || - wasteChuteStagingAreaFixtures.length > 0 + wasteChuteStagingAreaFixtures.length > 0 || + modulesOnDeck.findIndex( + module => module.moduleModel === 'absorbanceReaderV1' + ) >= 0 } /> ) : null} diff --git a/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx b/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx index a514eacfd70..e65b1c339e8 100644 --- a/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx +++ b/components/src/hardware-sim/DeckConfigurator/AbsorbanceReaderFixture.tsx @@ -30,7 +30,7 @@ interface AbsorbanceReaderFixtureProps { selected?: boolean } -const ABSORBANCE_READER_FIXTURE_DISPLAY_NAME = 'Absorbance Reader' +const ABSORBANCE_READER_FIXTURE_DISPLAY_NAME = 'Absorbance' export function AbsorbanceReaderFixture( props: AbsorbanceReaderFixtureProps diff --git a/components/src/hardware-sim/DeckConfigurator/index.tsx b/components/src/hardware-sim/DeckConfigurator/index.tsx index 374131e2232..4a03ff6c866 100644 --- a/components/src/hardware-sim/DeckConfigurator/index.tsx +++ b/components/src/hardware-sim/DeckConfigurator/index.tsx @@ -275,7 +275,8 @@ export function DeckConfigurator(props: DeckConfiguratorProps): JSX.Element { show4thColumn={ stagingAreaFixtures.length > 0 || wasteChuteStagingAreaFixtures.length > 0 || - magneticBlockStagingAreaFixtures.length > 0 + magneticBlockStagingAreaFixtures.length > 0 || + absorbanceReaderFixtures.length > 0 } /> {children} diff --git a/components/src/hardware-sim/ProtocolDeck/utils/getModulesInSlots.ts b/components/src/hardware-sim/ProtocolDeck/utils/getModulesInSlots.ts index 0d9da4b48bd..612759b3d01 100644 --- a/components/src/hardware-sim/ProtocolDeck/utils/getModulesInSlots.ts +++ b/components/src/hardware-sim/ProtocolDeck/utils/getModulesInSlots.ts @@ -2,6 +2,7 @@ import { SPAN7_8_10_11_SLOT, getModuleDef2, getLoadedLabwareDefinitionsByUri, + NON_USER_ADDRESSABLE_LABWARE, } from '@opentrons/shared-data' import type { CompletedProtocolAnalysis, @@ -36,7 +37,8 @@ export const getModulesInSlots = ( commands .filter( (command): command is LoadLabwareRunTimeCommand => - command.commandType === 'loadLabware' + command.commandType === 'loadLabware' && + !NON_USER_ADDRESSABLE_LABWARE.includes(command.params.loadName) ) .find( (command: LoadLabwareRunTimeCommand) => diff --git a/shared-data/command/types/module.ts b/shared-data/command/types/module.ts index b0df1ae6b6b..7f48bcd16b0 100644 --- a/shared-data/command/types/module.ts +++ b/shared-data/command/types/module.ts @@ -24,6 +24,10 @@ export type ModuleRunTimeCommand = | HeaterShakerCloseLatchRunTimeCommand | HeaterShakerDeactivateHeaterRunTimeCommand | HeaterShakerDeactivateShakerRunTimeCommand + | AbsorbanceReaderOpenLidRunTimeCommand + | AbsorbanceReaderCloseLidRunTimeCommand + | AbsorbanceReaderInitializeRunTimeCommand + | AbsorbanceReaderReadRunTimeCommand export type ModuleCreateCommand = | MagneticModuleEngageMagnetCreateCommand @@ -49,6 +53,10 @@ export type ModuleCreateCommand = | HeaterShakerDeactivateHeaterCreateCommand | HeaterShakerDeactivateShakerCreateCommand | HeaterShakerSetTargetTemperatureCreateCommand + | AbsorbanceReaderOpenLidCreateCommand + | AbsorbanceReaderCloseLidCreateCommand + | AbsorbanceReaderInitializeCreateCommand + | AbsorbanceReaderReadCreateCommand export interface MagneticModuleEngageMagnetCreateCommand extends CommonCommandCreateInfo { @@ -281,12 +289,56 @@ export interface HeaterShakerDeactivateShakerRunTimeCommand HeaterShakerDeactivateShakerCreateCommand { result?: any } - +export interface AbsorbanceReaderOpenLidRunTimeCommand + extends CommonCommandRunTimeInfo, + AbsorbanceReaderOpenLidCreateCommand { + result?: any +} +export interface AbsorbanceReaderCloseLidRunTimeCommand + extends CommonCommandRunTimeInfo, + AbsorbanceReaderCloseLidCreateCommand { + result?: any +} +export interface AbsorbanceReaderInitializeRunTimeCommand + extends CommonCommandRunTimeInfo, + AbsorbanceReaderInitializeCreateCommand { + result?: any +} +export interface AbsorbanceReaderReadRunTimeCommand + extends CommonCommandRunTimeInfo, + AbsorbanceReaderReadCreateCommand { + result?: any +} +export interface AbsorbanceReaderOpenLidCreateCommand + extends CommonCommandCreateInfo { + commandType: 'absorbanceReader/openLid' + params: ModuleOnlyParams +} +export interface AbsorbanceReaderCloseLidCreateCommand + extends CommonCommandCreateInfo { + commandType: 'absorbanceReader/closeLid' + params: ModuleOnlyParams +} +export interface AbsorbanceReaderInitializeCreateCommand + extends CommonCommandCreateInfo { + commandType: 'absorbanceReader/initialize' + params: AbsorbanceReaderInitializeParams +} +export interface AbsorbanceReaderReadCreateCommand + extends CommonCommandCreateInfo { + commandType: 'absorbanceReader/read' + params: ModuleOnlyParams +} export interface EngageMagnetParams { moduleId: string height: number } - +export interface AbsorbanceReaderInitializeParams { + moduleId: string + measureMode: 'single' | 'multi' + sampleWavelengths: number[] + referenceWavelength?: number +} export interface TemperatureParams { moduleId: string celsius: number diff --git a/shared-data/js/constants.ts b/shared-data/js/constants.ts index 8ae6d2d6ff8..f5f764d480d 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -589,6 +589,12 @@ export const WASTE_CHUTE_STAGING_AREA_FIXTURES: CutoutFixtureId[] = [ export const LOW_VOLUME_PIPETTES = ['p50_single_flex', 'p50_multi_flex'] +// robot server loads absorbance reader lid as a labware but it is not +// user addressable so we need to hide it where we show labware in the app +export const NON_USER_ADDRESSABLE_LABWARE = [ + 'opentrons_flex_lid_absorbance_plate_reader_module', +] + // default hex values for liquid colors const electricPurple = '#b925ff' const goldenYellow = '#ffd600' diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index d903403dfb8..37d3794c9dc 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -231,7 +231,12 @@ export interface LabwareWellGroup { brand?: LabwareBrand } -export type LabwareRoles = 'labware' | 'adapter' | 'fixture' | 'maintenance' +export type LabwareRoles = + | 'labware' + | 'adapter' + | 'fixture' + | 'maintenance' + | 'lid' // NOTE: must be synced with shared-data/labware/schemas/2.json export interface LabwareDefinition2 { From 953112e52a2db5437b35184fcc86eff73fb60b26 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 16 Oct 2024 09:15:25 -0400 Subject: [PATCH 064/101] fix(app): Fix intermittent drop tip failing (#16490) Closes RQA-3287 After the semi-recent drop tip wizard cleanup, in which the fixit DT flows were more decoupled from the setup DT flow logic, the wizard eagerly closes before ensuring the maintenance run was actually deleted. This can put drop tip wizard in a weird state if launched too quickly after exiting, as seen in the ticket with the 500 POST request to the maintenance_runs endpoint. To fix this, let's just ensure we don't close the wizard until the request settles. This commit also fixes a weird bug. If you access drop tip wizard from the Device Details page and a pipette unrenders during drop tip wizard, it sometimes toggles a lot because we pass a generic drop tip toggle, and several different things within DT wiz call this toggle. By specifying enableDTWiz and disableDTWiz, this problem no longer occurs. --- .../Devices/PipetteCard/FlexPipetteCard.tsx | 6 +++--- .../__tests__/FlexPipetteCard.test.tsx | 4 ++-- .../PipetteCard/__tests__/PipetteCard.test.tsx | 3 ++- .../Desktop/Devices/PipetteCard/index.tsx | 6 +++--- .../hooks/useRunHeaderDropTip.ts | 10 +++++----- .../modals/ProtocolDropTipModal.tsx | 6 +++--- .../__tests__/ProtocolDropTipModal.test.tsx | 4 ++-- .../DropTipWizardFlows/DropTipWizardFlows.tsx | 15 ++++++++++----- .../DropTipWizardFlows/TipsAttachedModal.tsx | 6 +++--- .../__tests__/DropTipWizardFlows.test.ts | 8 +++++++- .../__tests__/TipsAttachedModal.test.tsx | 3 ++- .../hooks/useDropTipCommands.ts | 7 +++++-- .../hooks/useDropTipMaintenanceRun.tsx | 14 +++++--------- .../InstrumentDetailOverflowMenu.tsx | 8 ++++---- .../__tests__/InstrumentDetail.test.tsx | 3 ++- app/src/pages/ODD/InstrumentDetail/index.tsx | 6 +++--- 16 files changed, 61 insertions(+), 48 deletions(-) diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx index ca7b7a27340..3a1354f7680 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/FlexPipetteCard.tsx @@ -94,7 +94,7 @@ export function FlexPipetteCard({ setSelectedPipette(SINGLE_MOUNT_PIPETTES) } - const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() + const { showDTWiz, enableDTWiz, disableDTWiz } = useDropTipWizardFlows() const handleLaunchPipetteWizardFlows = ( flowType: PipetteWizardFlow @@ -186,7 +186,7 @@ export function FlexPipetteCard({ label: i18n.format(t('drop_tips'), 'capitalize'), disabled: attachedPipette == null || isRunActive, onClick: () => { - toggleDTWiz() + enableDTWiz() }, }, ] @@ -270,7 +270,7 @@ export function FlexPipetteCard({ robotType={FLEX_ROBOT_TYPE} mount={mount} instrumentModelSpecs={pipetteModelSpecs} - closeFlow={toggleDTWiz} + closeFlow={disableDTWiz} modalStyle="simple" /> ) : null} diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx index 1dd13e8f350..bd753e9f9d3 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/FlexPipetteCard.test.tsx @@ -62,9 +62,9 @@ describe('FlexPipetteCard', () => { data: undefined, } as any) vi.mocked(useDropTipWizardFlows).mockReturnValue({ - toggleDTWiz: mockDTWizToggle, + enableDTWiz: mockDTWizToggle, showDTWiz: false, - }) + } as any) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx index 80567d3f1b9..e04796bd491 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/__tests__/PipetteCard.test.tsx @@ -55,7 +55,8 @@ describe('PipetteCard', () => { ]) vi.mocked(useDropTipWizardFlows).mockReturnValue({ showDTWiz: false, - toggleDTWiz: vi.fn(), + enableDTWiz: vi.fn(), + disableDTWiz: vi.fn(), }) when(usePipetteSettingsQuery) .calledWith({ refetchInterval: 5000, enabled: true }) diff --git a/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx b/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx index 08488e98987..f391cff8b3f 100644 --- a/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx +++ b/app/src/organisms/Desktop/Devices/PipetteCard/index.tsx @@ -72,7 +72,7 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { const [showSlideout, setShowSlideout] = useState(false) const [showAboutSlideout, setShowAboutSlideout] = useState(false) - const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() + const { showDTWiz, disableDTWiz, enableDTWiz } = useDropTipWizardFlows() const settings = usePipetteSettingsQuery({ @@ -110,7 +110,7 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { robotType={OT2_ROBOT_TYPE} mount={mount} instrumentModelSpecs={pipetteModelSpecs} - closeFlow={toggleDTWiz} + closeFlow={disableDTWiz} modalStyle="simple" /> ) : null} @@ -207,7 +207,7 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { pipetteSpecs={pipetteModelSpecs} mount={mount} handleChangePipette={handleChangePipette} - handleDropTip={toggleDTWiz} + handleDropTip={enableDTWiz} handleSettingsSlideout={handleSettingsSlideout} handleAboutSlideout={handleAboutSlideout} pipetteSettings={settings} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts index d30dc6af145..e41b7edc8ec 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts @@ -49,7 +49,7 @@ export function useRunHeaderDropTip({ const enteredER = runRecord?.data.hasEverEnteredErrorRecovery ?? false const { closeCurrentRun } = useCloseCurrentRun() - const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() + const { showDTWiz, disableDTWiz, enableDTWiz } = useDropTipWizardFlows() const { areTipsAttached, @@ -66,7 +66,7 @@ export function useRunHeaderDropTip({ const dropTipModalUtils = useProtocolDropTipModal({ areTipsAttached, - toggleDTWiz, + enableDTWiz, isRunCurrent, currentRunId: runId, pipetteInfo: buildPipetteDetails(aPipetteWithTip), @@ -78,12 +78,12 @@ export function useRunHeaderDropTip({ // The onCloseFlow for Drop Tip Wizard const onCloseFlow = (isTakeover?: boolean): void => { if (isTakeover) { - toggleDTWiz() + disableDTWiz() } else { void setTipStatusResolved(() => { - toggleDTWiz() + disableDTWiz() closeCurrentRun() - }, toggleDTWiz) + }, disableDTWiz) } } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx index 7c4f9c3820e..7d96803c4a6 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx @@ -30,7 +30,7 @@ type UseProtocolDropTipModalProps = Pick< 'pipetteInfo' > & { areTipsAttached: TipAttachmentStatusResult['areTipsAttached'] - toggleDTWiz: () => void + enableDTWiz: () => void currentRunId: string onSkipAndHome: () => void /* True if the most recent run is the current run */ @@ -47,7 +47,7 @@ export type UseProtocolDropTipModalResult = // Wraps functionality required for rendering the related modal. export function useProtocolDropTipModal({ areTipsAttached, - toggleDTWiz, + enableDTWiz, isRunCurrent, onSkipAndHome, pipetteInfo, @@ -75,7 +75,7 @@ export function useProtocolDropTipModal({ } const onBeginRemoval = (): void => { - toggleDTWiz() + enableDTWiz() setShowModal(false) } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx index 8fb21ec5027..56a508b9666 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/__tests__/ProtocolDropTipModal.test.tsx @@ -21,7 +21,7 @@ describe('useProtocolDropTipModal', () => { beforeEach(() => { props = { areTipsAttached: true, - toggleDTWiz: vi.fn(), + enableDTWiz: vi.fn(), isRunCurrent: true, onSkipAndHome: vi.fn(), currentRunId: 'MOCK_ID', @@ -89,7 +89,7 @@ describe('useProtocolDropTipModal', () => { result.current.modalProps?.onBeginRemoval() }) - expect(props.toggleDTWiz).toHaveBeenCalled() + expect(props.enableDTWiz).toHaveBeenCalled() }) it('should set isDisabled to true when isHomingPipettes is true', () => { diff --git a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx index 5bfb143b9fb..5684b123afb 100644 --- a/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx +++ b/app/src/organisms/DropTipWizardFlows/DropTipWizardFlows.tsx @@ -22,15 +22,20 @@ import type { */ export function useDropTipWizardFlows(): { showDTWiz: boolean - toggleDTWiz: () => void + enableDTWiz: () => void + disableDTWiz: () => void } { const [showDTWiz, setShowDTWiz] = useState(false) - const toggleDTWiz = (): void => { - setShowDTWiz(!showDTWiz) + return { + showDTWiz, + enableDTWiz: () => { + setShowDTWiz(true) + }, + disableDTWiz: () => { + setShowDTWiz(false) + }, } - - return { showDTWiz, toggleDTWiz } } export interface DropTipWizardFlowsProps { diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index 5117c520384..1330844e116 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -47,7 +47,7 @@ const TipsAttachedModal = NiceModal.create( const modal = useModal() const { mount, specs } = aPipetteWithTip - const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() + const { showDTWiz, disableDTWiz, enableDTWiz } = useDropTipWizardFlows() const { homePipettes, isHoming } = useHomePipettes({ ...homePipetteProps, pipetteInfo: buildPipetteDetails(aPipetteWithTip), @@ -68,7 +68,7 @@ const TipsAttachedModal = NiceModal.create( } const cleanUpAndClose = (isTakeover?: boolean): void => { - toggleDTWiz() + disableDTWiz() if (!isTakeover) { modal.remove() @@ -106,7 +106,7 @@ const TipsAttachedModal = NiceModal.create( diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.ts b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.ts index bf85054259a..f401fc73677 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.ts +++ b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.ts @@ -13,9 +13,15 @@ describe('useDropTipWizardFlows', () => { expect(result.current.showDTWiz).toBe(false) act(() => { - result.current.toggleDTWiz() + result.current.enableDTWiz() }) expect(result.current.showDTWiz).toBe(true) + + act(() => { + result.current.disableDTWiz() + }) + + expect(result.current.showDTWiz).toBe(false) }) }) diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx index 2eeaf07f70e..917c770c10e 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx @@ -74,7 +74,8 @@ describe('TipsAttachedModal', () => { } as any) vi.mocked(useDropTipWizardFlows).mockReturnValue({ showDTWiz: false, - toggleDTWiz: mockToggleDTWiz, + enableDTWiz: mockToggleDTWiz, + disableDTWiz: vi.fn(), }) }) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts index fc040be2b52..63d3f264ae1 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts @@ -88,8 +88,11 @@ export function useDropTipCommands({ console.error(error.message) }) .finally(() => { - closeFlow() - deleteMaintenanceRun(activeMaintenanceRunId) + deleteMaintenanceRun(activeMaintenanceRunId, { + onSettled: () => { + closeFlow() + }, + }) }) } } diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipMaintenanceRun.tsx b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipMaintenanceRun.tsx index fcb69ff467d..2b2034a0724 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipMaintenanceRun.tsx +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipMaintenanceRun.tsx @@ -17,7 +17,7 @@ export type UseDropTipMaintenanceRunParams = Omit< UseDTWithTypeParams, 'instrumentModelSpecs' | 'mount' > & { - setErrorDetails?: (errorDetails: SetRobotErrorDetailsParams) => void + setErrorDetails: (errorDetails: SetRobotErrorDetailsParams) => void instrumentModelSpecs?: PipetteModelSpecs mount?: PipetteData['mount'] } @@ -95,9 +95,7 @@ function useCreateDropTipMaintenanceRun({ .catch((error: Error) => error) }, onError: (error: Error) => { - if (setErrorDetails != null) { - setErrorDetails({ message: error.message }) - } + setErrorDetails({ message: error.message }) }, }) @@ -108,11 +106,9 @@ function useCreateDropTipMaintenanceRun({ instrumentModelName != null ) { createTargetedMaintenanceRun({}).catch((e: Error) => { - if (setErrorDetails != null) { - setErrorDetails({ - message: `Error creating maintenance run: ${e.message}`, - }) - } + setErrorDetails({ + message: `Error creating maintenance run: ${e.message}`, + }) }) } else { console.warn( diff --git a/app/src/pages/ODD/InstrumentDetail/InstrumentDetailOverflowMenu.tsx b/app/src/pages/ODD/InstrumentDetail/InstrumentDetailOverflowMenu.tsx index 3b589295d0b..96ea37d14d3 100644 --- a/app/src/pages/ODD/InstrumentDetail/InstrumentDetailOverflowMenu.tsx +++ b/app/src/pages/ODD/InstrumentDetail/InstrumentDetailOverflowMenu.tsx @@ -35,7 +35,7 @@ import type { interface InstrumentDetailsOverflowMenuProps { instrument: PipetteData | GripperData host: HostConfig | null - toggleDTWiz: () => void + enableDTWiz: () => void } export const handleInstrumentDetailOverflowMenu = ( @@ -46,13 +46,13 @@ export const handleInstrumentDetailOverflowMenu = ( NiceModal.show(InstrumentDetailsOverflowMenu, { instrument, host, - toggleDTWiz, + enableDTWiz: toggleDTWiz, }) } const InstrumentDetailsOverflowMenu = NiceModal.create( (props: InstrumentDetailsOverflowMenuProps): JSX.Element => { - const { instrument, host, toggleDTWiz } = props + const { instrument, host, enableDTWiz } = props const { t } = useTranslation('robot_controls') const modal = useModal() const [wizardProps, setWizardProps] = React.useState< @@ -98,7 +98,7 @@ const InstrumentDetailsOverflowMenu = NiceModal.create( } const handleDropTip = (): void => { - toggleDTWiz() + enableDTWiz() modal.remove() } diff --git a/app/src/pages/ODD/InstrumentDetail/__tests__/InstrumentDetail.test.tsx b/app/src/pages/ODD/InstrumentDetail/__tests__/InstrumentDetail.test.tsx index f9efce16e0d..b16e9539b11 100644 --- a/app/src/pages/ODD/InstrumentDetail/__tests__/InstrumentDetail.test.tsx +++ b/app/src/pages/ODD/InstrumentDetail/__tests__/InstrumentDetail.test.tsx @@ -111,8 +111,9 @@ describe('InstrumentDetail', () => { vi.mocked(useParams).mockReturnValue({ mount: 'left' }) vi.mocked(useIsOEMMode).mockReturnValue(false) vi.mocked(useDropTipWizardFlows).mockReturnValue({ - toggleDTWiz: () => null, + enableDTWiz: vi.fn(), showDTWiz: false, + disableDTWiz: vi.fn(), }) vi.mocked(DropTipWizardFlows).mockReturnValue(
    MOCK_DROP_TIP_WIZ
    ) }) diff --git a/app/src/pages/ODD/InstrumentDetail/index.tsx b/app/src/pages/ODD/InstrumentDetail/index.tsx index 88c63e2faa6..8127d7b6cf1 100644 --- a/app/src/pages/ODD/InstrumentDetail/index.tsx +++ b/app/src/pages/ODD/InstrumentDetail/index.tsx @@ -48,7 +48,7 @@ export const InstrumentDetail = (): JSX.Element => { const gripperDisplayName = useGripperDisplayName( instrument?.instrumentModel as GripperModel ) - const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() + const { showDTWiz, disableDTWiz, enableDTWiz } = useDropTipWizardFlows() const pipetteModelSpecs = instrument != null ? getPipetteModelSpecs((instrument as PipetteData).instrumentModel) ?? @@ -69,7 +69,7 @@ export const InstrumentDetail = (): JSX.Element => { robotType={FLEX_ROBOT_TYPE} mount={instrument.mount} instrumentModelSpecs={pipetteModelSpecs} - closeFlow={toggleDTWiz} + closeFlow={disableDTWiz} modalStyle="simple" />, getTopPortalEl() @@ -93,7 +93,7 @@ export const InstrumentDetail = (): JSX.Element => { handleInstrumentDetailOverflowMenu( instrument, host, - toggleDTWiz + enableDTWiz ) }} > From 46336c1b095fb44fbd82a767c02e0c15189bb859 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 16 Oct 2024 09:15:47 -0400 Subject: [PATCH 065/101] refactor(app): do not show fallback tooltip on run action button (#16492) Closes RQA-3311 --- .../ActionButton/hooks/useActionBtnDisabledUtils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts index df418deda15..1c46c10372e 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderContent/ActionButton/hooks/useActionBtnDisabledUtils.ts @@ -23,7 +23,7 @@ interface UseActionButtonDisabledUtilsProps extends BaseActionButtonProps { } type UseActionButtonDisabledUtilsResult = - | { isDisabled: true; disabledReason: string } + | { isDisabled: true; disabledReason: string | null } | { isDisabled: false; disabledReason: null } // Manages the various reasons the ActionButton may be disabled, returning the disabled state and user-facing disabled @@ -45,7 +45,6 @@ export function useActionBtnDisabledUtils( isClosingCurrentRun, } = props - const { t } = useTranslation('shared') const { isPlayRunActionLoading, isPauseRunActionLoading, @@ -77,7 +76,7 @@ export function useActionBtnDisabledUtils( }) return isDisabled - ? { isDisabled: true, disabledReason: disabledReason ?? t('robot_is_busy') } + ? { isDisabled: true, disabledReason } : { isDisabled: false, disabledReason: null } } From ee1aacada7519823768addd609a5e34f15c84896 Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 16 Oct 2024 10:02:54 -0400 Subject: [PATCH 066/101] feat(protocol-designer): create StepSummary component (#16484) This PR creates and implements the new `StepSummary` component to be shown under the deck map in `ProtocolSteps`. The component produces different messages based on all step types and formats them with inline tags and various StyledText styles. --- components/src/organisms/Toolbox/index.tsx | 29 +- .../localization/en/protocol_steps.json | 57 ++- .../Designer/DeckSetup/DeckSetupContainer.tsx | 314 ++++++++-------- .../pages/Designer/Offdeck/OffDeckDetails.tsx | 13 +- .../src/pages/Designer/Offdeck/Offdeck.tsx | 6 +- .../ProtocolSteps/BatchEditToolbox/index.tsx | 9 +- .../StepForm/StepFormToolbox.tsx | 2 + .../Designer/ProtocolSteps/StepSummary.tsx | 336 ++++++++++++++++++ .../Timeline/SubstepsToolbox.tsx | 1 - .../Timeline/TimelineToolbox.tsx | 6 +- .../__tests__/ProtocolSteps.test.tsx | 1 + .../pages/Designer/ProtocolSteps/index.tsx | 92 +++-- .../pages/ProtocolOverview/DeckThumbnail.tsx | 2 - .../src/pages/ProtocolOverview/index.tsx | 12 +- 14 files changed, 664 insertions(+), 216 deletions(-) create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx diff --git a/components/src/organisms/Toolbox/index.tsx b/components/src/organisms/Toolbox/index.tsx index 566bcf1e4bf..4346806b861 100644 --- a/components/src/organisms/Toolbox/index.tsx +++ b/components/src/organisms/Toolbox/index.tsx @@ -28,6 +28,7 @@ export interface ToolboxProps { childrenPadding?: string subHeader?: JSX.Element | null secondaryHeaderButton?: JSX.Element + position?: string } export function Toolbox(props: ToolboxProps): JSX.Element { @@ -41,12 +42,13 @@ export function Toolbox(props: ToolboxProps): JSX.Element { height = '100%', disableCloseButton = false, width = '19.5rem', - confirmButton, side = 'right', horizontalSide = 'bottom', + confirmButton, childrenPadding = SPACING.spacing16, subHeader, secondaryHeaderButton, + position = POSITION_FIXED, } = props const slideOutRef = React.useRef(null) @@ -67,23 +69,26 @@ export function Toolbox(props: ToolboxProps): JSX.Element { handleScroll() }, [slideOutRef]) - const positionStyles = { - ...(side === 'right' && { right: '0' }), - ...(side === 'left' && { left: '0' }), - ...(horizontalSide === 'bottom' && { bottom: '0' }), - ...(horizontalSide === 'top' && { top: '5rem' }), - } - + const positionStyles = + position === POSITION_FIXED + ? { + ...(side === 'right' && { right: '0' }), + ...(side === 'left' && { left: '0' }), + ...(horizontalSide === 'bottom' && { bottom: '0' }), + ...(horizontalSide === 'top' && { top: '5rem' }), + } + : {} return ( - ) : null} - + ) } diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 24e42424355..513eeacaa05 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -15,21 +15,53 @@ "engage_height": "Engage height", "final_deck_state": "Final deck state", "from": "from", + "heater_shaker": { + "active": { + "latch": "Latch", + "shake": "Shaking at", + "temperature": "{{module}}set to", + "time": "Deactivating after" + }, + "deactivated": "{{module}}deactivated", + "latch": { + "closed": "closed", + "open": "open" + } + }, "heater_shaker_settings": "Heater-shaker settings", "in": "in", "into": "into", + "magnetic_module": { + "disengage": "{{module}}disengaged", + "engage": "{{module}}engaged to" + }, "mix": "Mix", + "mix_step": "Mixing{{times}} times in{{labware}}", "module": "Module", + "move_labware": { + "gripper": "Move{{labware}}tousing gripper", + "no_gripper": "Move{{labware}}to" + }, + "move_liquid": { + "consolidate": "Consolidatingfrom{{source}}to{{destination}}", + "distribute": "Distributingfrom{{source}}to{{destination}}", + "transfer": "Transferringfrom{{source}}to{{destination}}" + }, "multiAspirate": "Consolidate path", "multiDispense": "Distribute path", "new_location": "New location", + "pause": { + "untilResume": "Pausing until manually told to resume", + "untilTemperature": "Pausing until{{module}}reaches", + "untilTime": "Pausing for" + }, "protocol_steps": "Protocol steps", "protocol_timeline": "Protocol timeline", "rename": "Rename", "save_errors": "{{stepType}} has been saved with {{numErrors}} error(s)", "save_no_errors": "{{stepType}} has been saved", - "save_warnings_and_errors": "{{stepType}} has been saved with {{numErrors}} error(s) and {{numWarnings}} warning(s)", "save_warnings": "{{stepType}} has been saved with {{numWarnings}} warning(s)", + "save_warnings_and_errors": "{{stepType}} has been saved with {{numErrors}} error(s) and {{numWarnings}} warning(s)", "select_aspirate_labware": "Select a source labware", "select_aspirate_wells": "Select source wells using a {{displayName}}", "select_dispense_labware": "Select a destination labware", @@ -47,6 +79,29 @@ "starting_deck_state": "Starting deck state", "step_substeps": "{{stepType}} details", "temperature": "Temperature", + "temperature_module": { + "active": "{{module}}set to", + "deactivated": "{{module}}deactivated" + }, + "thermocycler_module": { + "lid_position": { + "closed": "closed", + "open": "open" + }, + "thermocycler_profile": { + "end_hold": { + "block": "End at thermocycler block", + "lid_position": "Thermocycler lid" + }, + "lid_temperature": "and lid temperature at", + "volume": "Run thermocycler profile with" + }, + "thermocycler_state": { + "block": "Set thermocycler block to", + "lid_position": "Lid position", + "lid_temperature": "Set thermocycler lid to" + } + }, "time": "Time", "view_details": "View details", "well_name": "Well {{wellName}}" diff --git a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx index 5308167e7dc..a8d0915a59d 100644 --- a/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx +++ b/protocol-designer/src/pages/Designer/DeckSetup/DeckSetupContainer.tsx @@ -171,180 +171,182 @@ export function DeckSetupContainer(props: DeckSetupTabType): JSX.Element { ) return ( - <> + {tab === 'protocolSteps' ? ( ) : null} - + - - {() => ( - <> - {robotType === OT2_ROBOT_TYPE ? ( - - ) : ( - <> - {filteredAddressableAreas.map(addressableArea => { - const cutoutId = getCutoutIdForAddressableArea( - addressableArea.id, - deckDef.cutoutFixtures - ) - return cutoutId != null ? ( - - ) : null - })} - {stagingAreaFixtures.map(fixture => { - if ( - zoomIn.cutout == null || - zoomIn.cutout !== fixture.location - ) { - return ( - - ) - } - })} - {trash != null - ? trashBinFixtures.map(({ cutoutId }) => - cutoutId != null && - (zoomIn.cutout == null || - zoomIn.cutout !== cutoutId) ? ( - - - - - ) : null + + {() => ( + <> + {robotType === OT2_ROBOT_TYPE ? ( + + ) : ( + <> + {filteredAddressableAreas.map(addressableArea => { + const cutoutId = getCutoutIdForAddressableArea( + addressableArea.id, + deckDef.cutoutFixtures ) - : null} - {wasteChuteFixtures.map(fixture => { - if ( - zoomIn.cutout == null || - zoomIn.cutout !== fixture.location - ) { - return ( - - ) - } - })} - {wasteChuteStagingAreaFixtures.map(fixture => { - if ( - zoomIn.cutout == null || - zoomIn.cutout !== fixture.location - ) { - return ( - - ) - } - })} - - )} - areas.location as CutoutId + ) : null + })} + {stagingAreaFixtures.map(fixture => { + if ( + zoomIn.cutout == null || + zoomIn.cutout !== fixture.location + ) { + return ( + + ) + } + })} + {trash != null + ? trashBinFixtures.map(({ cutoutId }) => + cutoutId != null && + (zoomIn.cutout == null || + zoomIn.cutout !== cutoutId) ? ( + + + + + ) : null + ) + : null} + {wasteChuteFixtures.map(fixture => { + if ( + zoomIn.cutout == null || + zoomIn.cutout !== fixture.location + ) { + return ( + + ) + } + })} + {wasteChuteStagingAreaFixtures.map(fixture => { + if ( + zoomIn.cutout == null || + zoomIn.cutout !== fixture.location + ) { + return ( + + ) + } + })} + )} - {...{ - deckDef, - showGen1MultichannelCollisionWarnings, - }} - /> - 0} - /> - {hoverSlot != null ? ( - areas.location as CutoutId + )} + {...{ + deckDef, + showGen1MultichannelCollisionWarnings, + }} + /> + 0} /> - ) : null} - - )} - + {hoverSlot != null ? ( + + ) : null} + + )} + + + {zoomIn.slot != null && zoomIn.cutout != null ? ( + { + dispatch(selectZoomedIntoSlot({ slot: null, cutout: null })) + animateZoom({ + targetViewBox: initialViewBox, + viewBox, + setViewBox, + }) + }} + setHoveredLabware={setHoveredLabware} + /> + ) : null} - {zoomIn.slot != null && zoomIn.cutout != null ? ( - { - dispatch(selectZoomedIntoSlot({ slot: null, cutout: null })) - animateZoom({ - targetViewBox: initialViewBox, - viewBox, - setViewBox, - }) - }} - setHoveredLabware={setHoveredLabware} - /> - ) : null} - + ) } diff --git a/protocol-designer/src/pages/Designer/Offdeck/OffDeckDetails.tsx b/protocol-designer/src/pages/Designer/Offdeck/OffDeckDetails.tsx index 54561d0db9d..235e0778031 100644 --- a/protocol-designer/src/pages/Designer/Offdeck/OffDeckDetails.tsx +++ b/protocol-designer/src/pages/Designer/Offdeck/OffDeckDetails.tsx @@ -48,8 +48,8 @@ export function OffDeckDetails(props: OffDeckDetailsProps): JSX.Element { ) : null} diff --git a/protocol-designer/src/pages/Designer/Offdeck/Offdeck.tsx b/protocol-designer/src/pages/Designer/Offdeck/Offdeck.tsx index e76218186eb..a6b7994deaa 100644 --- a/protocol-designer/src/pages/Designer/Offdeck/Offdeck.tsx +++ b/protocol-designer/src/pages/Designer/Offdeck/Offdeck.tsx @@ -112,8 +112,8 @@ export function OffDeck(props: DeckSetupTabType): JSX.Element { return ( <> {selectedSlot.slot === 'offDeck' ? ( - <> - + + - + ) : ( { if (stepType === 'moveLiquid' || stepType === 'mix') { return ( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index b3236eb1e0e..24d3c8ff1cb 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -9,6 +9,7 @@ import { COLORS, Flex, Icon, + POSITION_RELATIVE, PrimaryButton, SPACING, SecondaryButton, @@ -139,6 +140,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { return ( <> diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx new file mode 100644 index 00000000000..39855667e64 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepSummary.tsx @@ -0,0 +1,336 @@ +import { useSelector } from 'react-redux' +import { Trans, useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + FLEX_MAX_CONTENT, + SPACING, + StyledText, + Tag, +} from '@opentrons/components' +import { getModuleDisplayName } from '@opentrons/shared-data' + +import { getModuleEntities } from '../../../step-forms/selectors' +import { getLabwareNicknamesById } from '../../../ui/labware/selectors' + +import type { FormData } from '../../../form-types' + +interface StyledTransProps { + i18nKey: string + tagText?: string + values?: object +} +function StyledTrans(props: StyledTransProps): JSX.Element { + const { i18nKey, tagText, values } = props + const { t } = useTranslation(['protocol_steps', 'application']) + return ( + + , + semiBoldText: , + tag: , + }} + values={values} + /> + + ) +} + +interface StepSummaryProps { + currentStep: FormData | null +} + +export function StepSummary(props: StepSummaryProps): JSX.Element | null { + const { currentStep } = props + const { t } = useTranslation(['protocol_steps', 'application']) + + const labwareNicknamesById = useSelector(getLabwareNicknamesById) + + const modules = useSelector(getModuleEntities) + if (currentStep?.stepType == null) { + return null + } + const { stepType } = currentStep + + let stepSummaryContent: JSX.Element | null = null + switch (stepType) { + case 'mix': + const { labware: mixLabwareId, volume: mixVolume, times } = currentStep + const mixLabwareDisplayName = labwareNicknamesById[mixLabwareId] + stepSummaryContent = ( + + ) + break + case 'magnet': + const { + moduleId: magneticModuleId, + engageHeight, + magnetAction, + } = currentStep + const magneticModuleDisplayName = getModuleDisplayName( + modules[magneticModuleId].model + ) + stepSummaryContent = + magnetAction === 'engage' ? ( + + ) : ( + + ) + break + case 'thermocycler': + const { + lidIsActive, + lidTargetTemp, + blockIsActive, + blockTargetTemp, + lidOpen, + thermocyclerFormType, + lidOpenHold, + profileTargetLidTemp, + profileVolume, + } = currentStep + stepSummaryContent = + thermocyclerFormType === 'thermocyclerState' ? ( + + {blockIsActive ? ( + + ) : null} + + {lidIsActive ? ( + + ) : null} + + + + ) : ( + + + + + + + + + ) + break + case 'pause': + const { + moduleId: pauseModuleId, + pauseAction, + pauseTime, + pauseTemperature, + } = currentStep + const pauseModuleDisplayName = getModuleDisplayName( + modules[pauseModuleId].model + ) + switch (pauseAction) { + case 'untilResume': + stepSummaryContent = ( + + {t('protocol_steps:pause.untilResume')} + + ) + break + case 'untilTemperature': + stepSummaryContent = ( + + ) + break + case 'untilTime': + stepSummaryContent = ( + + ) + break + } + break + case 'temperature': + const { + moduleId: tempModuleId, + setTemperature, + targetTemperature, + } = currentStep + const isDeactivating = setTemperature === 'false' + const tempModuleDisplayName = getModuleDisplayName( + modules[tempModuleId].model + ) + stepSummaryContent = isDeactivating ? ( + + ) : ( + + ) + break + case 'moveLabware': + const { labware, newLocation, useGripper } = currentStep + const labwareName = labwareNicknamesById[labware] + stepSummaryContent = ( + + ) + break + case 'moveLiquid': + let moveLiquidType + if ( + currentStep.dispense_wells.length > currentStep.aspirate_wells.length + ) { + moveLiquidType = 'distribute' + } else if ( + currentStep.dispense_wells.length < currentStep.aspirate_wells.length + ) { + moveLiquidType = 'consolidate' + } else { + moveLiquidType = 'transfer' + } + const { aspirate_labware, dispense_labware, volume } = currentStep + const sourceLabwareName = labwareNicknamesById[aspirate_labware] + const destinationLabwareName = labwareNicknamesById[dispense_labware] + stepSummaryContent = ( + + ) + + break + case 'heaterShaker': + const { + latchOpen, + heaterShakerTimerMinutes, + heaterShakerTimerSeconds, + moduleId: heaterShakerModuleId, + targetHeaterShakerTemperature, + targetSpeed, + } = currentStep + const moduleDisplayName = getModuleDisplayName( + modules[heaterShakerModuleId].model + ) + stepSummaryContent = ( + + + + {targetSpeed != null ? ( + + ) : null} + + + {heaterShakerTimerMinutes != null && + heaterShakerTimerSeconds != null ? ( + + ) : null} + + + + ) + break + default: + stepSummaryContent = null + } + + return stepSummaryContent != null ? ( + + {stepSummaryContent} + + ) : null +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx index 2be387661fb..b6340bd97ac 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/SubstepsToolbox.tsx @@ -69,7 +69,6 @@ export function SubstepsToolbox( {t('shared:done')} } - height="calc(100vh - 64px)" title={ {i18n.format( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TimelineToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TimelineToolbox.tsx index 7aeabd53802..d51c4b5a9e1 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TimelineToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/TimelineToolbox.tsx @@ -3,8 +3,8 @@ import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { DIRECTION_COLUMN, - FLEX_MAX_CONTENT, Flex, + POSITION_RELATIVE, SPACING, StyledText, Toolbox, @@ -61,10 +61,8 @@ export const TimelineToolbox = (): JSX.Element => { return ( {t('protocol_timeline')} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx index 06e662ebd78..e5a37810a3f 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx @@ -14,6 +14,7 @@ import { SubstepsToolbox, TimelineToolbox } from '../Timeline' vi.mock('../../Offdeck') vi.mock('../../../../step-forms/selectors') vi.mock('../../../../ui/steps/selectors') +vi.mock('../../../../ui/labware/selectors') vi.mock('../StepForm') vi.mock('../../DeckSetup') vi.mock('../Timeline') diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx index 800f1115633..4d1585040a7 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx @@ -7,22 +7,30 @@ import { COLORS, DIRECTION_COLUMN, Flex, - JUSTIFY_CENTER, + JUSTIFY_FLEX_END, + JUSTIFY_FLEX_START, + JUSTIFY_SPACE_BETWEEN, POSITION_FIXED, SPACING, Tag, ToggleGroup, } from '@opentrons/components' -import { getUnsavedForm } from '../../../step-forms/selectors' +import { + getSavedStepForms, + getUnsavedForm, +} from '../../../step-forms/selectors' import { getEnableHotKeysDisplay } from '../../../feature-flags/selectors' import { getIsMultiSelectMode, getSelectedSubstep, + getSelectedStepId, + getHoveredStepId, } from '../../../ui/steps/selectors' import { DeckSetupContainer } from '../DeckSetup' import { OffDeck } from '../Offdeck' import { TimelineToolbox, SubstepsToolbox } from './Timeline' import { StepForm } from './StepForm' +import { StepSummary } from './StepSummary' import { BatchEditToolbox } from './BatchEditToolbox' export function ProtocolSteps(): JSX.Element { @@ -45,39 +53,63 @@ export function ProtocolSteps(): JSX.Element { } }, [formData, formType, deckView]) + const currentHoveredStepId = useSelector(getHoveredStepId) + const currentSelectedStepId = useSelector(getSelectedStepId) + const currentstepIdForStepSummary = + currentHoveredStepId ?? currentSelectedStepId + const savedStepForms = useSelector(getSavedStepForms) + const currentStep = + currentstepIdForStepSummary != null + ? savedStepForms[currentstepIdForStepSummary] + : null + return ( - <> + + {selectedSubstep ? : null} - - - {isMultiSelectMode ? : null} - {formData == null || formType === 'moveLabware' ? ( - { - setDeckView(leftString) - }} - rightClick={() => { - setDeckView(rightString) - }} - /> - ) : null} - {deckView === leftString ? ( - - ) : ( - - )} + + {formData == null || formType === 'moveLabware' ? ( + + { + setDeckView(leftString) + }} + rightClick={() => { + setDeckView(rightString) + }} + /> + + ) : null} + + {deckView === leftString ? ( + + ) : ( + + )} + {formData == null ? ( + + ) : null} + + {enableHoyKeyDisplay ? ( @@ -88,6 +120,8 @@ export function ProtocolSteps(): JSX.Element { ) : null} - + + {isMultiSelectMode ? : null} + ) } diff --git a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx index a96e84b3b44..a4707497ebb 100644 --- a/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx +++ b/protocol-designer/src/pages/ProtocolOverview/DeckThumbnail.tsx @@ -96,9 +96,7 @@ export function DeckThumbnail(props: DeckThumbnailProps): JSX.Element { ) return ( - + - + {deckView === leftString ? ( ) : ( From ce26f3ecb82a51986b6293d8ea92ddeb68c08e1c Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:42:46 -0400 Subject: [PATCH 067/101] fix(shared-data): fix broken RTP choice range formatter util (#16494) We developed a utility function `orderRuntimeParameterRangeOptions` to order choice-type parameters in the `ParametersTable` and `ProtocolDetails` components. However, the util incorrectly assumed that the RTP choice array passed to the utility was of length 2 when it could also be of length 1. This PR filters the array of any length rather than explicitly indexing its second element to fix the bug. --- .../js/helpers/orderRuntimeParameterRangeOptions.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts index 289dd919b14..21565de0e16 100644 --- a/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts +++ b/shared-data/js/helpers/orderRuntimeParameterRangeOptions.ts @@ -21,12 +21,14 @@ export const isNumeric = (str: string): boolean => { export const orderRuntimeParameterRangeOptions = ( choices: Choice[] ): string => { - // when this function is called, the array length is always 2 + // when this function is called, the array length is always in [1,2] if (choices.length > 2) { - console.error(`expected to have length 2 but has length ${choices.length}`) + console.error( + `expected to have length [1,2] but has length ${choices.length}` + ) return '' } - const displayNames = [choices[0].displayName, choices[1].displayName] + const displayNames = choices.map(({ displayName }) => displayName) if (isNumeric(displayNames[0])) { return displayNames .sort((a, b) => { From 64247254c295d7f8f4b1db9159b39ba133e7bae3 Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Wed, 16 Oct 2024 12:03:10 -0400 Subject: [PATCH 068/101] feat(protocol-designer): page one of mix tools (#16493) closes AUTH-807 --- components/src/atoms/InputField/index.tsx | 1 + .../src/assets/localization/en/form.json | 2 +- .../localization/en/protocol_steps.json | 3 +- .../src/assets/localization/en/tooltip.json | 19 +-- .../molecules/InputStepFormField/index.tsx | 34 +----- .../PipetteFields/WellSelectionField.tsx | 3 +- .../StepForm/StepTools/MixTools/index.tsx | 108 +++++++++++++++++- 7 files changed, 128 insertions(+), 42 deletions(-) diff --git a/components/src/atoms/InputField/index.tsx b/components/src/atoms/InputField/index.tsx index 8a9eb76c2d8..8933ca5345c 100644 --- a/components/src/atoms/InputField/index.tsx +++ b/components/src/atoms/InputField/index.tsx @@ -234,6 +234,7 @@ export const InputField = React.forwardRef( return ( Mixing{{times}} times in{{labware}}", + "mix_repetitions": "Mix repetitions", "module": "Module", "move_labware": { "gripper": "Move{{labware}}tousing gripper", @@ -68,7 +69,7 @@ "select_dispense_wells": "Select destination wells using a {{displayName}}", "select_labware": "Select labware", "select_mix_labware": "Select a mix labware", - "select_mix_wells": "Select wells using a {{displayName}}", + "select_wells": "Select wells using a {{displayName}}", "select_nozzles": "Select nozzles", "select_pipette": "Select a pipette", "select_tip_location": "Select pick up tip location", diff --git a/protocol-designer/src/assets/localization/en/tooltip.json b/protocol-designer/src/assets/localization/en/tooltip.json index 3bd438e8587..32e195bc390 100644 --- a/protocol-designer/src/assets/localization/en/tooltip.json +++ b/protocol-designer/src/assets/localization/en/tooltip.json @@ -28,41 +28,43 @@ "step_fields": { "defaults": { - "aspirate_wells": "Selected source wells", - "aspirate_labware": "Select a source labware to use", "aspirate_airGap_checkbox": "Aspirate air before moving to next well", "aspirate_delay_checkbox": "Delay pipette movement after each aspirate in this step, including any Air Gap", "aspirate_delay_mmFromBottom": "Distance from the bottom of the well", "aspirate_flowRate": "The speed at which the pipette aspirates", + "aspirate_labware": "Select a source labware to use", "aspirate_mix_checkbox": "Pipette up and down before aspirating", "aspirate_mmFromBottom": "Adjust tip position for aspirate", "aspirate_touchTip_checkbox": "Touch tip to each side of well after aspirating", "aspirate_touchTip_mmFromBottom": "Distance from the bottom of the well", + "aspirate_wells": "Selected source wells", "blowout_checkbox": "Where to dispose of remaining volume in tip", + "blowout_flowRate": "Blowout speed", "blowout_location": "Where to dispose of remaining volume in tip", + "blowout_z_offset": "The height at which blowout occurs from the top of the well", "changeTip": "Choose when the robot picks up fresh tips", - "dispense_wells": "Selected destination wells", - "dispense_labware": "Select a destination labware to use", "dispense_airGap_checkbox": "Aspirate air before moving to Trash to dispose of tip. Tip will be disposed of at the end of steps using this setting.", "dispense_delay_checkbox": "Delay pipette movement after each dispense in this step", "dispense_delay_mmFromBottom": "Distance from the bottom of the well", "dispense_flowRate": "The speed at which the pipette dispenses", + "dispense_labware": "Select a destination labware to use", "dispense_mix_checkbox": "Pipette up and down after dispensing", "dispense_mmFromBottom": "Adjust tip position for dispense", "dispense_touchTip_checkbox": "Touch tip to each side of well after dispensing and other dispense advanced setting commands", "dispense_touchTip_mmFromBottom": "Distance from the bottom of the well", + "dispense_wells": "Selected destination wells", "disposalVolume_checkbox": "Aspirate extra volume that is disposed of after a multi-dispense is complete. We recommend a disposal volume of at least the pipette's minimum.", "dropTip_location": "Choose where you would like to drop tip", "heaterShakerSetTimer": "Once this counter has elapsed, the module will deactivate the heater and shaker", + "labware": "Select a labware to use", "mix_mmFromBottom": "Adjust tip position", "mix_touchTip_checkbox": "Touch tip to each side of the well after mixing", "mix_touchTip_mmFromBottom": "Distance from the bottom of the well", "pipette": "Select the pipette you want to use", "preWetTip": "Pre-wet pipette tip by aspirating and dispensing 2/3 of the tip's max volume", - "volume": "Volume to dispense in each well", - "blowout_z_offset": "The height at which blowout occurs from the top of the well", - "blowout_flowRate": "Blowout speed", - "setTemperature": "Select the temperature to set your module to" + "setTemperature": "Select the temperature to set your module to", + "wells": "Select wells", + "volume": "Volume to dispense in each well" }, "indeterminate": { "aspirate_airGap_checkbox": "Not all selected steps are using this setting", @@ -79,6 +81,7 @@ }, "mix": { "disabled": { + "wells": "Select a labware before selecting wells", "mix_mmFromBottom": "Tip position adjustment is not supported", "blowout_z_offset": "Blowout location and destination labware must first be selected" } diff --git a/protocol-designer/src/molecules/InputStepFormField/index.tsx b/protocol-designer/src/molecules/InputStepFormField/index.tsx index 1c05b07e1fc..93dde06295f 100644 --- a/protocol-designer/src/molecules/InputStepFormField/index.tsx +++ b/protocol-designer/src/molecules/InputStepFormField/index.tsx @@ -1,14 +1,5 @@ import { useTranslation } from 'react-i18next' -import { - COLORS, - DIRECTION_COLUMN, - Flex, - Icon, - InputField, - SPACING, - Tooltip, - useHoverTooltip, -} from '@opentrons/components' +import { Flex, InputField, SPACING } from '@opentrons/components' import type { FieldProps } from '../../components/StepEditForm/types' interface InputStepFormFieldProps extends FieldProps { @@ -38,29 +29,14 @@ export function InputStepFormField( ...otherProps } = props const { t } = useTranslation('tooltip') - const [targetProps, tooltipProps] = useHoverTooltip() return ( - - - {showTooltip ? ( - <> - - - - - {t(`${tooltipContent}`)} - - - ) : null} - + { dispatch(stepsActions.setWellSelectionLabwareKey(key)) } + const handleOpen = (): void => { if (onFieldFocus) { onFieldFocus() @@ -109,7 +110,7 @@ export const WellSelectionField = ( {t(`tooltip:${tooltipContent}`)} TODO: wire this up
    +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { DIRECTION_COLUMN, Divider, Flex } from '@opentrons/components' +import { InputStepFormField } from '../../../../../../molecules' +import { + getLabwareEntities, + getPipetteEntities, +} from '../../../../../../step-forms/selectors' +import { getEnableReturnTip } from '../../../../../../feature-flags/selectors' +import { + ChangeTipField, + DropTipField, + LabwareField, + PartialTipField, + PickUpTipField, + PipetteField, + TipWellSelectionField, + TiprackField, + VolumeField, + WellSelectionField, +} from '../../PipetteFields' +import type { StepFormProps } from '../../types' + +export function MixTools(props: StepFormProps): JSX.Element { + const { propsForFields, formData, toolboxStep } = props + const pipettes = useSelector(getPipetteEntities) + const enableReturnTip = useSelector(getEnableReturnTip) + const labwares = useSelector(getLabwareEntities) + const { t } = useTranslation(['application', 'form']) + const is96Channel = + propsForFields.pipette.value != null && + pipettes[String(propsForFields.pipette.value)].name === 'p1000_96' + const userSelectedPickUpTipLocation = + labwares[String(propsForFields.pickUpTip_location.value)] != null + const userSelectedDropTipLocation = + labwares[String(propsForFields.dropTip_location.value)] != null + + return toolboxStep === 0 ? ( + + + {is96Channel ? : null} + + + + + + + + + + + + + + + {enableReturnTip ? ( + <> + + {userSelectedPickUpTipLocation ? ( + <> + + + + ) : null} + + ) : null} + + + {userSelectedDropTipLocation && enableReturnTip ? ( + <> + + + + ) : null} + + ) : ( +
    wire this up
    + ) } From e3dc8d76c21a3c95fc5377969a4146144cf2fa2e Mon Sep 17 00:00:00 2001 From: Brayan Almonte Date: Wed, 16 Oct 2024 14:14:05 -0400 Subject: [PATCH 069/101] fix(robot-server): fix robot-server blinker task startup causing hw init failure on the Flex. (#16483) --- robot-server/robot_server/hardware.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/robot-server/robot_server/hardware.py b/robot-server/robot_server/hardware.py index 2ccdba30105..83d94b4f4ed 100644 --- a/robot-server/robot_server/hardware.py +++ b/robot-server/robot_server/hardware.py @@ -153,11 +153,19 @@ async def start_blinking(self, hardware: HardwareControlAPI) -> None: Blinking will continue until `mark_hardware_init_complete()` and `mark_persistence_init_complete()` have both been called. """ + if should_use_ot3(): + # Dont run this task on the Flex because, + # 1. There is no front button blinker on the Flex + # 2. The Flex raises an error when attempting to turn on the lights + # while there is a system firmware update in progress. This causes + # the hardware controller to fail initialization, leaving the + # robot server in a bad state. + return + assert self._hardware_and_task is None, "hardware should only be set once." async def blink_forever() -> None: while True: - # This should no-op on a Flex. await hardware.set_lights(button=True) await asyncio.sleep(0.5) await hardware.set_lights(button=False) From e4a8d6ceaa1adffbcb2061e26f7d6f78282a724f Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Wed, 16 Oct 2024 15:00:11 -0400 Subject: [PATCH 070/101] feat(app): add Tip Drop Failed flow to Error Recovery (#16496) Closes EXEC-722 This PR wires up the FE implementation of the "Tip Drop Failed" Error Recovery flow. This flow is straightforward. The only real refactor is that the copy for recovery options is now special-cased, so instead of "Retry step", it's, for example, "Retry dropping tip". Note that the feature is not fully implemented until EXEC-764 closes. --- .../localization/en/error_recovery.json | 2 + .../ErrorRecoveryFlows/RecoveryError.tsx | 12 ++- .../RecoveryOptions/SelectRecoveryOption.tsx | 17 ++- .../__tests__/SelectRecoveryOptions.test.tsx | 100 +++++++++++++++--- .../organisms/ErrorRecoveryFlows/constants.ts | 2 + .../__tests__/useRecoveryOptionCopy.test.tsx | 21 +++- .../ErrorRecoveryFlows/hooks/useErrorName.ts | 2 + .../hooks/useRecoveryOptionCopy.tsx | 16 ++- .../shared/RetryStepInfo.tsx | 2 + .../shared/__tests__/RetryStepInfo.test.tsx | 11 ++ .../utils/__tests__/getErrorKind.test.ts | 11 ++ .../ErrorRecoveryFlows/utils/getErrorKind.ts | 9 +- 12 files changed, 173 insertions(+), 32 deletions(-) diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index c5e5537ca83..261dbfd278d 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -68,6 +68,7 @@ "replace_used_tips_in_rack_location": "Replace used tips in rack location {{location}} in Slot {{slot}}", "replace_with_new_tip_rack": "Replace with new tip rack in Slot {{slot}}", "resume": "Resume", + "retry_dropping_tip": "Retry dropping tip", "retry_now": "Retry now", "retry_step": "Retry step", "retry_with_new_tips": "Retry with new tips", @@ -98,6 +99,7 @@ "take_any_necessary_precautions": "Take any necessary precautions before positioning yourself to stabilize or catch the labware. Once confirmed, a countdown will begin before the gripper releases.", "take_necessary_actions": "First, take any necessary actions to prepare the robot to retry the failed step.Then, close the robot door before proceeding.", "take_necessary_actions_failed_pickup": "First, take any necessary actions to prepare the robot to retry the failed tip pickup.Then, close the robot door before proceeding.", + "take_necessary_actions_failed_tip_drop": "First, take any necessary actions to prepare the robot to retry the failed tip drop.Then, close the robot door before proceeding.", "terminate_remote_activity": "Terminate remote activity", "tip_drop_failed": "Tip drop failed", "tip_not_detected": "Tip not detected", diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx index 985c7a456c7..dd680ed24f6 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx @@ -54,6 +54,7 @@ export function ErrorRecoveryFlowError({ currentRecoveryOptionUtils, routeUpdateActions, recoveryCommands, + errorKind, }: RecoveryContentProps): JSX.Element { const { OPTION_SELECTION } = RECOVERY_MAP const { t } = useTranslation('error_recovery') @@ -61,7 +62,10 @@ export function ErrorRecoveryFlowError({ const { proceedToRouteAndStep, handleMotionRouting } = routeUpdateActions const { homePipetteZAxes } = recoveryCommands - const userRecoveryOptionCopy = getRecoveryOptionCopy(selectedRecoveryOption) + const userRecoveryOptionCopy = getRecoveryOptionCopy( + selectedRecoveryOption, + errorKind + ) const onPrimaryClick = (): void => { void handleMotionRouting(true) @@ -87,6 +91,7 @@ export function RecoveryDropTipFlowErrors({ currentRecoveryOptionUtils, routeUpdateActions, getRecoveryOptionCopy, + errorKind, }: RecoveryContentProps): JSX.Element { const { t } = useTranslation('error_recovery') const { step } = recoveryMap @@ -98,7 +103,10 @@ export function RecoveryDropTipFlowErrors({ const { selectedRecoveryOption } = currentRecoveryOptionUtils const { proceedToRouteAndStep } = routeUpdateActions - const userRecoveryOptionCopy = getRecoveryOptionCopy(selectedRecoveryOption) + const userRecoveryOptionCopy = getRecoveryOptionCopy( + selectedRecoveryOption, + errorKind + ) const buildTitle = (): string => { switch (step) { diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index 731f443f1dc..36b22c6ed3c 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -89,6 +89,7 @@ export function SelectRecoveryOptionHome({ setSelectedRoute={setSelectedRoute} selectedRoute={selectedRoute} getRecoveryOptionCopy={getRecoveryOptionCopy} + errorKind={errorKind} />
    @@ -97,6 +98,7 @@ export function SelectRecoveryOptionHome({ setSelectedRoute={setSelectedRoute} selectedRoute={selectedRoute} getRecoveryOptionCopy={getRecoveryOptionCopy} + errorKind={errorKind} />
    @@ -109,10 +111,12 @@ interface RecoveryOptionsProps { validRecoveryOptions: RecoveryRoute[] setSelectedRoute: (route: RecoveryRoute) => void getRecoveryOptionCopy: RecoveryContentProps['getRecoveryOptionCopy'] + errorKind: ErrorKind selectedRoute?: RecoveryRoute } // For ODD use only. export function ODDRecoveryOptions({ + errorKind, validRecoveryOptions, selectedRoute, setSelectedRoute, @@ -125,7 +129,7 @@ export function ODDRecoveryOptions({ width="100%" > {validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { - const optionName = getRecoveryOptionCopy(recoveryOption) + const optionName = getRecoveryOptionCopy(recoveryOption, errorKind) return ( - {getRecoveryOptionCopy(option)} + {getRecoveryOptionCopy(option, errorKind)} ), } as const) @@ -195,6 +200,8 @@ export function getRecoveryOptions(errorKind: ErrorKind): RecoveryRoute[] { return OVERPRESSURE_WHILE_DISPENSING_OPTIONS case ERROR_KINDS.TIP_NOT_DETECTED: return TIP_NOT_DETECTED_OPTIONS + case ERROR_KINDS.TIP_DROP_FAILED: + return TIP_DROP_FAILED_OPTIONS case ERROR_KINDS.GRIPPER_ERROR: return GRIPPER_ERROR_OPTIONS case ERROR_KINDS.GENERAL_ERROR: @@ -231,6 +238,12 @@ export const TIP_NOT_DETECTED_OPTIONS: RecoveryRoute[] = [ RECOVERY_MAP.CANCEL_RUN.ROUTE, ] +export const TIP_DROP_FAILED_OPTIONS: RecoveryRoute[] = [ + RECOVERY_MAP.RETRY_STEP.ROUTE, + RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE, + RECOVERY_MAP.CANCEL_RUN.ROUTE, +] + export const GRIPPER_ERROR_OPTIONS: RecoveryRoute[] = [ RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx index dd3acb06ece..0d9fae0f958 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx @@ -17,6 +17,7 @@ import { OVERPRESSURE_WHILE_DISPENSING_OPTIONS, NO_LIQUID_DETECTED_OPTIONS, TIP_NOT_DETECTED_OPTIONS, + TIP_DROP_FAILED_OPTIONS, GRIPPER_ERROR_OPTIONS, } from '../SelectRecoveryOption' import { RECOVERY_MAP, ERROR_KINDS } from '../../constants' @@ -80,22 +81,28 @@ describe('SelectRecoveryOption', () => { } when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, expect.any(String)) .thenReturn('Retry step') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, ERROR_KINDS.TIP_DROP_FAILED) + .thenReturn('Retry dropping tip') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE, expect.any(String)) .thenReturn('Cancel run') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE, expect.any(String)) .thenReturn('Retry with new tips') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE) + .calledWith(RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, expect.any(String)) .thenReturn('Manually fill well and skip to next step') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, expect.any(String)) .thenReturn('Retry with same tips') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE) + .calledWith( + RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, + expect.any(String) + ) .thenReturn('Skip to next step with same tips') }) @@ -129,7 +136,6 @@ describe('SelectRecoveryOption', () => { ...props, errorKind: ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING, } - renderSelectRecoveryOption(props) screen.getByText('Choose a recovery action') @@ -212,6 +218,28 @@ describe('SelectRecoveryOption', () => { RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE ) }) + + it('renders appropriate "Tip drop failed" copy and click behavior', () => { + props = { + ...props, + errorKind: ERROR_KINDS.TIP_DROP_FAILED, + } + + renderSelectRecoveryOption(props) + + screen.getByText('Choose a recovery action') + + const retryDroppingTip = screen.getAllByRole('label', { + name: 'Retry dropping tip', + }) + + fireEvent.click(retryDroppingTip[0]) + clickButtonLabeled('Continue') + + expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.RETRY_STEP.ROUTE + ) + }) }) ;([ ['desktop', renderDesktopRecoveryOptions] as const, @@ -230,40 +258,53 @@ describe('SelectRecoveryOption', () => { ) props = { + errorKind: ERROR_KINDS.GENERAL_ERROR, validRecoveryOptions: generalRecoveryOptions, setSelectedRoute: mockSetSelectedRoute, getRecoveryOptionCopy: mockGetRecoveryOptionCopy, } when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, expect.any(String)) .thenReturn('Retry step') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_STEP.ROUTE, ERROR_KINDS.TIP_DROP_FAILED) + .thenReturn('Retry dropping tip') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.CANCEL_RUN.ROUTE, expect.any(String)) .thenReturn('Cancel run') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE, expect.any(String)) .thenReturn('Retry with new tips') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE) + .calledWith(RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, expect.any(String)) .thenReturn('Manually fill well and skip to next step') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE) + .calledWith(RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE, expect.any(String)) .thenReturn('Retry with same tips') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE) + .calledWith( + RECOVERY_MAP.SKIP_STEP_WITH_SAME_TIPS.ROUTE, + expect.any(String) + ) .thenReturn('Skip to next step with same tips') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.SKIP_STEP_WITH_NEW_TIPS.ROUTE) + .calledWith( + RECOVERY_MAP.SKIP_STEP_WITH_NEW_TIPS.ROUTE, + expect.any(String) + ) .thenReturn('Skip to next step with new tips') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE) + .calledWith(RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE, expect.any(String)) .thenReturn('Ignore error and skip to next step') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE) + .calledWith(RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE, expect.any(String)) .thenReturn('Manually move labware and skip to next step') when(mockGetRecoveryOptionCopy) - .calledWith(RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE) + .calledWith( + RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE, + expect.any(String) + ) .thenReturn('Manually replace labware on deck and retry step') }) @@ -354,6 +395,24 @@ describe('SelectRecoveryOption', () => { screen.getByRole('label', { name: 'Cancel run' }) }) + it(`renders valid recovery options for a ${ERROR_KINDS.TIP_DROP_FAILED} errorKind`, () => { + props = { + ...props, + errorKind: ERROR_KINDS.TIP_DROP_FAILED, + validRecoveryOptions: TIP_DROP_FAILED_OPTIONS, + } + + renderer(props) + + screen.getByRole('label', { + name: 'Retry dropping tip', + }) + screen.getByRole('label', { + name: 'Ignore error and skip to next step', + }) + screen.getByRole('label', { name: 'Cancel run' }) + }) + it(`renders valid recovery options for a ${ERROR_KINDS.GRIPPER_ERROR} errorKind`, () => { props = { ...props, @@ -418,6 +477,13 @@ describe('getRecoveryOptions', () => { expect(overpressureWhileDispensingOptions).toBe(TIP_NOT_DETECTED_OPTIONS) }) + it(`returns valid options when the errorKind is ${ERROR_KINDS.TIP_DROP_FAILED}`, () => { + const overpressureWhileDispensingOptions = getRecoveryOptions( + ERROR_KINDS.TIP_DROP_FAILED + ) + expect(overpressureWhileDispensingOptions).toBe(TIP_DROP_FAILED_OPTIONS) + }) + it(`returns valid options when the errorKind is ${ERROR_KINDS.GRIPPER_ERROR}`, () => { const overpressureWhileDispensingOptions = getRecoveryOptions( ERROR_KINDS.GRIPPER_ERROR diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index 4923ceca53e..d63e69e5e22 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -18,6 +18,7 @@ export const DEFINED_ERROR_TYPES = { OVERPRESSURE: 'overpressure', LIQUID_NOT_FOUND: 'liquidNotFound', TIP_PHYSICALLY_MISSING: 'tipPhysicallyMissing', + TIP_PHYSICALLY_ATTACHED: 'tipPhysicallyAttached', GRIPPER_MOVEMENT: 'gripperMovement', } @@ -29,6 +30,7 @@ export const ERROR_KINDS = { OVERPRESSURE_WHILE_ASPIRATING: 'OVERPRESSURE_WHILE_ASPIRATING', OVERPRESSURE_WHILE_DISPENSING: 'OVERPRESSURE_WHILE_DISPENSING', TIP_NOT_DETECTED: 'TIP_NOT_DETECTED', + TIP_DROP_FAILED: 'TIP_DROP_FAILED', GRIPPER_ERROR: 'GRIPPER_ERROR', } as const diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx index 90a78262e6c..2c2e2b4442b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx @@ -4,20 +4,26 @@ import { describe, it } from 'vitest' import { screen } from '@testing-library/react' import { useRecoveryOptionCopy } from '../useRecoveryOptionCopy' -import { RECOVERY_MAP } from '../../constants' +import { ERROR_KINDS, RECOVERY_MAP } from '../../constants' -import type { RecoveryRoute } from '../../types' +import type { ErrorKind, RecoveryRoute } from '../../types' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' function MockRenderCmpt({ route, + errorKind, }: { route: RecoveryRoute | null + errorKind?: ErrorKind }): JSX.Element { const getRecoveryOptionCopy = useRecoveryOptionCopy() - return
    {getRecoveryOptionCopy(route)}
    + return ( +
    + {getRecoveryOptionCopy(route, errorKind ?? ERROR_KINDS.GENERAL_ERROR)} +
    + ) } const render = (props: React.ComponentProps) => { @@ -33,6 +39,15 @@ describe('useRecoveryOptionCopy', () => { screen.getByText('Retry step') }) + it(`renders the correct copy for ${RECOVERY_MAP.RETRY_STEP.ROUTE} when the error kind is ${ERROR_KINDS.TIP_DROP_FAILED}`, () => { + render({ + route: RECOVERY_MAP.RETRY_STEP.ROUTE, + errorKind: ERROR_KINDS.TIP_DROP_FAILED, + }) + + screen.getByText('Retry dropping tip') + }) + it(`renders the correct copy for ${RECOVERY_MAP.CANCEL_RUN.ROUTE}`, () => { render({ route: RECOVERY_MAP.CANCEL_RUN.ROUTE }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts index ee1eaad7775..6acd0df2f45 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts @@ -19,6 +19,8 @@ export function useErrorName(errorKind: ErrorKind): string { return t('pipette_overpressure') case ERROR_KINDS.TIP_NOT_DETECTED: return t('tip_not_detected') + case ERROR_KINDS.TIP_DROP_FAILED: + return t('tip_drop_failed') case ERROR_KINDS.GRIPPER_ERROR: return t('gripper_error') default: diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx index 4cf7cc80eda..18a7da7a319 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx @@ -1,21 +1,27 @@ import { useTranslation } from 'react-i18next' -import type { RecoveryRoute } from '../types' -import { RECOVERY_MAP } from '../constants' +import type { ErrorKind, RecoveryRoute } from '../types' +import { ERROR_KINDS, RECOVERY_MAP } from '../constants' // Return user-friendly recovery option copy from a given route. Only routes that are // recovery options are handled. export function useRecoveryOptionCopy(): ( - recoveryOption: RecoveryRoute | null + recoveryOption: RecoveryRoute | null, + errorKind: ErrorKind ) => string { const { t } = useTranslation('error_recovery') const getRecoveryOptionCopy = ( - recoveryOption: RecoveryRoute | null + recoveryOption: RecoveryRoute | null, + errorKind: ErrorKind ): string => { switch (recoveryOption) { case RECOVERY_MAP.RETRY_STEP.ROUTE: - return t('retry_step') + if (errorKind === ERROR_KINDS.TIP_DROP_FAILED) { + return t('retry_dropping_tip') + } else { + return t('retry_step') + } case RECOVERY_MAP.CANCEL_RUN.ROUTE: return t('cancel_run') case RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE: diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx index d9bc3e1e23d..c9f7567ee94 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx @@ -29,6 +29,8 @@ export function RetryStepInfo(props: RecoveryContentProps): JSX.Element { return 'take_necessary_actions_failed_pickup' case ERROR_KINDS.GRIPPER_ERROR: return 'robot_retry_failed_lw_movement' + case ERROR_KINDS.TIP_DROP_FAILED: + return 'take_necessary_actions_failed_tip_drop' default: return 'take_necessary_actions' } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx index 67419387852..03ecd64299b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/RetryStepInfo.test.tsx @@ -78,6 +78,17 @@ describe('RetryStepInfo', () => { screen.getByText('Then, close the robot door before proceeding.') }) + it(`renders correct body text for ${ERROR_KINDS.TIP_DROP_FAILED} error`, () => { + props.errorKind = ERROR_KINDS.TIP_DROP_FAILED + + render(props) + + screen.getByText( + 'First, take any necessary actions to prepare the robot to retry the failed tip drop.' + ) + screen.getByText('Then, close the robot door before proceeding.') + }) + it(`renders correct body text for ${ERROR_KINDS.GRIPPER_ERROR}`, () => { props.errorKind = ERROR_KINDS.GRIPPER_ERROR diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts index b541a10e72a..55399fa1d22 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts @@ -50,6 +50,17 @@ describe('getErrorKind', () => { expect(result).toEqual(ERROR_KINDS.TIP_NOT_DETECTED) }) + it(`returns ${ERROR_KINDS.TIP_DROP_FAILED} for ${DEFINED_ERROR_TYPES.TIP_PHYSICALLY_ATTACHED} errorType`, () => { + const result = getErrorKind({ + commandType: 'dropTip', + error: { + isDefined: true, + errorType: DEFINED_ERROR_TYPES.TIP_PHYSICALLY_ATTACHED, + } as RunCommandError, + } as RunTimeCommand) + expect(result).toEqual(ERROR_KINDS.TIP_DROP_FAILED) + }) + it(`returns ${ERROR_KINDS.GRIPPER_ERROR} for ${DEFINED_ERROR_TYPES.GRIPPER_MOVEMENT} errorType`, () => { const result = getErrorKind({ commandType: 'moveLabware', diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts index c75e18026f5..9c96230836b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts @@ -35,9 +35,12 @@ export function getErrorKind(failedCommand: RunTimeCommand | null): ErrorKind { errorType === DEFINED_ERROR_TYPES.TIP_PHYSICALLY_MISSING ) { return ERROR_KINDS.TIP_NOT_DETECTED - } - // TODO(jh 09-25-24): Update the error to match what the server actually sends when available. - else if ( + } else if ( + (commandType === 'dropTip' || commandType === 'dropTipInPlace') && + errorType === DEFINED_ERROR_TYPES.TIP_PHYSICALLY_ATTACHED + ) { + return ERROR_KINDS.TIP_DROP_FAILED + } else if ( commandType === 'moveLabware' && errorType === DEFINED_ERROR_TYPES.GRIPPER_MOVEMENT ) { From f88fabbd4a099afeec7c0ef1f44c149335551180 Mon Sep 17 00:00:00 2001 From: Josh McVey Date: Wed, 16 Oct 2024 17:01:27 -0500 Subject: [PATCH 071/101] chore(docs): ai server and client local dev (#16452) --- opentrons-ai-client/Makefile | 4 ++ opentrons-ai-client/README.md | 31 +++++++-- opentrons-ai-server/Makefile | 4 +- opentrons-ai-server/README.md | 127 +++++++++++++++++++++++++--------- 4 files changed, 126 insertions(+), 40 deletions(-) diff --git a/opentrons-ai-client/Makefile b/opentrons-ai-client/Makefile index 2ba3671882e..ba4e1076639 100644 --- a/opentrons-ai-client/Makefile +++ b/opentrons-ai-client/Makefile @@ -78,3 +78,7 @@ staging-deploy: .PHONY: prod-deploy prod-deploy: aws s3 sync ./dist s3://prod-opentrons-ai-front-end/ --delete + +.PHONY: format-md-json +format-md-json: + yarn prettier --ignore-path .eslintignore --write **/*.md **/*.json diff --git a/opentrons-ai-client/README.md b/opentrons-ai-client/README.md index c2a15875311..e603c3466a8 100644 --- a/opentrons-ai-client/README.md +++ b/opentrons-ai-client/README.md @@ -8,7 +8,11 @@ The Opentrons AI application helps you to create a protocol with natural languag ## Developing -To get started: clone the `Opentrons/opentrons` repository, set up your computer for development as specified in the [contributing guide][contributing-guide-setup], and then: +To get started: + +1. Clone the `Opentrons/opentrons` repository +1. Read the [contributing guide.][contributing-guide-setup] +1. Follow the [DEV_SETUP.md](../DEV_SETUP.md) for your platform. ```shell # change into the cloned directory @@ -24,6 +28,23 @@ make teardown-js && make setup-js make -C opentrons-ai-client dev ``` +## Auth0 + +[Auth0 requires consent](https://auth0.com/docs/get-started/applications/confidential-and-public-applications/user-consent-and-third-party-applications#skip-consent-for-first-party-applications) in the local application. + +### Allow consent in the local application + +Alter the `authorizationParams` in `src/main.tsx`, provide consent, then remove the change. Once you provide consent in the local application, you will not be prompted for consent again. The consent is stored in Auth0. + +```ts +// src/main.tsx +authorizationParams={{ + redirect_uri: window.location.origin, + prompt: 'consent', + audience: 'sandbox-ai-api', + }} +``` + ## Stack and structure The UI stack is built using: @@ -55,14 +76,16 @@ Test tasks can also be run with the following arguments: | watch | `false` | Run tests in watch mode | `make test-unit watch=true` | | cover | `!watch` | Calculate code coverage | `make test watch=true cover=true` | -## Building +## Local development notes + +- [constants.ts](./src/resources/constants.ts) defines the AI API location and the Auth0 configuration. +- [main.tsx](./src/main.tsx) has the logic to use the appropriate constants based on the environment. -TBD +## Links [style-guide]: https://standardjs.com [style-guide-badge]: https://img.shields.io/badge/code_style-standard-brightgreen.svg?style=flat-square&maxAge=3600 [contributing-guide-setup]: ../CONTRIBUTING.md#development-setup -[contributing-guide-running-the-api]: ../CONTRIBUTING.md#opentrons-api [react]: https://react.dev/ [babel]: https://babeljs.io/ [vite]: https://vitejs.dev/ diff --git a/opentrons-ai-server/Makefile b/opentrons-ai-server/Makefile index 60eba38a312..ecc643d9cd0 100644 --- a/opentrons-ai-server/Makefile +++ b/opentrons-ai-server/Makefile @@ -95,8 +95,8 @@ live-client: live-hf: python -m pipenv run python -m tests.helpers.huggingface_client -.PHONY: test-live -test-live: +.PHONY: live-test +live-test: python -m pipenv run python -m pytest tests -m live --env $(ENV) IMAGE_NAME=ai-server-local diff --git a/opentrons-ai-server/README.md b/opentrons-ai-server/README.md index 323eb9985bf..b072429c41c 100644 --- a/opentrons-ai-server/README.md +++ b/opentrons-ai-server/README.md @@ -2,7 +2,22 @@ ## Overview -The Opentrons AI application's server. +The Opentrons AI server is a FastAPI server that handles complex tasks like running generative AI models and integrating with the OpenAI API. One challenge we faced was the long processing time for chat completion, which can take 1-3 minutes. This ruled out serverless options like Lambda, as they typically have strict time limits. Ultimately, we opted for a robust architecture using CloudFront, a load balancer, and an ECS Fargate container running our FastAPI server. This robust architecture ensures reliable performance and scalability, allowing users to seamlessly interact with our AI-powered tools and automate their lab workflows. + +## Deployed Environments + +Currently we have 2 environments: `staging` and `prod`. + +- staging: +- prod: + +If your browser blocks cross site cookies, use instead. + +### Environment Variables and Secrets + +The opentrons-ai-server/api/settings.py file manages environment variables and secrets. Locally, a .env file (which is ignored by git) stores these values. For deployed environments, AWS Secrets Manager handles both secrets and environment variables. Our deploy script uses the settings class to ensure ECS Fargate loads these values correctly. Important: Update the settings class whenever you add new environment variables or secrets; otherwise, the deploy script will fail. + +> Note: To update and environment variable or secret you must update the value in AWS secrets manager AND redeploy the service. Environment variables and secrets are not dynamically updated in the deployed environment. They are loaded at service start up. ## Developing @@ -10,51 +25,95 @@ The Opentrons AI application's server. ### Setup -1. clone the repository `gh repo clone Opentrons/opentrons` +1. clone the repository `gh repo clone Opentrons/opentrons`. 1. `cd opentrons/opentrons-ai-server` -1. Have pyenv installed per [DEV_SETUP.md](../DEV_SETUP.md) -1. Use pyenv to install python `pyenv install 3.12.6` or latest 3.12.\* -1. Have nodejs and yarn installed per [DEV_SETUP.md](../DEV_SETUP.md) - 1. This allows formatting of of `.md` and `.json` files -1. select the python version `pyenv local 3.12.6` - 1. This will create a `.python-version` file in this directory -1. select the node version with `nvs` or `nvm` currently 18.19\* -1. Install pipenv and python dependencies `make setup` +1. Have pyenv installed per [DEV_SETUP.md](../DEV_SETUP.md). +1. Use pyenv to install python `pyenv install 3.12.6` or latest 3.12.\*. +1. Have nodejs and yarn installed per [DEV_SETUP.md](../DEV_SETUP.md). + 1. This allows formatting of of `.md` and `.json` files. +1. select the python version `pyenv local 3.12.6`. + 1. This will create a `.python-version` file in this directory. +1. select the node version with `nvs` or `nvm` currently 18.19\*. +1. Install pipenv and python dependencies using `make setup`. +1. Install docker if you plan to run and build the docker container locally. +1. `make teardown` will remove the virtual environment but requires pipenv to be installed. -## For building and deploying +### Run locally -1. AWS credentials and config -1. docker +> The server may be run locally with or without Docker. Run without docker to test changes quickly. Run with docker to test in a more production like environment. -## Install a dev dependency +#### Without Docker -`python -m pipenv install pytest==8.2.0 --dev` +1. get the .env file from a team member +1. in the `opentrons-ai-server` directory +1. `make local-run` -## Install a production dependency +#### With Docker -`python -m pipenv install openai==1.25.1` +In the deployed environments the FastAPI server is run in a docker container. To run the server locally in a docker container: + +1. get the .env file from a team member +1. put the .env file in the `opentrons-ai-server` directory +1. in the `opentrons-ai-server` directory +1. `make rebuild` + +Now the API is running at +View the API docs in a browser at + +#### Direct API Interaction and Authentication -## FastAPI Code Organization and Separation of Concerns +> There is only 1 endpoint with the potential to call the OpenAI API. This is the `/api/chat/completion` endpoint. This endpoint requires authentication and the steps are outlined below. In the POST request body setting `"fake": true` will short circuit the handling of the call. The OpenAI API will not be hit. Instead, a hard coded response is returned. We plan to extend this capability to allow for live local testing of the UI without calling the OpenAI API. -- handler - - the router and request/response handling -- domain - - business logic -- integration - - integration with other services +To access the `/api/chat/completion` API endpoint, you will need to provide an Authorization header in your API calls. +`"Authorization": "Bearer YOUR_TOKEN"` + +1. get the file `test.env` from a team member +1. put the `test.env` file in the `opentrons-ai-server/tests/helpers` directory +1. run `make live-client` and select local for the environment +1. a token will now be cached `opentrons-ai-server/tests/helpers/cached_token.txt` directory +1. use this token in the Authorization header of your favorite API client + +#### Live Tests + +The live-test target will run tests against any environment. The default is local. The environment can be set by setting the ENV variable. + +1. have the server running locally +1. run `make live-test` +1. You should see the tests run and pass against the local environment + +#### API Access from the UI + +1. Follow the directions in the [opentrons-ai-client README](../opentrons-ai-client/README.md) to run the UI locally +1. The UI is running at when you load this it will redirect you to the login page +1. It should start with +1. Create an account or login with your existing account +1. You will be redirected back to the UI +1. Your token (JWT) will be stored in the browser local storage and used for all API calls +1. The local dev API actually validates this real token. ## Dev process -1. Make your changes -1. Fix what can be automatically then lint and unit test like CI will `make pre-commit` -1. `make pre-commit` passes -1. run locally `make run` this runs the FastAPI server directly at localhost:8000 - 1. this watches for changes and restarts the server +1. run the server locally `make run-local` +1. do development +1. `make fixup` formats, lints, and runs mypy +1. `make pre-commit` does what CI will do +1. `make build` to make sure that the docker container builds +1. `make run` to make sure the docker container runs 1. test locally `make live-test` (ENV=local is the default in the Makefile) -1. use the live client `make live-client` +1. use the live client `make live-client`, your favorite API tool, or the UI to test the API +1. commit and push your changes and create a PR pointing at the `edge` branch +1. CI passes and a team member reviews your PR +1. when your PR is merged to `edge` it will be automatically deployed to the staging environment + +## Install a dev dependency + +`python -m pipenv install pytest==8.2.0 --dev` + +## Install a production dependency + +`python -m pipenv install openai==1.25.1` -## ECS Fargate +## Upgrade a dependency -- Our first version of this service is a long running POST that may take from 1-3 minutes to complete -- This forces us to use CloudFront(Max 180) + Load Balancer + ECS Fargate FastAPI container -- An AWS service ticket is needed to increase the max CloudFront response time from 60 to 180 seconds +1. alter the `Pipfile` to the new pinned version +1. run `make setup` to update the `Pipfile.lock` From 6645be2ba91f046a350852de6514af21edcf8bed Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Wed, 16 Oct 2024 18:29:52 -0400 Subject: [PATCH 072/101] feat(api): Return `tipPhysicallyAttachedError` from `dropTip` and `dropTipInPlace` commands (#16485) --- .../commands/command_unions.py | 2 + .../protocol_engine/commands/drop_tip.py | 67 ++++++++--- .../commands/drop_tip_in_place.py | 63 ++++++++--- .../commands/pipetting_common.py | 16 +++ .../protocol_engine/execution/tip_handler.py | 33 +++--- .../protocol_engine/commands/test_drop_tip.py | 104 ++++++++++++++++-- .../commands/test_drop_tip_in_place.py | 69 ++++++++++-- 7 files changed, 288 insertions(+), 66 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 80df6710f8b..7623cc09f68 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -11,6 +11,7 @@ from .pipetting_common import ( OverpressureError, LiquidNotFoundError, + TipPhysicallyAttachedError, ) from . import absorbance_reader @@ -713,6 +714,7 @@ # All `DefinedErrorData`s that implementations will actually return in practice. CommandDefinedErrorData = Union[ DefinedErrorData[TipPhysicallyMissingError], + DefinedErrorData[TipPhysicallyAttachedError], DefinedErrorData[OverpressureError], DefinedErrorData[LiquidNotFoundError], DefinedErrorData[GripperMovementError], diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip.py b/api/src/opentrons/protocol_engine/commands/drop_tip.py index a006bd73dd3..f4917a82195 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip.py @@ -5,10 +5,23 @@ from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal +from opentrons.protocol_engine.errors.exceptions import TipAttachedError +from opentrons.protocol_engine.resources.model_utils import ModelUtils + from ..state import update_types from ..types import DropTipWellLocation, DeckPoint -from .pipetting_common import PipetteIdMixin, DestinationPositionResult -from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from .pipetting_common import ( + PipetteIdMixin, + DestinationPositionResult, + TipPhysicallyAttachedError, +) +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + DefinedErrorData, + SuccessData, +) from ..errors.error_occurrence import ErrorOccurrence if TYPE_CHECKING: @@ -54,9 +67,12 @@ class DropTipResult(DestinationPositionResult): pass -class DropTipImplementation( - AbstractCommandImpl[DropTipParams, SuccessData[DropTipResult, None]] -): +_ExecuteReturn = ( + SuccessData[DropTipResult, None] | DefinedErrorData[TipPhysicallyAttachedError] +) + + +class DropTipImplementation(AbstractCommandImpl[DropTipParams, _ExecuteReturn]): """Drop tip command implementation.""" def __init__( @@ -64,13 +80,15 @@ def __init__( state_view: StateView, tip_handler: TipHandler, movement: MovementHandler, + model_utils: ModelUtils, **kwargs: object, ) -> None: self._state_view = state_view self._tip_handler = tip_handler self._movement_handler = movement + self._model_utils = model_utils - async def execute(self, params: DropTipParams) -> SuccessData[DropTipResult, None]: + async def execute(self, params: DropTipParams) -> _ExecuteReturn: """Move to and drop a tip using the requested pipette.""" pipette_id = params.pipetteId labware_id = params.labwareId @@ -112,17 +130,32 @@ async def execute(self, params: DropTipParams) -> SuccessData[DropTipResult, Non new_deck_point=deck_point, ) - await self._tip_handler.drop_tip(pipette_id=pipette_id, home_after=home_after) - - state_update.update_pipette_tip_state( - pipette_id=params.pipetteId, tip_geometry=None - ) - - return SuccessData( - public=DropTipResult(position=deck_point), - private=None, - state_update=state_update, - ) + try: + await self._tip_handler.drop_tip( + pipette_id=pipette_id, home_after=home_after + ) + except TipAttachedError as exception: + error = TipPhysicallyAttachedError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=exception, + ) + ], + ) + return DefinedErrorData(public=error, state_update=state_update) + else: + state_update.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) + return SuccessData( + public=DropTipResult(position=deck_point), + private=None, + state_update=state_update, + ) class DropTip(BaseCommand[DropTipParams, DropTipResult, ErrorOccurrence]): diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py index c414df86428..81b47e05c08 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py @@ -1,13 +1,21 @@ """Drop tip in place command request, result, and implementation models.""" from __future__ import annotations -from opentrons.protocol_engine.state import update_types from pydantic import Field, BaseModel from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal -from .pipetting_common import PipetteIdMixin -from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + DefinedErrorData, + SuccessData, +) +from .pipetting_common import PipetteIdMixin, TipPhysicallyAttachedError +from ..errors.exceptions import TipAttachedError from ..errors.error_occurrence import ErrorOccurrence +from ..resources.model_utils import ModelUtils +from ..state import update_types if TYPE_CHECKING: from ..execution import TipHandler @@ -35,35 +43,54 @@ class DropTipInPlaceResult(BaseModel): pass +_ExecuteReturn = ( + SuccessData[DropTipInPlaceResult, None] + | DefinedErrorData[TipPhysicallyAttachedError] +) + + class DropTipInPlaceImplementation( - AbstractCommandImpl[DropTipInPlaceParams, SuccessData[DropTipInPlaceResult, None]] + AbstractCommandImpl[DropTipInPlaceParams, _ExecuteReturn] ): """Drop tip in place command implementation.""" def __init__( self, tip_handler: TipHandler, + model_utils: ModelUtils, **kwargs: object, ) -> None: self._tip_handler = tip_handler + self._model_utils = model_utils - async def execute( - self, params: DropTipInPlaceParams - ) -> SuccessData[DropTipInPlaceResult, None]: + async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn: """Drop a tip using the requested pipette.""" - await self._tip_handler.drop_tip( - pipette_id=params.pipetteId, home_after=params.homeAfter - ) - state_update = update_types.StateUpdate() - state_update.update_pipette_tip_state( - pipette_id=params.pipetteId, tip_geometry=None - ) - - return SuccessData( - public=DropTipInPlaceResult(), private=None, state_update=state_update - ) + try: + await self._tip_handler.drop_tip( + pipette_id=params.pipetteId, home_after=params.homeAfter + ) + except TipAttachedError as exception: + error = TipPhysicallyAttachedError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=exception, + ) + ], + ) + return DefinedErrorData(public=error, state_update=state_update) + else: + state_update.update_pipette_tip_state( + pipette_id=params.pipetteId, tip_geometry=None + ) + return SuccessData( + public=DropTipInPlaceResult(), private=None, state_update=state_update + ) class DropTipInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index 29aabcb78df..3fbe2d9609d 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -168,3 +168,19 @@ class LiquidNotFoundError(ErrorOccurrence): errorCode: str = ErrorCodes.PIPETTE_LIQUID_NOT_FOUND.value.code detail: str = ErrorCodes.PIPETTE_LIQUID_NOT_FOUND.value.detail + + +class TipPhysicallyAttachedError(ErrorOccurrence): + """Returned when sensors determine that a tip remains on the pipette after a drop attempt. + + The pipette will act as if the tip was not dropped. So, you won't be able to pick + up a new tip without dropping the current one, and movement commands will assume + there is a tip hanging off the bottom of the pipette. + """ + + isDefined: bool = True + + errorType: Literal["tipPhysicallyAttached"] = "tipPhysicallyAttached" + + errorCode: str = ErrorCodes.TIP_DROP_FAILED.value.code + detail: str = ErrorCodes.TIP_DROP_FAILED.value.detail diff --git a/api/src/opentrons/protocol_engine/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index af6c2fa8c05..3fed8510a43 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -68,13 +68,19 @@ async def pick_up_tip( Returns: Tip geometry of the picked up tip. + + Raises: + TipNotAttachedError """ ... async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: - """Drop the attached tip into the named location. + """Drop the attached tip into the current location. Pipette should be in place over the destination prior to calling this method. + + Raises: + TipAttachedError """ async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: @@ -89,7 +95,12 @@ async def verify_tip_presence( expected: TipPresenceStatus, follow_singular_sensor: Optional[InstrumentProbeType] = None, ) -> None: - """Verify the expected tip presence status.""" + """Use sensors to verify that a tip is or is not physically attached. + + Raises: + TipNotAttachedError or TipAttachedError, as appropriate, if the physical + status doesn't match what was expected. + """ async def _available_for_nozzle_layout( # noqa: C901 @@ -195,7 +206,7 @@ async def available_for_nozzle_layout( front_right_nozzle: Optional[str] = None, back_left_nozzle: Optional[str] = None, ) -> Dict[str, str]: - """Returns configuration for nozzle layout to pass to configure_nozzle_layout.""" + """See documentation on abstract base class.""" if self._state_view.pipettes.get_attached_tip(pipette_id): raise CommandPreconditionViolated( message=f"Cannot configure nozzle layout of {str(self)} while it has tips attached." @@ -211,7 +222,7 @@ async def pick_up_tip( labware_id: str, well_name: str, ) -> TipGeometry: - """Pick up a tip at the current location using the Hardware API.""" + """See documentation on abstract base class.""" hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() nominal_tip_geometry = self._state_view.geometry.get_nominal_tip_geometry( @@ -249,7 +260,7 @@ async def pick_up_tip( ) async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: - """Drop a tip at the current location using the Hardware API.""" + """See documentation on abstract base class.""" hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() # Let the hardware controller handle defaulting home_after since its behavior @@ -263,7 +274,7 @@ async def drop_tip(self, pipette_id: str, home_after: Optional[bool]) -> None: await self.verify_tip_presence(pipette_id, TipPresenceStatus.ABSENT) async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: - """Tell the Hardware API that a tip is attached.""" + """See documentation on abstract base class.""" hw_mount = self._state_view.pipettes.get_mount(pipette_id).to_hw_mount() await self._hardware_api.add_tip(mount=hw_mount, tip_length=tip.length) @@ -279,7 +290,7 @@ async def add_tip(self, pipette_id: str, tip: TipGeometry) -> None: ) async def get_tip_presence(self, pipette_id: str) -> TipPresenceStatus: - """Get the tip presence status of the pipette.""" + """See documentation on abstract base class.""" try: ot3api = ensure_ot3_hardware(hardware_api=self._hardware_api) @@ -297,11 +308,7 @@ async def verify_tip_presence( expected: TipPresenceStatus, follow_singular_sensor: Optional[InstrumentProbeType] = None, ) -> None: - """Verify the expecterd tip presence status of the pipette. - - This function will raise an exception if the specified tip presence status - isn't matched. - """ + """See documentation on abstract base class.""" nozzle_configuration = ( self._state_view.pipettes.state.nozzle_configuration_by_id[pipette_id] ) @@ -385,7 +392,7 @@ async def available_for_nozzle_layout( front_right_nozzle: Optional[str] = None, back_left_nozzle: Optional[str] = None, ) -> Dict[str, str]: - """Returns configuration for nozzle layout to pass to configure_nozzle_layout.""" + """See documentation on abstract base class.""" if self._state_view.pipettes.get_attached_tip(pipette_id): raise CommandPreconditionViolated( message=f"Cannot configure nozzle layout of {str(self)} while it has tips attached." diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py index a44b6892401..4a8e32c05d0 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py @@ -1,6 +1,8 @@ """Test drop tip commands.""" +from datetime import datetime + import pytest -from decoy import Decoy +from decoy import Decoy, matchers from opentrons.protocol_engine import ( DropTipWellLocation, @@ -9,17 +11,22 @@ WellOffset, DeckPoint, ) -from opentrons.protocol_engine.state import update_types -from opentrons.protocol_engine.state.state import StateView -from opentrons.protocol_engine.execution import MovementHandler, TipHandler -from opentrons.types import Point - -from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.command import DefinedErrorData, SuccessData from opentrons.protocol_engine.commands.drop_tip import ( DropTipParams, DropTipResult, DropTipImplementation, ) +from opentrons.protocol_engine.commands.pipetting_common import ( + TipPhysicallyAttachedError, +) +from opentrons.protocol_engine.errors.exceptions import TipAttachedError +from opentrons.protocol_engine.resources.model_utils import ModelUtils +from opentrons.protocol_engine.state import update_types +from opentrons.protocol_engine.state.state import StateView +from opentrons.protocol_engine.execution import MovementHandler, TipHandler + +from opentrons.types import Point @pytest.fixture @@ -40,6 +47,12 @@ def mock_tip_handler(decoy: Decoy) -> TipHandler: return decoy.mock(cls=TipHandler) +@pytest.fixture +def mock_model_utils(decoy: Decoy) -> ModelUtils: + """Get a mock ModelUtils.""" + return decoy.mock(cls=ModelUtils) + + def test_drop_tip_params_defaults() -> None: """A drop tip should use a `WellOrigin.DROP_TIP` by default.""" default_params = DropTipParams.parse_obj( @@ -72,12 +85,14 @@ async def test_drop_tip_implementation( mock_state_view: StateView, mock_movement_handler: MovementHandler, mock_tip_handler: TipHandler, + mock_model_utils: ModelUtils, ) -> None: """A DropTip command should have an execution implementation.""" subject = DropTipImplementation( state_view=mock_state_view, movement=mock_movement_handler, tip_handler=mock_tip_handler, + model_utils=mock_model_utils, ) params = DropTipParams( @@ -141,12 +156,14 @@ async def test_drop_tip_with_alternating_locations( mock_state_view: StateView, mock_movement_handler: MovementHandler, mock_tip_handler: TipHandler, + mock_model_utils: ModelUtils, ) -> None: """It should drop tip at random location within the labware every time.""" subject = DropTipImplementation( state_view=mock_state_view, movement=mock_movement_handler, tip_handler=mock_tip_handler, + model_utils=mock_model_utils, ) params = DropTipParams( pipetteId="abc", @@ -205,3 +222,76 @@ async def test_drop_tip_with_alternating_locations( ), ), ) + + +async def test_tip_attached_error( + decoy: Decoy, + mock_state_view: StateView, + mock_movement_handler: MovementHandler, + mock_tip_handler: TipHandler, + mock_model_utils: ModelUtils, +) -> None: + """A DropTip command should have an execution implementation.""" + subject = DropTipImplementation( + state_view=mock_state_view, + movement=mock_movement_handler, + tip_handler=mock_tip_handler, + model_utils=mock_model_utils, + ) + + params = DropTipParams( + pipetteId="abc", + labwareId="123", + wellName="A3", + wellLocation=DropTipWellLocation(offset=WellOffset(x=1, y=2, z=3)), + ) + + decoy.when( + mock_state_view.pipettes.get_is_partially_configured(pipette_id="abc") + ).then_return(False) + + decoy.when( + mock_state_view.geometry.get_checked_tip_drop_location( + pipette_id="abc", + labware_id="123", + well_location=DropTipWellLocation(offset=WellOffset(x=1, y=2, z=3)), + partially_configured=False, + ) + ).then_return(WellLocation(offset=WellOffset(x=4, y=5, z=6))) + + decoy.when( + await mock_movement_handler.move_to_well( + pipette_id="abc", + labware_id="123", + well_name="A3", + well_location=WellLocation(offset=WellOffset(x=4, y=5, z=6)), + ) + ).then_return(Point(x=111, y=222, z=333)) + decoy.when( + await mock_tip_handler.drop_tip(pipette_id="abc", home_after=None) + ).then_raise(TipAttachedError("Egads!")) + + decoy.when(mock_model_utils.generate_id()).then_return("error-id") + decoy.when(mock_model_utils.get_timestamp()).then_return( + datetime(year=1, month=2, day=3) + ) + + result = await subject.execute(params) + + assert result == DefinedErrorData( + public=TipPhysicallyAttachedError.construct( + id="error-id", + createdAt=datetime(year=1, month=2, day=3), + wrappedErrors=[matchers.Anything()], + ), + state_update=update_types.StateUpdate( + pipette_location=update_types.PipetteLocationUpdate( + pipette_id="abc", + new_location=update_types.Well( + labware_id="123", + well_name="A3", + ), + new_deck_point=DeckPoint(x=111, y=222, z=333), + ), + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py index aa7854f6105..f2061c3d552 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py @@ -1,19 +1,25 @@ """Test drop tip in place commands.""" -from opentrons.protocol_engine.state.update_types import ( - PipetteTipStateUpdate, - StateUpdate, -) -import pytest -from decoy import Decoy +from datetime import datetime -from opentrons.protocol_engine.execution import TipHandler +import pytest +from decoy import Decoy, matchers -from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.pipetting_common import ( + TipPhysicallyAttachedError, +) +from opentrons.protocol_engine.commands.command import DefinedErrorData, SuccessData from opentrons.protocol_engine.commands.drop_tip_in_place import ( DropTipInPlaceParams, DropTipInPlaceResult, DropTipInPlaceImplementation, ) +from opentrons.protocol_engine.errors.exceptions import TipAttachedError +from opentrons.protocol_engine.execution import TipHandler +from opentrons.protocol_engine.resources.model_utils import ModelUtils +from opentrons.protocol_engine.state.update_types import ( + PipetteTipStateUpdate, + StateUpdate, +) @pytest.fixture @@ -22,13 +28,21 @@ def mock_tip_handler(decoy: Decoy) -> TipHandler: return decoy.mock(cls=TipHandler) -async def test_drop_tip_implementation( +@pytest.fixture +def mock_model_utils(decoy: Decoy) -> ModelUtils: + """Get a mock ModelUtils.""" + return decoy.mock(cls=ModelUtils) + + +async def test_success( decoy: Decoy, mock_tip_handler: TipHandler, + mock_model_utils: ModelUtils, ) -> None: """A DropTip command should have an execution implementation.""" - subject = DropTipInPlaceImplementation(tip_handler=mock_tip_handler) - + subject = DropTipInPlaceImplementation( + tip_handler=mock_tip_handler, model_utils=mock_model_utils + ) params = DropTipInPlaceParams(pipetteId="abc", homeAfter=False) result = await subject.execute(params) @@ -45,3 +59,36 @@ async def test_drop_tip_implementation( await mock_tip_handler.drop_tip(pipette_id="abc", home_after=False), times=1, ) + + +async def test_tip_attached_error( + decoy: Decoy, + mock_tip_handler: TipHandler, + mock_model_utils: ModelUtils, +) -> None: + """A DropTip command should have an execution implementation.""" + subject = DropTipInPlaceImplementation( + tip_handler=mock_tip_handler, model_utils=mock_model_utils + ) + + params = DropTipInPlaceParams(pipetteId="abc", homeAfter=False) + + decoy.when( + await mock_tip_handler.drop_tip(pipette_id="abc", home_after=False) + ).then_raise(TipAttachedError("Egads!")) + + decoy.when(mock_model_utils.generate_id()).then_return("error-id") + decoy.when(mock_model_utils.get_timestamp()).then_return( + datetime(year=1, month=2, day=3) + ) + + result = await subject.execute(params) + + assert result == DefinedErrorData( + public=TipPhysicallyAttachedError.construct( + id="error-id", + createdAt=datetime(year=1, month=2, day=3), + wrappedErrors=[matchers.Anything()], + ), + state_update=StateUpdate(), + ) From 37cf7385c01fc3ba61c3ad570b1e4cfdbcd7348e Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Thu, 17 Oct 2024 08:41:10 -0400 Subject: [PATCH 073/101] feat(app): Add error modals to OT-2 calibration flows (#16500) Closes RQA-3273 RESC tickets identify that a common problem for OT-2 users undergoing calibration flows is that there is not any error messaging when something goes wrong. More specifically, the app never bubbles up errors received from the server, effectively black-holing them. This commit adds an error details modal modeled after one used in drop tip wizard, implementing it for all OT-2 calibration flows. Because stale errors will display after relaunching calibration flows after an error, we clear all robotApi requests every time we launch a calibration flow. Doing so should not impact the app, since the selectors utilizing these requests were unused until now! Note: clearing just the one error request is insufficient, since it's possible for a user to receive several of the same errored responses, which results in the same stale error problem on the next calibration flow. --- app/src/assets/localization/en/anonymous.json | 13 +- app/src/assets/localization/en/branded.json | 13 +- .../localization/en/robot_calibration.json | 2 + .../__tests__/CalibrateDeck.test.tsx | 21 +++ .../organisms/Desktop/CalibrateDeck/index.tsx | 31 ++-- .../organisms/Desktop/CalibrateDeck/types.ts | 1 + .../__tests__/CalibratePipetteOffset.test.tsx | 21 +++ .../Desktop/CalibratePipetteOffset/index.tsx | 20 ++- .../Desktop/CalibratePipetteOffset/types.ts | 1 + .../useCalibratePipetteOffset.tsx | 3 +- .../__tests__/CalibrateTipLength.test.tsx | 23 ++- .../Desktop/CalibrateTipLength/index.tsx | 31 ++-- .../Desktop/CalibrateTipLength/types.ts | 1 + .../__tests__/CalibrationError.test.tsx | 50 ++++++ .../__tests__/useCalibrationError.test.ts | 99 ++++++++++++ .../Desktop/CalibrationError/index.tsx | 142 ++++++++++++++++++ .../RobotSettingsCalibration.test.tsx | 1 + .../RobotSettingsCalibration/index.tsx | 3 +- .../hooks/useDashboardCalibrateDeck.tsx | 3 +- .../hooks/useDashboardCalibratePipOffset.tsx | 3 +- .../hooks/useDashboardCalibrateTipLength.tsx | 3 +- .../redux/robot-api/__tests__/actions.test.ts | 6 + .../redux/robot-api/__tests__/reducer.test.ts | 9 ++ app/src/redux/robot-api/actions.ts | 6 +- app/src/redux/robot-api/constants.ts | 2 + app/src/redux/robot-api/reducer.ts | 10 +- app/src/redux/robot-api/types.ts | 6 +- 27 files changed, 474 insertions(+), 50 deletions(-) create mode 100644 app/src/organisms/Desktop/CalibrationError/__tests__/CalibrationError.test.tsx create mode 100644 app/src/organisms/Desktop/CalibrationError/__tests__/useCalibrationError.test.ts create mode 100644 app/src/organisms/Desktop/CalibrationError/index.tsx diff --git a/app/src/assets/localization/en/anonymous.json b/app/src/assets/localization/en/anonymous.json index 221dfd72400..5c1db25d10d 100644 --- a/app/src/assets/localization/en/anonymous.json +++ b/app/src/assets/localization/en/anonymous.json @@ -2,8 +2,8 @@ "a_robot_software_update_is_available": "A robot software update is required to run protocols with this version of the desktop app. Go to Robot", "about_flex_gripper": "About Gripper", "alternative_security_types_description": "The robot supports connecting to various enterprise access points. Connect via USB and finish setup in the desktop app.", - "attach_a_pipette_for_quick_transfer": "To create a quick transfer, you need to attach a pipette to your robot.", "attach_a_pipette": "Attach a pipette to your robot", + "attach_a_pipette_for_quick_transfer": "To create a quick transfer, you need to attach a pipette to your robot.", "calibration_block_description": "This block is a specially made tool that fits perfectly on your deck and helps with calibration.If you do not have a Calibration Block, please email support so we can send you one. In your message, be sure to include your name, company or institution name, and shipping address. While you wait for the block to arrive, you can use the flat surface on the trash bin of your robot instead.", "calibration_on_opentrons_tips_is_important": "It’s extremely important to perform this calibration using the tips and tip racks specified above, as the robot determines accuracy based on the known measurements of these tips.", "choose_what_data_to_share": "Choose what robot data to share.", @@ -23,11 +23,11 @@ "find_your_robot": "Find your robot in the Devices section of the app to install software updates.", "firmware_update_download_logs": "Contact support for assistance.", "general_error_message": "If you keep getting this message, try restarting your app and robot. If this does not resolve the issue, contact support.", + "gripper": "Gripper", "gripper_still_attached": "Gripper still attached", "gripper_successfully_attached_and_calibrated": "Gripper successfully attached and calibrated", "gripper_successfully_calibrated": "Gripper successfully calibrated", "gripper_successfully_detached": "Gripper successfully detached", - "gripper": "Gripper", "help_us_improve_send_error_report": "Help us improve your experience by sending an error report to support", "ip_description_second": "Work with your network administrator to assign a static IP address to the robot.", "learn_uninstalling": "Learn more about uninstalling the app", @@ -40,9 +40,9 @@ "new_robot_instructions": "When setting up a new robot, follow the instructions on the touchscreen. For more information, consult the Quickstart Guide for your robot.", "oem_mode_description": "Enable OEM Mode to remove all instances of Opentrons from the Flex touchscreen.", "opentrons_app_successfully_updated": "The app was successfully updated.", - "opentrons_app_update_available_variation": "An app update is available.", - "opentrons_app_update_available": "App Update Available", "opentrons_app_update": "app update", + "opentrons_app_update_available": "App Update Available", + "opentrons_app_update_available_variation": "An app update is available.", "opentrons_app_will_use_interpreter": "If specified, the app will use the Python interpreter at this path instead of the default bundled Python interpreter.", "opentrons_cares_about_privacy": "We care about your privacy. We anonymize all data and only use it to improve our products.", "opentrons_def": "Verified Definition", @@ -60,15 +60,16 @@ "secure_labware_explanation_thermocycler": "Secure your labware to the Thermocycler Module by closing its latch. Doing so ensures level and accurate plate placement for optimal results.", "send_a_protocol_to_store": "Send a protocol to the robot to get started.", "setup_instructions_description": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box.", - "share_app_analytics_description": "Help improve this product by automatically sending anonymous diagnostics and usage data.", "share_app_analytics": "Share App Analytics", + "share_app_analytics_description": "Help improve this product by automatically sending anonymous diagnostics and usage data.", "share_display_usage_description": "Data on how you interact with the robot's touchscreen.", - "share_logs_with_opentrons_description": "Help improve this product by automatically sending anonymous robot logs. These logs are used to troubleshoot robot issues and spot error trends.", "share_logs_with_opentrons": "Share robot logs", + "share_logs_with_opentrons_description": "Help improve this product by automatically sending anonymous robot logs. These logs are used to troubleshoot robot issues and spot error trends.", "show_labware_offset_snippets_description": "Only for users who need to apply labware offset data outside of the app. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", "something_seems_wrong": "There may be a problem with your pipette. Exit setup and contact support for assistance.", "storage_limit_reached_description": "Your robot has reached the limit of quick transfers that it can store. You must delete an existing quick transfer before creating a new one.", "these_are_advanced_settings": "These are advanced settings. Please do not attempt to adjust without assistance from support. Changing these settings may affect the lifespan of your pipette.These settings do not override any pipette settings defined in protocols.", + "unexpected_error": "An unexpected error has occurred. If the issue persists, contact customer support for assistance.", "update_requires_restarting_app": "Updating requires restarting the app.", "update_robot_software_description": "Bypass the auto-update process and update the robot software manually.", "update_robot_software_link": "Launch software update page", diff --git a/app/src/assets/localization/en/branded.json b/app/src/assets/localization/en/branded.json index 2b5f47373e8..c2fa52cf885 100644 --- a/app/src/assets/localization/en/branded.json +++ b/app/src/assets/localization/en/branded.json @@ -2,8 +2,8 @@ "a_robot_software_update_is_available": "A robot software update is required to run protocols with this version of the Opentrons App. Go to Robot", "about_flex_gripper": "About Flex Gripper", "alternative_security_types_description": "The Opentrons App supports connecting Flex to various enterprise access points. Connect via USB and finish setup in the app.", - "attach_a_pipette_for_quick_transfer": "To create a quick transfer, you need to attach a pipette to your Opentrons Flex.", "attach_a_pipette": "Attach a pipette to your Flex", + "attach_a_pipette_for_quick_transfer": "To create a quick transfer, you need to attach a pipette to your Opentrons Flex.", "calibration_block_description": "This block is a specially made tool that fits perfectly on your deck and helps with calibration.If you do not have a Calibration Block, please email support@opentrons.com so we can send you one. In your message, be sure to include your name, company or institution name, and shipping address. While you wait for the block to arrive, you can use the flat surface on the trash bin of your robot instead.", "calibration_on_opentrons_tips_is_important": "It’s extremely important to perform this calibration using the Opentrons tips and tip racks specified above, as the robot determines accuracy based on the known measurements of these tips.", "choose_what_data_to_share": "Choose what data to share with Opentrons.", @@ -23,11 +23,11 @@ "find_your_robot": "Find your robot in the Opentrons App to install software updates.", "firmware_update_download_logs": "Download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", "general_error_message": "If you keep getting this message, try restarting your app and robot. If this does not resolve the issue, contact Opentrons Support.", + "gripper": "Flex Gripper", "gripper_still_attached": "Flex Gripper still attached", "gripper_successfully_attached_and_calibrated": "Flex Gripper successfully attached and calibrated", "gripper_successfully_calibrated": "Flex Gripper successfully calibrated", "gripper_successfully_detached": "Flex Gripper successfully detached", - "gripper": "Flex Gripper", "help_us_improve_send_error_report": "Help us improve your experience by sending an error report to {{support_email}}", "ip_description_second": "Opentrons recommends working with your network administrator to assign a static IP address to the robot.", "learn_uninstalling": "Learn more about uninstalling the Opentrons App", @@ -40,9 +40,9 @@ "new_robot_instructions": "When setting up a new Flex, follow the instructions on the touchscreen. For more information, consult the Quickstart Guide for your robot.", "oem_mode_description": "Enable OEM Mode to remove all instances of Opentrons from the Flex touchscreen.", "opentrons_app_successfully_updated": "The Opentrons App was successfully updated.", - "opentrons_app_update_available_variation": "An Opentrons App update is available.", - "opentrons_app_update_available": "Opentrons App Update Available", "opentrons_app_update": "Opentrons App update", + "opentrons_app_update_available": "Opentrons App Update Available", + "opentrons_app_update_available_variation": "An Opentrons App update is available.", "opentrons_app_will_use_interpreter": "If specified, the Opentrons App will use the Python interpreter at this path instead of the default bundled Python interpreter.", "opentrons_cares_about_privacy": "Opentrons cares about your privacy. We anonymize all data and only use it to improve our products.", "opentrons_def": "Opentrons Definition", @@ -60,15 +60,16 @@ "secure_labware_explanation_thermocycler": "Opentrons recommends securing your labware to the Thermocycler Module by closing its latch. Doing so ensures level and accurate plate placement for optimal results.", "send_a_protocol_to_store": "Send a protocol from the Opentrons App to get started.", "setup_instructions_description": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box or scan the QR code to visit the modules section of the Opentrons Help Center.", - "share_app_analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", "share_app_analytics": "Share App Analytics with Opentrons", + "share_app_analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", "share_display_usage_description": "Data on how you interact with the touchscreen on Flex.", - "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", "share_logs_with_opentrons": "Share Robot logs with Opentrons", + "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", "show_labware_offset_snippets_description": "Only for users who need to apply labware offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", "something_seems_wrong": "There may be a problem with your pipette. Exit setup and contact Opentrons Support for assistance.", "storage_limit_reached_description": "Your Opentrons Flex has reached the limit of quick transfers that it can store. You must delete an existing quick transfer before creating a new one.", "these_are_advanced_settings": "These are advanced settings. Please do not attempt to adjust without assistance from Opentrons Support. Changing these settings may affect the lifespan of your pipette.These settings do not override any pipette settings defined in protocols.", + "unexpected_error": "An unexpected error has occurred. If the issue persists, contact Opentrons Support for assistance.", "update_requires_restarting_app": "Updating requires restarting the Opentrons App.", "update_robot_software_description": "Bypass the Opentrons App auto-update process and update the robot software manually.", "update_robot_software_link": "Launch Opentrons software update page", diff --git a/app/src/assets/localization/en/robot_calibration.json b/app/src/assets/localization/en/robot_calibration.json index 723e1526adc..a8333470d94 100644 --- a/app/src/assets/localization/en/robot_calibration.json +++ b/app/src/assets/localization/en/robot_calibration.json @@ -53,6 +53,8 @@ "download_calibration_data_unavailable": "No calibration data available.", "download_calibration_title": "Download Calibration Data", "download_details": "Download details JSON Calibration Check summary", + "error": "Error", + "exit": "Exit", "finish": "Finish", "get_started": "Get started", "good_calibration": "Good calibration", diff --git a/app/src/organisms/Desktop/CalibrateDeck/__tests__/CalibrateDeck.test.tsx b/app/src/organisms/Desktop/CalibrateDeck/__tests__/CalibrateDeck.test.tsx index ac5e8822416..3f90064c03c 100644 --- a/app/src/organisms/Desktop/CalibrateDeck/__tests__/CalibrateDeck.test.tsx +++ b/app/src/organisms/Desktop/CalibrateDeck/__tests__/CalibrateDeck.test.tsx @@ -9,6 +9,10 @@ import { i18n } from '/app/i18n' import * as Sessions from '/app/redux/sessions' import { mockDeckCalibrationSessionAttributes } from '/app/redux/sessions/__fixtures__' import { CalibrateDeck } from '../index' +import { + useCalibrationError, + CalibrationError, +} from '/app/organisms/Desktop/CalibrationError' import type { DeckCalibrationStep } from '/app/redux/sessions/types' import type { DispatchRequestsType } from '/app/redux/robot-api' @@ -16,6 +20,7 @@ import type { DispatchRequestsType } from '/app/redux/robot-api' vi.mock('/app/redux/sessions/selectors') vi.mock('/app/redux/robot-api/selectors') vi.mock('/app/redux/config') +vi.mock('/app/organisms/Desktop/CalibrationError') vi.mock('@opentrons/shared-data', async importOriginal => { const actual = await importOriginal() return { @@ -50,6 +55,7 @@ describe('CalibrateDeck', () => { dispatchRequests={dispatchRequests} showSpinner={showSpinner} isJogging={isJogging} + requestIds={[]} />, { i18nInstance: i18n } ) @@ -85,6 +91,10 @@ describe('CalibrateDeck', () => { beforeEach(() => { dispatchRequests = vi.fn() vi.mocked(getDeckDefinitions).mockReturnValue({}) + vi.mocked(useCalibrationError).mockReturnValue(null) + vi.mocked(CalibrationError).mockReturnValue( +
    MOCK_CALIBRATION_ERROR
    + ) }) SPECS.forEach(spec => { @@ -182,4 +192,15 @@ describe('CalibrateDeck', () => { }) ) }) + + it('renders an error modal if there is an error', () => { + vi.mocked(useCalibrationError).mockReturnValue({ + title: 'test', + subText: 'test', + }) + + render() + + screen.getByText('MOCK_CALIBRATION_ERROR') + }) }) diff --git a/app/src/organisms/Desktop/CalibrateDeck/index.tsx b/app/src/organisms/Desktop/CalibrateDeck/index.tsx index fc31cc28992..783b3d0ba13 100644 --- a/app/src/organisms/Desktop/CalibrateDeck/index.tsx +++ b/app/src/organisms/Desktop/CalibrateDeck/index.tsx @@ -22,6 +22,10 @@ import { } from '/app/organisms/Desktop/CalibrationPanels' import { WizardHeader } from '/app/molecules/WizardHeader' import { getTopPortalEl } from '/app/App/portal' +import { + CalibrationError, + useCalibrationError, +} from '/app/organisms/Desktop/CalibrationError' import type { Mount } from '@opentrons/components' import type { @@ -57,19 +61,18 @@ const STEPS_IN_ORDER: CalibrationSessionStep[] = [ Sessions.DECK_STEP_CALIBRATION_COMPLETE, ] -export function CalibrateDeck( - props: CalibrateDeckParentProps -): JSX.Element | null { +export function CalibrateDeck({ + session, + robotName, + dispatchRequests, + requestIds, + showSpinner, + isJogging, + exitBeforeDeckConfigCompletion, + offsetInvalidationHandler, +}: CalibrateDeckParentProps): JSX.Element | null { const { t } = useTranslation('robot_calibration') - const { - session, - robotName, - dispatchRequests, - showSpinner, - isJogging, - exitBeforeDeckConfigCompletion, - offsetInvalidationHandler, - } = props + const { currentStep, instrument, labware, supportedCommands } = session?.details || {} @@ -84,6 +87,8 @@ export function CalibrateDeck( cleanUpAndExit() }, true) + const errorInfo = useCalibrationError(requestIds, session?.id) + const isMulti = React.useMemo(() => { const spec = instrument && getPipetteModelSpecs(instrument.model) return spec ? spec.channels > 1 : false @@ -160,6 +165,8 @@ export function CalibrateDeck( sessionType: t('deck_calibration'), })} /> + ) : errorInfo != null ? ( + ) : ( diff --git a/app/src/organisms/Desktop/CalibratePipetteOffset/__tests__/CalibratePipetteOffset.test.tsx b/app/src/organisms/Desktop/CalibratePipetteOffset/__tests__/CalibratePipetteOffset.test.tsx index 7f85d0ad109..15117cac92f 100644 --- a/app/src/organisms/Desktop/CalibratePipetteOffset/__tests__/CalibratePipetteOffset.test.tsx +++ b/app/src/organisms/Desktop/CalibratePipetteOffset/__tests__/CalibratePipetteOffset.test.tsx @@ -10,6 +10,10 @@ import { mockPipetteOffsetCalibrationSessionAttributes } from '/app/redux/sessio import { CalibratePipetteOffset } from '../index' import type { PipetteOffsetCalibrationStep } from '/app/redux/sessions/types' import type { DispatchRequestsType } from '/app/redux/robot-api' +import { + useCalibrationError, + CalibrationError, +} from '/app/organisms/Desktop/CalibrationError' vi.mock('@opentrons/shared-data', async importOriginal => { const actual = await importOriginal() @@ -20,6 +24,7 @@ vi.mock('@opentrons/shared-data', async importOriginal => { }) vi.mock('/app/redux/sessions/selectors') vi.mock('/app/redux/robot-api/selectors') +vi.mock('/app/organisms/Desktop/CalibrationError') vi.mock('/app/redux/config') interface CalibratePipetteOffsetSpec { @@ -46,6 +51,7 @@ describe('CalibratePipetteOffset', () => { dispatchRequests={dispatchRequests} showSpinner={showSpinner} isJogging={isJogging} + requestIds={[]} />, { i18nInstance: i18n } ) @@ -73,6 +79,10 @@ describe('CalibratePipetteOffset', () => { beforeEach(() => { dispatchRequests = vi.fn() when(vi.mocked(getDeckDefinitions)).calledWith().thenReturn({}) + vi.mocked(useCalibrationError).mockReturnValue(null) + vi.mocked(CalibrationError).mockReturnValue( +
    MOCK_CALIBRATION_ERROR
    + ) mockPipOffsetCalSession = { id: 'fake_session_id', @@ -175,4 +185,15 @@ describe('CalibratePipetteOffset', () => { }) ) }) + + it('renders an error modal if there is an error', () => { + vi.mocked(useCalibrationError).mockReturnValue({ + title: 'test', + subText: 'test', + }) + + render() + + screen.getByText('MOCK_CALIBRATION_ERROR') + }) }) diff --git a/app/src/organisms/Desktop/CalibratePipetteOffset/index.tsx b/app/src/organisms/Desktop/CalibratePipetteOffset/index.tsx index 42a31213b43..dce310da9c6 100644 --- a/app/src/organisms/Desktop/CalibratePipetteOffset/index.tsx +++ b/app/src/organisms/Desktop/CalibratePipetteOffset/index.tsx @@ -22,6 +22,10 @@ import { } from '/app/organisms/Desktop/CalibrationPanels' import { WizardHeader } from '/app/molecules/WizardHeader' import { getTopPortalEl } from '/app/App/portal' +import { + CalibrationError, + useCalibrationError, +} from '/app/organisms/Desktop/CalibrationError' import type { Mount } from '@opentrons/components' import type { @@ -54,11 +58,15 @@ const STEPS_IN_ORDER: CalibrationSessionStep[] = [ Sessions.PIP_OFFSET_STEP_CALIBRATION_COMPLETE, ] -export function CalibratePipetteOffset( - props: CalibratePipetteOffsetParentProps -): JSX.Element | null { +export function CalibratePipetteOffset({ + session, + robotName, + dispatchRequests, + showSpinner, + isJogging, + requestIds, +}: CalibratePipetteOffsetParentProps): JSX.Element | null { const { t } = useTranslation('robot_calibration') - const { session, robotName, dispatchRequests, showSpinner, isJogging } = props const { currentStep, instrument, labware, supportedCommands } = session?.details ?? {} @@ -73,6 +81,8 @@ export function CalibratePipetteOffset( cleanUpAndExit() }, true) + const errorInfo = useCalibrationError(requestIds, session?.id) + const tipRack: CalibrationLabware | null = labware != null ? labware.find(l => l.isTiprack) ?? null : null const calBlock: CalibrationLabware | null = @@ -146,6 +156,8 @@ export function CalibratePipetteOffset( sessionType: t('pipette_offset_calibration'), })} /> + ) : errorInfo != null ? ( + ) : ( { if ( dispatchedAction.type === Sessions.ENSURE_SESSION && @@ -175,6 +175,7 @@ export function useCalibratePipetteOffset( showSpinner={startingSession || showSpinner} dispatchRequests={dispatchRequests} isJogging={isJogging} + requestIds={requestIds} /> ), getTopPortalEl() diff --git a/app/src/organisms/Desktop/CalibrateTipLength/__tests__/CalibrateTipLength.test.tsx b/app/src/organisms/Desktop/CalibrateTipLength/__tests__/CalibrateTipLength.test.tsx index 8753a346cbf..7324f182f66 100644 --- a/app/src/organisms/Desktop/CalibrateTipLength/__tests__/CalibrateTipLength.test.tsx +++ b/app/src/organisms/Desktop/CalibrateTipLength/__tests__/CalibrateTipLength.test.tsx @@ -9,8 +9,12 @@ import { getDeckDefinitions } from '@opentrons/shared-data' import { i18n } from '/app/i18n' import * as Sessions from '/app/redux/sessions' import { mockTipLengthCalibrationSessionAttributes } from '/app/redux/sessions/__fixtures__' - import { CalibrateTipLength } from '../index' +import { + useCalibrationError, + CalibrationError, +} from '/app/organisms/Desktop/CalibrationError' + import type { TipLengthCalibrationStep } from '/app/redux/sessions/types' vi.mock('@opentrons/shared-data', async importOriginal => { @@ -23,6 +27,7 @@ vi.mock('@opentrons/shared-data', async importOriginal => { vi.mock('/app/redux/sessions/selectors') vi.mock('/app/redux/robot-api/selectors') vi.mock('/app/redux/config') +vi.mock('/app/organisms/Desktop/CalibrationError') interface CalibrateTipLengthSpec { heading: string @@ -50,6 +55,7 @@ describe('CalibrateTipLength', () => { dispatchRequests={dispatchRequests} showSpinner={showSpinner} isJogging={isJogging} + requestIds={[]} />, { i18nInstance: i18n } ) @@ -76,6 +82,10 @@ describe('CalibrateTipLength', () => { beforeEach(() => { when(vi.mocked(getDeckDefinitions)).calledWith().thenReturn({}) + vi.mocked(useCalibrationError).mockReturnValue(null) + vi.mocked(CalibrationError).mockReturnValue( +
    MOCK_CALIBRATION_ERROR
    + ) }) afterEach(() => { vi.resetAllMocks() @@ -176,4 +186,15 @@ describe('CalibrateTipLength', () => { }) ) }) + + it('renders an error modal if there is an error', () => { + vi.mocked(useCalibrationError).mockReturnValue({ + title: 'test', + subText: 'test', + }) + + render() + + screen.getByText('MOCK_CALIBRATION_ERROR') + }) }) diff --git a/app/src/organisms/Desktop/CalibrateTipLength/index.tsx b/app/src/organisms/Desktop/CalibrateTipLength/index.tsx index 14ab84d2ef6..02ac74e09d5 100644 --- a/app/src/organisms/Desktop/CalibrateTipLength/index.tsx +++ b/app/src/organisms/Desktop/CalibrateTipLength/index.tsx @@ -23,7 +23,10 @@ import { } from '/app/organisms/Desktop/CalibrationPanels' import { WizardHeader } from '/app/molecules/WizardHeader' import { getTopPortalEl } from '/app/App/portal' - +import { + CalibrationError, + useCalibrationError, +} from '/app/organisms/Desktop/CalibrationError' import slotOneRemoveBlockAsset from '/app/assets/videos/tip-length-cal/Slot_1_Remove_CalBlock_(330x260)REV1.webm' import slotThreeRemoveBlockAsset from '/app/assets/videos/tip-length-cal/Slot_3_Remove_CalBlock_(330x260)REV1.webm' @@ -60,19 +63,17 @@ const STEPS_IN_ORDER: CalibrationSessionStep[] = [ Sessions.TIP_LENGTH_STEP_CALIBRATION_COMPLETE, ] -export function CalibrateTipLength( - props: CalibrateTipLengthParentProps -): JSX.Element | null { +export function CalibrateTipLength({ + session, + robotName, + showSpinner, + dispatchRequests, + requestIds, + isJogging, + offsetInvalidationHandler, + allowChangeTipRack = false, +}: CalibrateTipLengthParentProps): JSX.Element | null { const { t } = useTranslation('robot_calibration') - const { - session, - robotName, - showSpinner, - dispatchRequests, - isJogging, - offsetInvalidationHandler, - allowChangeTipRack = false, - } = props const { currentStep, instrument, labware, supportedCommands } = session?.details ?? {} @@ -90,6 +91,8 @@ export function CalibrateTipLength( const calBlock: CalibrationLabware | null = labware != null ? labware.find(l => !l.isTiprack) ?? null : null + const errorInfo = useCalibrationError(requestIds, session?.id) + function sendCommands(...commands: SessionCommandParams[]): void { if (session?.id != null && !isJogging) { const sessionCommandActions = commands.map(c => @@ -160,6 +163,8 @@ export function CalibrateTipLength( sessionType: t('tip_length_calibration'), })} /> + ) : errorInfo != null ? ( + ) : ( { + let props: React.ComponentProps + + beforeEach(() => { + props = { + title: 'Error Title', + subText: 'Error Description', + onClose: vi.fn(), + } + }) + + const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] + } + + it('displays expected copy', () => { + render(props) + + screen.getByText('Error Title') + screen.getByText('Error Description') + }) + + it('calls onClose when exit button is clicked', () => { + render(props) + + fireEvent.click(screen.getByRole('button', { name: 'Exit' })) + + expect(props.onClose).toHaveBeenCalled() + }) + + it('disables the exit button after it is clicked', () => { + render(props) + + const exitButton = screen.getByRole('button', { name: 'Exit' }) + fireEvent.click(exitButton) + + expect(exitButton).toBeDisabled() + }) +}) diff --git a/app/src/organisms/Desktop/CalibrationError/__tests__/useCalibrationError.test.ts b/app/src/organisms/Desktop/CalibrationError/__tests__/useCalibrationError.test.ts new file mode 100644 index 00000000000..ee86cde480e --- /dev/null +++ b/app/src/organisms/Desktop/CalibrationError/__tests__/useCalibrationError.test.ts @@ -0,0 +1,99 @@ +import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useDispatch, useSelector } from 'react-redux' + +import { getRequests, dismissAllRequests } from '/app/redux/robot-api' +import { useCalibrationError } from '/app/organisms/Desktop/CalibrationError' + +vi.mock('react-redux', () => ({ + useDispatch: vi.fn(), + useSelector: vi.fn(), +})) + +vi.mock('/app/redux/robot-api', () => ({ + dismissAllRequests: vi.fn(), + getRequests: vi.fn(), +})) + +describe('useCalibrationError', () => { + const mockDispatch = vi.fn() + const mockRequestIds = ['req1', 'req2'] + const mockSessionId = 'session1' + + beforeEach(() => { + vi.mocked(useDispatch).mockReturnValue(mockDispatch) + vi.mocked(useSelector).mockImplementation(selector => selector({} as any)) + vi.mocked(getRequests).mockReturnValue([]) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should return null when there are no errored requests', () => { + const { result } = renderHook(() => + useCalibrationError(mockRequestIds, mockSessionId) + ) + expect(result.current).toBeNull() + }) + + it('should dispatch dismissAllRequests when sessionId is provided', () => { + renderHook(() => useCalibrationError(mockRequestIds, mockSessionId)) + expect(mockDispatch).toHaveBeenCalledWith(dismissAllRequests()) + }) + + it('should return error info when there is an errored request with errors', () => { + vi.mocked(getRequests).mockReturnValue([ + { + status: 'failure', + error: { + errors: [{ title: 'Test Error', detail: 'Test Detail' }], + }, + }, + ] as any) + + const { result } = renderHook(() => + useCalibrationError(mockRequestIds, mockSessionId) + ) + expect(result.current).toEqual({ + title: 'Test Error', + subText: 'Test Detail', + }) + }) + + it('should return error info when there is an errored request with message', () => { + vi.mocked(getRequests).mockReturnValue([ + { + status: 'failure', + error: { + message: 'Test Message', + }, + }, + ] as any) + + const { result } = renderHook(() => + useCalibrationError(mockRequestIds, mockSessionId) + ) + expect(result.current).toEqual({ + title: 'robot_calibration:error', + subText: 'Test Message', + }) + }) + + it('should return default error info when error details are missing', () => { + vi.mocked(getRequests).mockReturnValue([ + { + status: 'failure', + error: {}, + }, + ] as any) + + const { result } = renderHook(() => + useCalibrationError(mockRequestIds, mockSessionId) + ) + expect(result.current).toEqual({ + title: 'robot_calibration:error', + subText: 'branded:unexpected_error', + }) + }) +}) diff --git a/app/src/organisms/Desktop/CalibrationError/index.tsx b/app/src/organisms/Desktop/CalibrationError/index.tsx new file mode 100644 index 00000000000..b3bfc7d1745 --- /dev/null +++ b/app/src/organisms/Desktop/CalibrationError/index.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' +import { useDispatch, useSelector } from 'react-redux' + +import { + DISPLAY_FLEX, + DIRECTION_COLUMN, + SPACING, + ALIGN_CENTER, + COLORS, + Icon, + Flex, + StyledText, + JUSTIFY_CENTER, + JUSTIFY_FLEX_END, + TEXT_ALIGN_CENTER, + AlertPrimaryButton, +} from '@opentrons/components' + +import { dismissAllRequests, getRequests } from '/app/redux/robot-api' + +import type { State } from '/app/redux/types' + +export interface CalibrationErrorInfo { + title: string + subText: string +} + +export type UseCalibrationErrorInfoResult = CalibrationErrorInfo | null + +// Returns relevant copy derived from the error response, if any. +export function useCalibrationError( + requestIds: string[], + sessionId: string | undefined +): UseCalibrationErrorInfoResult { + const { t } = useTranslation(['robot_calibration', 'branded']) + const dispatch = useDispatch() + + // Dismiss all network requests during a unique session to prevent stale error state. + useEffect(() => { + if (sessionId != null) { + dispatch(dismissAllRequests()) + } + }, [sessionId]) + + const reqs = useSelector((state: State) => { + return getRequests(state, requestIds) + }) + const erroredReqs = reqs.filter(req => req?.status === 'failure') + + if (erroredReqs.length > 0) { + const erroredReq = erroredReqs[0] + if (erroredReq != null && erroredReq.status === 'failure') { + if ('errors' in erroredReq.error) { + const title = + erroredReq.error.errors[0].title ?? + (t('robot_calibration:error') as string) + const subText = + erroredReq.error.errors[0].detail ?? + (t('branded:unexpected_error') as string) + + return { title, subText } + } else if ('message' in erroredReq.error) { + const title = t('robot_calibration:error') + const subText = + erroredReq.error.message ?? (t('branded:unexpected_error') as string) + + return { title, subText } + } else { + return { + title: t('robot_calibration:error'), + subText: t('branded:unexpected_error'), + } + } + } + } + + return null +} + +export type CalibrationErrorProps = CalibrationErrorInfo & { + onClose: () => void +} + +export function CalibrationError({ + subText, + title, + onClose, +}: CalibrationErrorProps): JSX.Element { + const [isClosing, setIsClosing] = useState(false) + const { t } = useTranslation('robot_calibration') + + return ( + + + + + {title} + + + {subText} + + + + { + setIsClosing(true) + onClose() + }} + disabled={isClosing} + > + {t('exit')} + + + + ) +} + +const CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + padding: ${SPACING.spacing32}; +` + +const CONTENT_CONTAINER_STYLE = css` + display: ${DISPLAY_FLEX}; + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing16}; + padding: ${SPACING.spacing40} ${SPACING.spacing16}; + align-items: ${ALIGN_CENTER}; + justify-content: ${JUSTIFY_CENTER}; + text-align: ${TEXT_ALIGN_CENTER}; + margin-top: ${SPACING.spacing16}; +` + +const ICON_STYLE = css` + width: 40px; + height: 40px; +` diff --git a/app/src/organisms/Desktop/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx b/app/src/organisms/Desktop/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx index f883fcfe4c8..9d072cf1df0 100644 --- a/app/src/organisms/Desktop/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx +++ b/app/src/organisms/Desktop/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx @@ -59,6 +59,7 @@ vi.mock('../RobotSettingsGripperCalibration') vi.mock('../RobotSettingsPipetteOffsetCalibration') vi.mock('../RobotSettingsTipLengthCalibration') vi.mock('../RobotSettingsModuleCalibration') +vi.mock('/app/organisms/Desktop/CalibrationError') const mockAttachedPipettes: AttachedPipettesByMount = { left: mockAttachedPipette, diff --git a/app/src/organisms/Desktop/RobotSettingsCalibration/index.tsx b/app/src/organisms/Desktop/RobotSettingsCalibration/index.tsx index 76e5d8f9171..7516a262172 100644 --- a/app/src/organisms/Desktop/RobotSettingsCalibration/index.tsx +++ b/app/src/organisms/Desktop/RobotSettingsCalibration/index.tsx @@ -93,7 +93,7 @@ export function RobotSettingsCalibration({ dispatch(Sessions.fetchAllSessions(robotName)) }, [dispatch, robotName]) - const [dispatchRequests] = RobotApi.useDispatchApiRequests( + const [dispatchRequests, requestIds] = RobotApi.useDispatchApiRequests( dispatchedAction => { if (dispatchedAction.type === Sessions.ENSURE_SESSION) { createRequestId.current = @@ -263,6 +263,7 @@ export function RobotSettingsCalibration({ dispatchRequests={dispatchRequests} showSpinner={isPending} isJogging={isJogging} + requestIds={requestIds} /> {createStatus === RobotApi.PENDING ? ( { if (dispatchedAction.type === Sessions.ENSURE_SESSION) { createRequestId.current = @@ -113,6 +113,7 @@ export function useDashboardCalibrateDeck( robotName={robotName} showSpinner={showSpinner} dispatchRequests={dispatchRequests} + requestIds={requestIds} isJogging={isJogging} exitBeforeDeckConfigCompletion={exitBeforeDeckConfigCompletion} offsetInvalidationHandler={invalidateHandlerRef.current} diff --git a/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibratePipOffset.tsx b/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibratePipOffset.tsx index 029065ea5a7..09da025af71 100644 --- a/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibratePipOffset.tsx +++ b/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibratePipOffset.tsx @@ -42,7 +42,7 @@ export function useDashboardCalibratePipOffset( } ) - const [dispatchRequests] = RobotApi.useDispatchApiRequests( + const [dispatchRequests, requestIds] = RobotApi.useDispatchApiRequests( dispatchedAction => { if ( dispatchedAction.type === Sessions.ENSURE_SESSION && @@ -166,6 +166,7 @@ export function useDashboardCalibratePipOffset( showSpinner={startingSession || showSpinner} dispatchRequests={dispatchRequests} isJogging={isJogging} + requestIds={requestIds} /> ), getTopPortalEl() diff --git a/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibrateTipLength.tsx b/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibrateTipLength.tsx index deddb78c198..d4bd697eee1 100644 --- a/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibrateTipLength.tsx +++ b/app/src/pages/Desktop/Devices/CalibrationDashboard/hooks/useDashboardCalibrateTipLength.tsx @@ -46,7 +46,7 @@ export function useDashboardCalibrateTipLength( const sessionType = Sessions.SESSION_TYPE_TIP_LENGTH_CALIBRATION - const [dispatchRequests] = RobotApi.useDispatchApiRequests( + const [dispatchRequests, requestIds] = RobotApi.useDispatchApiRequests( dispatchedAction => { if ( dispatchedAction.type === Sessions.ENSURE_SESSION && @@ -171,6 +171,7 @@ export function useDashboardCalibrateTipLength( robotName={robotName} showSpinner={showSpinner} dispatchRequests={dispatchRequests} + requestIds={requestIds} isJogging={isJogging} offsetInvalidationHandler={invalidateHandlerRef.current} allowChangeTipRack={sessionParams.current?.tipRackDefinition == null} diff --git a/app/src/redux/robot-api/__tests__/actions.test.ts b/app/src/redux/robot-api/__tests__/actions.test.ts index 1594a8f1ea8..0bbc72376c8 100644 --- a/app/src/redux/robot-api/__tests__/actions.test.ts +++ b/app/src/redux/robot-api/__tests__/actions.test.ts @@ -21,6 +21,12 @@ describe('robot admin actions', () => { payload: { requestId: 'requestId' }, }, }, + { + name: 'robotApi:DISMISS_ALL_REQUESTS', + creator: Actions.dismissAllRequests, + args: [], + expected: { type: 'robotApi:DISMISS_ALL_REQUESTS' }, + }, ] SPECS.forEach(spec => { diff --git a/app/src/redux/robot-api/__tests__/reducer.test.ts b/app/src/redux/robot-api/__tests__/reducer.test.ts index d4c64fa3852..34288127e42 100644 --- a/app/src/redux/robot-api/__tests__/reducer.test.ts +++ b/app/src/redux/robot-api/__tests__/reducer.test.ts @@ -66,6 +66,15 @@ const SPECS: ReducerSpec[] = [ def: { status: 'pending' }, }, }, + { + name: 'handles a dismiss all request action', + state: { abc: mockFailedRequestState, def: { status: 'pending' } }, + action: { + type: 'robotApi:DISMISS_ALL_REQUESTS', + payload: { requestId: 'abc' }, + }, + expected: {}, + }, ] describe('robotApiReducer', () => { diff --git a/app/src/redux/robot-api/actions.ts b/app/src/redux/robot-api/actions.ts index a41a232f702..47ebb940317 100644 --- a/app/src/redux/robot-api/actions.ts +++ b/app/src/redux/robot-api/actions.ts @@ -1,4 +1,4 @@ -import { DISMISS_REQUEST } from './constants' +import { DISMISS_ALL_REQUESTS, DISMISS_REQUEST } from './constants' import type * as Types from './types' export const dismissRequest = ( @@ -9,3 +9,7 @@ export const dismissRequest = ( payload: { requestId }, } } + +export const dismissAllRequests = (): Types.DismissAllRequestsAction => { + return { type: DISMISS_ALL_REQUESTS } +} diff --git a/app/src/redux/robot-api/constants.ts b/app/src/redux/robot-api/constants.ts index 5c667c860ac..77e3e8a8c02 100644 --- a/app/src/redux/robot-api/constants.ts +++ b/app/src/redux/robot-api/constants.ts @@ -12,3 +12,5 @@ export const FAILURE: 'failure' = 'failure' export const DISMISS_REQUEST: 'robotApi:DISMISS_REQUEST' = 'robotApi:DISMISS_REQUEST' +export const DISMISS_ALL_REQUESTS: 'robotApi:DISMISS_ALL_REQUESTS' = + 'robotApi:DISMISS_ALL_REQUESTS' diff --git a/app/src/redux/robot-api/reducer.ts b/app/src/redux/robot-api/reducer.ts index 88c7088ce90..325fbbf2fa7 100644 --- a/app/src/redux/robot-api/reducer.ts +++ b/app/src/redux/robot-api/reducer.ts @@ -4,7 +4,13 @@ // be fairly broken; make sure you have unit tests in place when changing import omit from 'lodash/omit' -import { PENDING, SUCCESS, FAILURE, DISMISS_REQUEST } from './constants' +import { + PENDING, + SUCCESS, + FAILURE, + DISMISS_REQUEST, + DISMISS_ALL_REQUESTS, +} from './constants' import type { Action } from '../types' import type { RobotApiState } from './types' @@ -15,6 +21,8 @@ export function robotApiReducer( ): RobotApiState { if (action.type === DISMISS_REQUEST) { return omit(state, action.payload.requestId) + } else if (action.type === DISMISS_ALL_REQUESTS) { + return {} } // @ts-expect-error(sa, 2021-05-17): type guard action.meta const meta = action.meta ? action.meta : {} diff --git a/app/src/redux/robot-api/types.ts b/app/src/redux/robot-api/types.ts index 92107e92e6c..a07124a9d3f 100644 --- a/app/src/redux/robot-api/types.ts +++ b/app/src/redux/robot-api/types.ts @@ -56,7 +56,11 @@ export interface DismissRequestAction { payload: { requestId: string } } -export type RobotApiAction = DismissRequestAction +export interface DismissAllRequestsAction { + type: 'robotApi:DISMISS_ALL_REQUESTS' +} + +export type RobotApiAction = DismissRequestAction | DismissAllRequestsAction // parameterized response type // DataT parameter must be a subtype of RobotApiV2ResponseData From 61e886d99eb0c919e49964cb974dac371cf101a7 Mon Sep 17 00:00:00 2001 From: syao1226 <146495172+syao1226@users.noreply.github.com> Date: Thu, 17 Oct 2024 10:44:42 -0400 Subject: [PATCH 074/101] feat(protocol-designer): wire up rename step (#16437) re AUTH-805 # Overview Update the rename step feature to allow users to rename a step and add step notes when they click the rename button on step forms. ## Test Plan and Hands on Testing - Go to Protocol steps and add a step - Click the rename button and confirm that a modal appears - Save the new step name and step notes - Verify that the step name is renamed (the step details will be added below the command summary as the next task). - Ensure it matches the [design](https://www.figma.com/design/WbkiUyU8VhtKz0JSuIFA45/Feature%3A-Protocol-Designer-Phase-1?node-id=11219-264190&t=1OIUHns25vPqdMDK-4) ## Changelog - Created a new `RenameStepModal` under the `organisms` directory to display a modal for users to update the step name and step details - Added `renameStep()` and `RenameStepAction` interface in `labware-ingred/actions/actions.ts` to handle changes - Updated `StepFormToolbox` to handle rename button click - Added a unit test for `RenameStepModal` ## Review requests - I used `CHANGE_FORM_INPUT` as the type for the `RenameStepAction` interface, taking it from the old PD `ChangeFormInputAction` to handle `stepName` and `stepDetails` updates. Am I supposed to use this, or should I create a new type, add it to `labware-ingred/actions/actions.ts`, and use that instead? ## Risk assessment --------- Co-authored-by: shiyaochen Co-authored-by: shiyaochen --- .../localization/en/protocol_steps.json | 1 + .../src/assets/localization/en/shared.json | 1 + .../src/labware-ingred/actions/actions.ts | 17 +++ .../__tests__/RenameStepModal.test.tsx | 64 +++++++++ .../src/organisms/RenameStepModal/index.tsx | 129 ++++++++++++++++++ .../StepForm/StepFormToolbox.tsx | 15 +- .../StepForm/StepTools/MagnetTools/index.tsx | 2 - .../src/step-forms/reducers/index.ts | 13 ++ 8 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 protocol-designer/src/organisms/RenameStepModal/__tests__/RenameStepModal.test.tsx create mode 100644 protocol-designer/src/organisms/RenameStepModal/index.tsx diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 01da3c1b225..6adfae63960 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -59,6 +59,7 @@ "protocol_steps": "Protocol steps", "protocol_timeline": "Protocol timeline", "rename": "Rename", + "rename_error": "Oops! Your step name is too long.", "save_errors": "{{stepType}} has been saved with {{numErrors}} error(s)", "save_no_errors": "{{stepType}} has been saved", "save_warnings": "{{stepType}} has been saved with {{numWarnings}} warning(s)", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 382314e4052..e6456184334 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -80,6 +80,7 @@ "message_uses_standard_namespace": "This labware definition uses the Opentrons standard labware namespace. Change the namespace if it is custom, or use the standard labware in your protocol.", "mismatched": "The new labware has a different arrangement of wells than the labware it is replacing. Clicking Overwrite will deselect all wells in any existing steps that use this labware. You will have to edit each of those steps and select new wells.", "module": "Module", + "name_step": "Name step", "next": "next", "ninety_six_channel": "96-Channel", "no_hints_to_restore": "No hints to restore", diff --git a/protocol-designer/src/labware-ingred/actions/actions.ts b/protocol-designer/src/labware-ingred/actions/actions.ts index b9a36aa416c..2064751568b 100644 --- a/protocol-designer/src/labware-ingred/actions/actions.ts +++ b/protocol-designer/src/labware-ingred/actions/actions.ts @@ -1,8 +1,10 @@ import { createAction } from 'redux-actions' import { selectors } from '../selectors' +import type { StepFieldName } from '../../form-types' import type { DeckSlot, ThunkAction } from '../../types' import type { Fixture, IngredInputs } from '../types' import type { CutoutId, ModuleModel } from '@opentrons/shared-data' + // ===== Labware selector actions ===== export interface OpenAddLabwareModalAction { type: 'OPEN_ADD_LABWARE_MODAL' @@ -295,3 +297,18 @@ export const generateNewProtocol: ( type: 'GENERATE_NEW_PROTOCOL', payload, }) + +export interface RenameStepAction { + type: 'CHANGE_STEP_DETAILS' + payload: { + stepId?: string + update: Partial> + } +} + +export const renameStep: ( + payload: RenameStepAction['payload'] +) => RenameStepAction = payload => ({ + type: 'CHANGE_STEP_DETAILS', + payload, +}) diff --git a/protocol-designer/src/organisms/RenameStepModal/__tests__/RenameStepModal.test.tsx b/protocol-designer/src/organisms/RenameStepModal/__tests__/RenameStepModal.test.tsx new file mode 100644 index 00000000000..3bdc35028ae --- /dev/null +++ b/protocol-designer/src/organisms/RenameStepModal/__tests__/RenameStepModal.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, beforeEach, vi, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../assets/localization' +import { PAUSE_UNTIL_RESUME } from '../../../constants' +import { renameStep } from '../../../labware-ingred/actions' +import { RenameStepModal } from '..' + +vi.mock('../../../labware-ingred/actions') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('EditNickNameModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onClose: vi.fn(), + formData: { + stepType: 'pause', + id: 'test_id', + pauseAction: PAUSE_UNTIL_RESUME, + description: 'some description', + pauseMessage: 'some message', + stepName: 'pause', + stepDetails: '', + }, + } + }) + it('renders the text and add a step name and a step notes', () => { + render(props) + screen.getByText('Name step') + screen.getByText('Step Name') + screen.getByText('Step Notes') + + fireEvent.click(screen.getByText('Cancel')) + expect(props.onClose).toHaveBeenCalled() + + const stepName = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(stepName, { target: { value: 'mockStepName' } }) + + const stepDetails = screen.getAllByRole('textbox', { name: '' })[1] + fireEvent.change(stepDetails, { target: { value: 'mockStepDetails' } }) + + fireEvent.click(screen.getByText('Save')) + expect(vi.mocked(renameStep)).toHaveBeenCalled() + expect(props.onClose).toHaveBeenCalled() + }) + it('renders the too long step name error', () => { + render(props) + const stepName = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(stepName, { + target: { + value: + 'mockStepNameisthelongeststepnameihaveeverseen mockstepNameisthelongeststepnameihaveeverseen mockstepNameisthelongest', + }, + }) + screen.getByText('Oops! Your step name is too long.') + }) +}) diff --git a/protocol-designer/src/organisms/RenameStepModal/index.tsx b/protocol-designer/src/organisms/RenameStepModal/index.tsx new file mode 100644 index 00000000000..2b98546751a --- /dev/null +++ b/protocol-designer/src/organisms/RenameStepModal/index.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react' +import { useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import styled from 'styled-components' +import { + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + JUSTIFY_END, + Modal, + PrimaryButton, + SecondaryButton, + SPACING, + StyledText, + TYPOGRAPHY, + InputField, +} from '@opentrons/components' +import { i18n } from '../../assets/localization' +import { getTopPortalEl } from '../../components/portals/TopPortal' +import { renameStep } from '../../labware-ingred/actions' +import type { FormData } from '../../form-types' + +const MAX_STEP_NAME_LENGTH = 60 +interface RenameStepModalProps { + formData: FormData + onClose: () => void +} +export function RenameStepModal(props: RenameStepModalProps): JSX.Element { + const { onClose, formData } = props + const dispatch = useDispatch() + const { t } = useTranslation(['form', 'shared', 'protocol_steps']) + const initialName = i18n.format(t(formData.stepName), 'capitalize') + const [stepName, setStepName] = useState(initialName) + const [stepDetails, setStepDetails] = useState( + String(formData.stepDetails) + ) + + const handleSave = (): void => { + const { stepId } = formData + dispatch( + renameStep({ + stepId, + update: { + stepName: stepName, + stepDetails: stepDetails, + }, + }) + ) + onClose() + } + + return createPortal( + + + {t('shared:cancel')} + + = MAX_STEP_NAME_LENGTH} + onClick={() => { + handleSave() + }} + > + {t('shared:save')} + + + } + > +
    + + + + {t('form:step_edit_form.field.step_name.label')} + + = MAX_STEP_NAME_LENGTH + ? t('protocol_steps:rename_error') + : null + } + value={stepName} + autoFocus + onChange={e => { + setStepName(e.target.value) + }} + type="text" + /> + + + + {t('form:step_edit_form.field.step_notes.label')} + + + { + setStepDetails(e.target.value) + }} + /> + + +
    +
    , + getTopPortalEl() + ) +} + +const DescriptionField = styled.textarea` + min-height: 5rem; + width: 100%; + border: ${BORDERS.lineBorder}; + border-radius: ${BORDERS.borderRadius4}; + padding: ${SPACING.spacing8}; + font-size: ${TYPOGRAPHY.fontSizeH3}; + resize: none; +` diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index 24d3c8ff1cb..709cff49d7c 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -20,6 +20,7 @@ import { import { stepIconsByType } from '../../../../form-types' import { FormAlerts } from '../../../../organisms' import { useKitchen } from '../../../../organisms/Kitchen/hooks' +import { RenameStepModal } from '../../../../organisms/RenameStepModal' import { getFormWarningsForSelectedStep } from '../../../../dismiss/selectors' import { getTimelineWarningsForSelectedStep } from '../../../../top-selectors/timelineWarnings' import { getRobotStateTimeline } from '../../../../file-data/selectors' @@ -98,6 +99,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { ? 1 : 0 ) + const [isRename, setIsRename] = useState(false) const icon = stepIconsByType[formData.stepType] const ToolsComponent: typeof STEP_FORM_MAP[keyof typeof STEP_FORM_MAP] = get( @@ -137,8 +139,17 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { }) as string ) } + return ( <> + {isRename ? ( + { + setIsRename(false) + }} + /> + ) : null} { - console.log('TODO: wire this up') + setIsRename(true) }} css={BUTTON_LINK_STYLE} textDecoration={TYPOGRAPHY.textDecorationUnderline} @@ -200,7 +211,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { - {i18n.format(t(`stepType.${formData.stepType}`), 'capitalize')} + {i18n.format(t(formData.stepName), 'capitalize')} } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx index e32bbd860fb..e536bc750be 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx @@ -42,8 +42,6 @@ export function MagnetTools(props: StepFormProps): JSX.Element { const deckSetup = useSelector(getInitialDeckSetup) const modulesOnDeck = getModulesOnDeckByType(deckSetup, MAGNETIC_MODULE_TYPE) - console.log(modulesOnDeck) - const moduleModel = moduleEntities[formData.moduleId].model const slotInfo = moduleLabwareOptions[0].name.split('in') diff --git a/protocol-designer/src/step-forms/reducers/index.ts b/protocol-designer/src/step-forms/reducers/index.ts index 81b58777f15..2dec7cb6639 100644 --- a/protocol-designer/src/step-forms/reducers/index.ts +++ b/protocol-designer/src/step-forms/reducers/index.ts @@ -113,6 +113,7 @@ import type { CreateContainerAction, DeleteContainerAction, DuplicateLabwareAction, + RenameStepAction, SwapSlotContentsAction, } from '../../labware-ingred/actions' import type { @@ -157,6 +158,7 @@ export type UnsavedFormActions = | ToggleIsGripperRequiredAction | CreateDeckFixtureAction | DeleteDeckFixtureAction + | RenameStepAction export const unsavedForm = ( rootState: RootState, action: UnsavedFormActions @@ -213,6 +215,17 @@ export const unsavedForm = ( return { ...unsavedFormState, ...fieldUpdate } } + case 'CHANGE_STEP_DETAILS': { + const fieldUpdate = handleFormChange( + action.payload.update, + unsavedFormState, + _getPipetteEntitiesRootState(rootState), + _getLabwareEntitiesRootState(rootState) + ) + // @ts-expect-error (IL, 2020-02-24): address in #3161, underspecified form fields may be overwritten in type-unsafe manner + return { ...unsavedFormState, ...fieldUpdate } + } + case 'POPULATE_FORM': return action.payload From 35efa6fb032773ebe5c999bdbd4d29ff66d7e39b Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:52:18 -0400 Subject: [PATCH 075/101] feat(protocol-designer): transfer tools advanced settings and batch edit transfer (#16488) closes AUTH-870, partially addresses AUTH-926 --- .../localization/en/protocol_steps.json | 21 +- .../src/assets/localization/en/shared.json | 1 + .../CheckboxExpandStepFormField/index.tsx | 2 +- .../molecules/DropdownStepFormField/index.tsx | 8 +- .../BatchEditMoveLiquidTools.tsx | 252 ++++++++++++++- .../ProtocolSteps/BatchEditToolbox/index.tsx | 39 ++- .../PipetteFields/BlowoutLocationField.tsx | 29 ++ .../PipetteFields/BlowoutOffsetField.tsx | 91 ++++++ .../StepForm/PipetteFields/DisposalField.tsx | 115 +++++++ .../StepForm/PipetteFields/FlowRateField.tsx | 63 ++++ .../StepForm/PipetteFields/FlowRateInput.tsx | 255 +++++++++++++++ .../StepForm/PipetteFields/PositionField.tsx | 212 ++++++++++++ .../PipetteFields/WellsOrderField.tsx | 96 ++++++ .../StepForm/PipetteFields/index.ts | 6 + .../StepTools/MoveLiquidTools/index.tsx | 306 +++++++++++++++++- 15 files changed, 1469 insertions(+), 27 deletions(-) create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutLocationField.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutOffsetField.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/DisposalField.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateInput.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 6adfae63960..dfc3e0c6345 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -1,19 +1,29 @@ { "add_details": "Add step details", + "advanced_settings": "Advanced pipetting settings", + "air_gap_volume": "Air gap volume", + "aspirate": "Aspirate", "aspirated": "Aspirated", "batch_edit_steps": "Batch edit steps", "batch_edit": "Batch edit", "batch_edits_saved": "Batch edits saved", + "blowout_location": "Blowout location", + "blowout_position": "Blowout position from bottom", "change_tips": "Change tips", "default_tip_option": "Default - get next tip", + "delay_duration": "Delay duration", + "delay_position": "Delay position from bottom", "delete_steps": "Delete steps", "delete": "Delete step", + "dispense": "Dispense", "dispensed": "Dispensed", + "disposal_volume": "Disposal volume", "duplicate_steps": "Duplicate steps", "duplicate": "Duplicate step", "edit_step": "Edit step", "engage_height": "Engage height", "final_deck_state": "Final deck state", + "flow_type_title": "{{type}} flow rate", "from": "from", "heater_shaker": { "active": { @@ -35,6 +45,9 @@ "disengage": "{{module}}disengaged", "engage": "{{module}}engaged to" }, + "max_disposal_volume": "Max {{vol}} {{unit}}", + "mix_times": "Mix repititions", + "mix_volume": "Mix volume", "mix": "Mix", "mix_step": "Mixing{{times}} times in{{labware}}", "mix_repetitions": "Mix repetitions", @@ -48,6 +61,7 @@ "distribute": "Distributingfrom{{source}}to{{destination}}", "transfer": "Transferringfrom{{source}}to{{destination}}" }, + "multi_dispense_options": "Distribute options", "multiAspirate": "Consolidate path", "multiDispense": "Distribute path", "new_location": "New location", @@ -105,6 +119,11 @@ } }, "time": "Time", + "tip_position": "{{prefix}} tip position", + "touch_tip_position": "Touch tip position from top", + "valid_range": "Valid range between {{min}} - {{max}} {{unit}}", "view_details": "View details", - "well_name": "Well {{wellName}}" + "well_name": "Well {{wellName}}", + "well_order_title": "{{prefix}} well order", + "well_position": "Well position (x,y,z): " } diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index e6456184334..51525de47d6 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -14,6 +14,7 @@ "destination_well": "Destination Well", "developer_ff": "Developer feature flags", "done": "Done", + "pipette": "Pipette", "edit_existing": "Edit existing protocol", "edit_instruments": "Edit Instruments", "edit_pipette": "Edit Pipette", diff --git a/protocol-designer/src/molecules/CheckboxExpandStepFormField/index.tsx b/protocol-designer/src/molecules/CheckboxExpandStepFormField/index.tsx index 06db7033790..d9cdc22b6c8 100644 --- a/protocol-designer/src/molecules/CheckboxExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/CheckboxExpandStepFormField/index.tsx @@ -16,7 +16,7 @@ interface CheckboxExpandStepFormFieldProps { checkboxUpdateValue: (value: unknown) => void checkboxValue: unknown isChecked: boolean - children: React.ReactNode + children?: React.ReactNode } export function CheckboxExpandStepFormField( props: CheckboxExpandStepFormFieldProps diff --git a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx index b6940d38fc6..a6777a5be00 100644 --- a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx +++ b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx @@ -6,6 +6,8 @@ import type { FieldProps } from '../../pages/Designer/ProtocolSteps/StepForm/typ export interface DropdownStepFormFieldProps extends FieldProps { options: Options title: string + addPadding?: boolean + width?: string } export function DropdownStepFormField( @@ -18,15 +20,17 @@ export function DropdownStepFormField( title, errorToShow, tooltipContent, + addPadding = true, + width = '17.5rem', } = props const { t } = useTranslation('tooltip') const availableOptionId = options.find(opt => opt.value === value) return ( - + Todo: wire this up +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { + DIRECTION_COLUMN, + Divider, + Flex, + SPACING, + StyledText, + Tabs, +} from '@opentrons/components' +import { + CheckboxExpandStepFormField, + InputStepFormField, +} from '../../../../molecules' +import { + getBlowoutLocationOptionsForForm, + getLabwareFieldForPositioningField, +} from '../StepForm/utils' +import { + BlowoutLocationField, + BlowoutOffsetField, + FlowRateField, + PositionField, + WellsOrderField, +} from '../StepForm/PipetteFields' +import type { WellOrderOption } from '../../../../form-types' +import type { FieldPropsByName } from '../StepForm/types' + +interface BatchEditMoveLiquidProps { + propsForFields: FieldPropsByName +} + +export function BatchEditMoveLiquidTools( + props: BatchEditMoveLiquidProps +): JSX.Element { + const { t, i18n } = useTranslation(['button', 'tooltip', 'protocol_steps']) + const { propsForFields } = props + const [tab, setTab] = useState<'aspirate' | 'dispense'>('aspirate') + const aspirateTab = { + text: t('protocol_steps:aspirate'), + isActive: tab === 'aspirate', + onClick: () => { + setTab('aspirate') + }, + } + const dispenseTab = { + text: t('protocol_steps:dispense'), + + isActive: tab === 'dispense', + onClick: () => { + setTab('dispense') + }, + } + const addFieldNamePrefix = (name: string): string => `${tab}_${name}` + const getPipetteIdForForm = (): string | null => { + const pipetteId = propsForFields.pipette?.value + return pipetteId ? String(pipetteId) : null + } + const getLabwareIdForPositioningField = (name: string): string | null => { + const labwareField = getLabwareFieldForPositioningField(name) + const labwareId = propsForFields[labwareField]?.value + return labwareId ? String(labwareId) : null + } + const getWellOrderFieldValue = ( + name: string + ): WellOrderOption | null | undefined => { + const val = propsForFields[name]?.value + if (val === 'l2r' || val === 'r2l' || val === 't2b' || val === 'b2t') { + return val + } else { + return null + } + } + + return ( + + + + + + + + + + + + + + + + {t('protocol_steps:advanced_settings')} + + {tab === 'aspirate' ? ( + + ) : null} + + {propsForFields[`${tab}_mix_checkbox`].value === true ? ( + + + + + ) : null} + + + {propsForFields[`${tab}_delay_checkbox`].value === true ? ( + + + + + ) : null} + + {tab === 'dispense' ? ( + + {propsForFields.blowout_checkbox.value === true ? ( + + + + + + ) : null} + + ) : null} + + + ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx index f753a018e2d..a2fe674db2e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/BatchEditToolbox/index.tsx @@ -16,16 +16,18 @@ import { import { useKitchen } from '../../../../organisms/Kitchen/hooks' import { deselectAllSteps } from '../../../../ui/steps/actions/actions' import { - // changeBatchEditField, + changeBatchEditField, resetBatchEditFieldChanges, saveStepFormsMulti, } from '../../../../step-forms/actions' +import { maskField } from '../../../../steplist/fieldLevel' +import { getBatchEditFormHasUnsavedChanges } from '../../../../step-forms/selectors' +import { makeBatchEditFieldProps } from './utils' import { BatchEditMoveLiquidTools } from './BatchEditMoveLiquidTools' import { BatchEditMixTools } from './BatchEditMixTools' -// import { maskField } from '../../../../steplist/fieldLevel' -// import type { StepFieldName } from '../../../../steplist/fieldLevel' import type { ThunkDispatch } from 'redux-thunk' +import type { StepFieldName } from '../../../../steplist/fieldLevel' import type { BaseState } from '../../../../types' export const BatchEditToolbox = (): JSX.Element | null => { @@ -36,15 +38,16 @@ export const BatchEditToolbox = (): JSX.Element | null => { const stepTypes = useSelector(getBatchEditSelectedStepTypes) const disabledFields = useSelector(getMultiSelectDisabledFields) const selectedStepIds = useSelector(getMultiSelectItemIds) + const batchEditFormHasChanges = useSelector(getBatchEditFormHasUnsavedChanges) - // const handleChangeFormInput = (name: StepFieldName, value: unknown): void => { - // const maskedValue = maskField(name, value) - // dispatch(changeBatchEditField({ [name]: maskedValue })) - // } + const handleChangeFormInput = (name: StepFieldName, value: unknown): void => { + const maskedValue = maskField(name, value) + dispatch(changeBatchEditField({ [name]: maskedValue })) + } const handleSave = (): void => { dispatch(saveStepFormsMulti(selectedStepIds)) - makeSnackbar(t('batch_edits_saved') as string) + makeSnackbar(t('protocol_steps:batch_edits_saved') as string) dispatch(deselectAllSteps('EXIT_BATCH_EDIT_MODE_BUTTON_PRESS')) } @@ -56,12 +59,12 @@ export const BatchEditToolbox = (): JSX.Element | null => { const stepType = stepTypes.length === 1 ? stepTypes[0] : null if (stepType !== null && fieldValues !== null && disabledFields !== null) { - // const propsForFields = makeBatchEditFieldProps( - // fieldValues, - // disabledFields, - // handleChangeFormInput, - // t - // ) + const propsForFields = makeBatchEditFieldProps( + fieldValues, + disabledFields, + handleChangeFormInput, + t + ) if (stepType === 'moveLiquid' || stepType === 'mix') { return ( { onCloseClick={handleCancel} closeButton={} confirmButton={ - + {t('shared:save')} } > {stepType === 'moveLiquid' ? ( - + ) : ( )} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutLocationField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutLocationField.tsx new file mode 100644 index 00000000000..f603e5dc119 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutLocationField.tsx @@ -0,0 +1,29 @@ +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { selectors as uiLabwareSelectors } from '../../../../../ui/labware' +import { DropdownStepFormField } from '../../../../../molecules' +import type { Options } from '@opentrons/components' +import type { FieldProps } from '../types' + +type BlowoutLocationDropdownProps = FieldProps & { + options: Options +} + +export function BlowoutLocationField( + props: BlowoutLocationDropdownProps +): JSX.Element { + const { options: propOptions, ...restProps } = props + const { t } = useTranslation('protocol_steps') + const disposalOptions = useSelector(uiLabwareSelectors.getDisposalOptions) + const options = [...disposalOptions, ...propOptions] + + return ( + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutOffsetField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutOffsetField.tsx new file mode 100644 index 00000000000..8678a558f62 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/BlowoutOffsetField.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { + DEST_WELL_BLOWOUT_DESTINATION, + SOURCE_WELL_BLOWOUT_DESTINATION, +} from '@opentrons/step-generation' +import { getWellDepth } from '@opentrons/shared-data' +import { + Flex, + InputField, + Tooltip, + useHoverTooltip, +} from '@opentrons/components' +import { ZTipPositionModal } from '../../../../../components/StepEditForm/fields/TipPositionField/ZTipPositionModal' +import { getLabwareEntities } from '../../../../../step-forms/selectors' +import type { FieldProps } from '../types' + +interface BlowoutOffsetFieldProps extends FieldProps { + destLabwareId: unknown + sourceLabwareId?: unknown + blowoutLabwareId?: unknown +} + +export function BlowoutOffsetField( + props: BlowoutOffsetFieldProps +): JSX.Element { + const { + disabled, + value, + destLabwareId, + sourceLabwareId, + blowoutLabwareId, + tooltipContent, + name, + isIndeterminate, + updateValue, + } = props + const { t } = useTranslation(['application', 'protocol_steps']) + const [isModalOpen, setModalOpen] = useState(false) + const [targetProps, tooltipProps] = useHoverTooltip() + const labwareEntities = useSelector(getLabwareEntities) + + let labwareId = null + if (blowoutLabwareId === SOURCE_WELL_BLOWOUT_DESTINATION) { + labwareId = sourceLabwareId + } else if (blowoutLabwareId === DEST_WELL_BLOWOUT_DESTINATION) { + labwareId = destLabwareId + } + + const labwareWellDepth = + labwareId != null && labwareEntities[String(labwareId)]?.def != null + ? getWellDepth(labwareEntities[String(labwareId)].def, 'A1') + : 0 + + return ( + <> + {tooltipContent} + {isModalOpen ? ( + { + setModalOpen(false) + }} + name={name} + zValue={Number(value)} + updateValue={updateValue} + wellDepthMm={labwareWellDepth} + /> + ) : null} + + + { + setModalOpen(true) + } + } + value={String(value)} + isIndeterminate={isIndeterminate} + units={t('units.millimeter')} + id={`TipPositionField_${name}`} + /> + + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/DisposalField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/DisposalField.tsx new file mode 100644 index 00000000000..325f0639ac2 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/DisposalField.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { Flex, DIRECTION_COLUMN, SPACING } from '@opentrons/components' +import { getMaxDisposalVolumeForMultidispense } from '../../../../../steplist/formLevel/handleFormChange/utils' +import { selectors as stepFormSelectors } from '../../../../../step-forms' +import { selectors as uiLabwareSelectors } from '../../../../../ui/labware' +import { + CheckboxExpandStepFormField, + DropdownStepFormField, + InputStepFormField, +} from '../../../../../molecules' +import { getBlowoutLocationOptionsForForm } from '../utils' +import { FlowRateField } from './FlowRateField' +import { BlowoutOffsetField } from './BlowoutOffsetField' + +import type { PathOption, StepType } from '../../../../../form-types' +import type { FieldPropsByName } from '../types' + +interface DisposalFieldProps { + path: PathOption + pipette: string | null + propsForFields: FieldPropsByName + stepType: StepType + volume: string | null + aspirate_airGap_checkbox?: boolean | null + aspirate_airGap_volume?: string | null + tipRack?: string | null +} + +export function DisposalField(props: DisposalFieldProps): JSX.Element { + const { + path, + stepType, + volume, + pipette, + propsForFields, + aspirate_airGap_checkbox, + aspirate_airGap_volume, + tipRack, + } = props + const { t } = useTranslation(['application', 'form']) + + const disposalOptions = useSelector(uiLabwareSelectors.getDisposalOptions) + const pipetteEntities = useSelector(stepFormSelectors.getPipetteEntities) + const blowoutLocationOptions = getBlowoutLocationOptionsForForm({ + path, + stepType, + }) + const maxDisposalVolume = getMaxDisposalVolumeForMultidispense( + { + aspirate_airGap_checkbox, + aspirate_airGap_volume, + path, + pipette, + volume, + tipRack, + }, + pipetteEntities + ) + const disposalDestinationOptions = [ + ...disposalOptions, + ...blowoutLocationOptions, + ] + + const volumeBoundsCaption = + maxDisposalVolume != null + ? t('protocol_steps:max_disposal_volume', { + vol: maxDisposalVolume, + unit: t('units.microliter'), + }) + : '' + + const { value, updateValue } = propsForFields.disposalVolume_checkbox + return ( + + {value ? ( + + + + + + + ) : null} + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx new file mode 100644 index 00000000000..a89c4f0be62 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateField.tsx @@ -0,0 +1,63 @@ +import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { selectors as stepFormSelectors } from '../../../../../step-forms' +import { getMatchingTipLiquidSpecs } from '../../../../../utils' +import { FlowRateInput } from './FlowRateInput' +import type { FieldProps } from '../types' +import type { FlowRateInputProps } from './FlowRateInput' + +interface FlowRateFieldProps extends FieldProps { + flowRateType: FlowRateInputProps['flowRateType'] + volume: unknown + tiprack: unknown + pipetteId?: string | null +} + +export function FlowRateField(props: FlowRateFieldProps): JSX.Element { + const { + pipetteId, + flowRateType, + value, + volume, + tiprack, + name, + ...passThruProps + } = props + const { t } = useTranslation('shared') + const pipetteEntities = useSelector(stepFormSelectors.getPipetteEntities) + const pipette = pipetteId != null ? pipetteEntities[pipetteId] : null + const pipetteDisplayName = pipette ? pipette.spec.displayName : t('pipette') + const innerKey = `${name}:${String(value || 0)}` + const matchingTipLiquidSpecs = + pipette != null + ? getMatchingTipLiquidSpecs(pipette, volume as number, tiprack as string) + : null + + let defaultFlowRate + if (pipette) { + if (flowRateType === 'aspirate') { + defaultFlowRate = + matchingTipLiquidSpecs?.defaultAspirateFlowRate.default ?? 0 + } else if (flowRateType === 'dispense') { + defaultFlowRate = + matchingTipLiquidSpecs?.defaultDispenseFlowRate.default ?? 0 + } else if (flowRateType === 'blowout') { + defaultFlowRate = + matchingTipLiquidSpecs?.defaultBlowOutFlowRate.default ?? 0 + } + } + return ( + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateInput.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateInput.tsx new file mode 100644 index 00000000000..210f831bb86 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/FlowRateInput.tsx @@ -0,0 +1,255 @@ +import { useState } from 'react' +import { createPortal } from 'react-dom' +import round from 'lodash/round' +import { useTranslation } from 'react-i18next' +import { + RadioGroup, + Flex, + useHoverTooltip, + InputField, + Modal, + SecondaryButton, + PrimaryButton, + Tooltip, +} from '@opentrons/components' +import { getMainPagePortalEl } from '../../../../../components/portals/MainPageModalPortal' +import type { ChangeEvent } from 'react' +import type { FieldProps } from '../types' + +const DECIMALS_ALLOWED = 1 + +export interface FlowRateInputProps extends FieldProps { + flowRateType: 'aspirate' | 'dispense' | 'blowout' + minFlowRate: number + maxFlowRate: number + defaultFlowRate?: number | null + pipetteDisplayName?: string | null +} + +interface InitialState { + isPristine: boolean + modalUseDefault: boolean + showModal: boolean + modalFlowRate?: string | null +} + +export function FlowRateInput(props: FlowRateInputProps): JSX.Element { + const { + defaultFlowRate, + disabled, + flowRateType, + isIndeterminate, + maxFlowRate, + minFlowRate, + name, + pipetteDisplayName, + tooltipContent, + value, + } = props + const [targetProps, tooltipProps] = useHoverTooltip() + const { t, i18n } = useTranslation([ + 'form', + 'application', + 'shared', + 'protocol_steps', + ]) + + const initialState: InitialState = { + isPristine: true, + modalFlowRate: props.value ? String(props.value) : null, + modalUseDefault: !props.value && !isIndeterminate, + showModal: false, + } + + const [isPristine, setIsPristine] = useState( + initialState.isPristine + ) + + const [modalFlowRate, setModalFlowRate] = useState< + InitialState['modalFlowRate'] + >(initialState.modalFlowRate) + + const [modalUseDefault, setModalUseDefault] = useState< + InitialState['modalUseDefault'] + >(initialState.modalUseDefault) + + const [showModal, setShowModal] = useState( + initialState.showModal + ) + + const resetModalState = (): void => { + setShowModal(initialState.showModal) + setModalFlowRate(initialState.modalFlowRate) + setModalUseDefault(initialState.modalUseDefault) + setIsPristine(initialState.isPristine) + } + + const cancelModal = resetModalState + + const openModal = (): void => { + setShowModal(true) + } + + const makeSaveModal = (allowSave: boolean) => (): void => { + setIsPristine(false) + + if (allowSave) { + const newFlowRate = modalUseDefault ? null : Number(modalFlowRate) + props.updateValue(newFlowRate) + resetModalState() + } + } + + const handleChangeRadio = (e: ChangeEvent): void => { + setModalUseDefault(e.target.value !== 'custom') + } + + const handleChangeNumber = (e: ChangeEvent): void => { + const value = e.target.value + if (value === '' || value === '.' || !Number.isNaN(Number(value))) { + setModalFlowRate(value) + setModalUseDefault(false) + } + } + const title = i18n.format( + t('protocol_steps:flow_type_title', { type: flowRateType }), + 'capitalize' + ) + + const modalFlowRateNum = Number(modalFlowRate) + + // show 0.1 not 0 as minimum, since bottom of range is non-inclusive + const displayMinFlowRate = minFlowRate || Math.pow(10, -DECIMALS_ALLOWED) + const rangeDescription = t('step_edit_form.field.flow_rate.range', { + min: displayMinFlowRate, + max: maxFlowRate, + }) + const outOfBounds = + modalFlowRateNum === 0 || + minFlowRate > modalFlowRateNum || + modalFlowRateNum > maxFlowRate + const correctDecimals = + round(modalFlowRateNum, DECIMALS_ALLOWED) === modalFlowRateNum + const allowSave = modalUseDefault || (!outOfBounds && correctDecimals) + + let errorMessage = null + // validation only happens when "Custom" is selected, not "Default" + // and pristinity only masks the outOfBounds error, not the correctDecimals error + if (!modalUseDefault) { + if (!Number.isNaN(modalFlowRateNum) && !correctDecimals) { + errorMessage = t('step_edit_form.field.flow_rate.error_decimals', { + decimals: `${DECIMALS_ALLOWED}`, + }) + } else if (!isPristine && outOfBounds) { + errorMessage = t('step_edit_form.field.flow_rate.error_out_of_bounds', { + min: displayMinFlowRate, + max: maxFlowRate, + }) + } + } + + const FlowRateInputField = ( + + ) + + // TODO: update the modal + const FlowRateModal = + pipetteDisplayName && + createPortal( + + + {t('shared:cancel')} + + + {t('shared:done')} + + + } + > +

    {t('protocol_steps:flow_type_title', { type: flowRateType })}

    + +
    {title}
    + +
    {`${flowRateType} speed`}
    + + + , + getMainPagePortalEl() + ) + + return ( + <> + {flowRateType === 'blowout' ? ( + + + {tooltipContent} + + ) : ( + + + + )} + + {showModal && FlowRateModal} + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx new file mode 100644 index 00000000000..720a273d1a2 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { + COLORS, + DIRECTION_COLUMN, + Flex, + InputField, + ListButton, + SPACING, + StyledText, + Tooltip, + useHoverTooltip, +} from '@opentrons/components' +import { getWellsDepth, getWellDimension } from '@opentrons/shared-data' +import { getIsDelayPositionField } from '../../../../../form-types' +import { selectors as stepFormSelectors } from '../../../../../step-forms' +import { TipPositionModal } from '../../../../../components/StepEditForm/fields/TipPositionField/TipPositionModal' +import { getDefaultMmFromBottom } from '../../../../../components/StepEditForm/fields/TipPositionField/utils' +import { ZTipPositionModal } from '../../../../../components/StepEditForm/fields/TipPositionField/ZTipPositionModal' +import type { + TipXOffsetFields, + TipYOffsetFields, + TipZOffsetFields, +} from '../../../../../form-types' +import type { FieldPropsByName } from '../types' +import type { PositionSpecs } from '../../../../../components/StepEditForm/fields/TipPositionField/TipPositionModal' +interface PositionFieldProps { + prefix: 'aspirate' | 'dispense' + propsForFields: FieldPropsByName + zField: TipZOffsetFields + xField?: TipXOffsetFields + yField?: TipYOffsetFields + labwareId?: string | null +} + +export function PositionField(props: PositionFieldProps): JSX.Element { + const { labwareId, propsForFields, zField, xField, yField, prefix } = props + const { + name: zName, + value: rawZValue, + updateValue: zUpdateValue, + tooltipContent, + isIndeterminate, + disabled, + } = propsForFields[zField] + + const { t, i18n } = useTranslation(['application', 'protocol_steps']) + const [targetProps, tooltipProps] = useHoverTooltip() + const [isModalOpen, setModalOpen] = useState(false) + const labwareEntities = useSelector(stepFormSelectors.getLabwareEntities) + const labwareDef = + labwareId != null && labwareEntities[labwareId] != null + ? labwareEntities[labwareId].def + : null + + let wellDepthMm = 0 + let wellXWidthMm = 0 + let wellYWidthMm = 0 + + if (labwareDef != null) { + // NOTE: only taking depth of first well in labware def, UI not currently equipped for multiple depths/widths + const firstWell = labwareDef.wells.A1 + if (firstWell) { + wellDepthMm = getWellsDepth(labwareDef, ['A1']) + wellXWidthMm = getWellDimension(labwareDef, ['A1'], 'x') + wellYWidthMm = getWellDimension(labwareDef, ['A1'], 'y') + } + } + + if ( + (wellDepthMm === 0 || wellXWidthMm === 0 || wellYWidthMm === 0) && + labwareId != null && + labwareDef != null + ) { + console.error( + `expected to find all well dimensions mm with labwareId ${labwareId} but could not` + ) + } + + const handleOpen = (has3Specs: boolean): void => { + if (has3Specs && wellDepthMm && wellXWidthMm && wellYWidthMm) { + setModalOpen(true) + } + if (!has3Specs && wellDepthMm) { + setModalOpen(true) + } + } + const handleClose = (): void => { + setModalOpen(false) + } + const isDelayPositionField = getIsDelayPositionField(zName) + let zValue: string | number = '0' + const mmFromBottom = typeof rawZValue === 'number' ? rawZValue : null + if (wellDepthMm !== null) { + // show default value for field in parens if no mmFromBottom value is selected + zValue = + mmFromBottom ?? getDefaultMmFromBottom({ name: zName, wellDepthMm }) + } + let modal = ( + + ) + if (yField != null && xField != null) { + const { + name: xName, + value: rawXValue, + updateValue: xUpdateValue, + } = propsForFields[xField] + const { + name: yName, + value: rawYValue, + updateValue: yUpdateValue, + } = propsForFields[yField] + + const specs: PositionSpecs = { + z: { + name: zName, + value: mmFromBottom, + updateValue: zUpdateValue, + }, + x: { + name: xName, + value: rawXValue != null ? Number(rawXValue) : null, + updateValue: xUpdateValue, + }, + y: { + name: yName, + value: rawYValue != null ? Number(rawYValue) : null, + updateValue: yUpdateValue, + }, + } + + modal = ( + + ) + } + + return ( + <> + {tooltipContent} + {isModalOpen ? modal : null} + {yField != null && xField != null ? ( + + + {i18n.format( + t('protocol_steps:tip_position', { prefix }), + 'capitalize' + )} + + { + handleOpen(true) + }} + > + + {t('protocol_steps:well_position')} + {`${ + propsForFields[xField].value != null + ? Number(propsForFields[xField].value) + : 0 + }${t('units.millimeter')}, + ${ + propsForFields[yField].value != null + ? Number(propsForFields[yField].value) + : 0 + }${t('units.millimeter')}, + ${mmFromBottom ?? 0}${t('units.millimeter')}`} + + + + ) : ( + { + handleOpen(false) + }} + value={String(zValue)} + isIndeterminate={isIndeterminate} + units={t('units.millimeter')} + id={`TipPositionField_${zName}`} + /> + )} + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx new file mode 100644 index 00000000000..a4152db7d4b --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellsOrderField.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + useHoverTooltip, + Tooltip, + ListButton, + StyledText, + Flex, + SPACING, + DIRECTION_COLUMN, + COLORS, +} from '@opentrons/components' +import { WellOrderModal } from '../../../../../components/StepEditForm/fields/WellOrderField/WellOrderModal' +import type { WellOrderOption } from '../../../../../form-types' +import type { FieldProps } from '../types' + +export interface WellsOrderFieldProps { + prefix: 'aspirate' | 'dispense' | 'mix' + firstName: string + secondName: string + updateFirstWellOrder: FieldProps['updateValue'] + updateSecondWellOrder: FieldProps['updateValue'] + firstValue?: WellOrderOption | null + secondValue?: WellOrderOption | null +} + +export function WellsOrderField(props: WellsOrderFieldProps): JSX.Element { + const { + firstValue, + secondValue, + firstName, + secondName, + prefix, + updateFirstWellOrder, + updateSecondWellOrder, + } = props + const { t, i18n } = useTranslation(['form', 'modal', 'protocol_steps']) + const [isModalOpen, setModalOpen] = useState(false) + + const handleOpen = (): void => { + setModalOpen(true) + } + const handleClose = (): void => { + setModalOpen(false) + } + + const updateValues = (firstValue: unknown, secondValue: unknown): void => { + updateFirstWellOrder(firstValue) + updateSecondWellOrder(secondValue) + } + + const [targetProps, tooltipProps] = useHoverTooltip() + + return ( + <> + + {t('step_edit_form.field.well_order.label')} + + + + {i18n.format( + t('protocol_steps:well_order_title', { prefix }), + 'capitalize' + )} + + + + {t(`step_edit_form.field.well_order.option.${firstValue}`)} + {', '} + {t(`step_edit_form.field.well_order.option.${secondValue}`)} + + + + + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/index.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/index.ts index 36edfa514a4..667ad102c14 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/index.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/index.ts @@ -1,11 +1,17 @@ +export * from './BlowoutLocationField' +export * from './BlowoutOffsetField' export * from './ChangeTipField' +export * from './DisposalField' export * from './DropTipField' +export * from './FlowRateField' export * from './LabwareField' export * from './PartialTipField' export * from './PathField' export * from './PickUpTipField' export * from './PipetteField' +export * from './PositionField' export * from './TiprackField' export * from './TipWellSelectionField' export * from './VolumeField' export * from './WellSelectionField' +export * from './WellsOrderField' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx index fc56a417fd4..f30e5338b60 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx @@ -1,5 +1,14 @@ import { useSelector } from 'react-redux' -import { DIRECTION_COLUMN, Divider, Flex } from '@opentrons/components' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { + DIRECTION_COLUMN, + Divider, + Flex, + SPACING, + StyledText, + Tabs, +} from '@opentrons/components' import { getEnableReturnTip } from '../../../../../../feature-flags/selectors' import { getAdditionalEquipmentEntities, @@ -7,31 +16,64 @@ import { getPipetteEntities, } from '../../../../../../step-forms/selectors' import { + CheckboxExpandStepFormField, + InputStepFormField, +} from '../../../../../../molecules' +import { + BlowoutLocationField, + BlowoutOffsetField, ChangeTipField, + DisposalField, DropTipField, + FlowRateField, LabwareField, PartialTipField, PathField, PickUpTipField, PipetteField, + PositionField, TiprackField, TipWellSelectionField, VolumeField, WellSelectionField, + WellsOrderField, } from '../../PipetteFields' +import { + getBlowoutLocationOptionsForForm, + getLabwareFieldForPositioningField, +} from '../../utils' +import type { StepFieldName } from '../../../../../../form-types' import type { StepFormProps } from '../../types' +const makeAddFieldNamePrefix = (prefix: string) => ( + fieldName: string +): StepFieldName => `${prefix}_${fieldName}` + export function MoveLiquidTools(props: StepFormProps): JSX.Element { const { toolboxStep, propsForFields, formData } = props - // TODO: these will be used for the 2nd page advanced settings - // const { stepType, path } = formData + const { t, i18n } = useTranslation(['protocol_steps', 'form']) + const { path } = formData + const [tab, setTab] = useState<'aspirate' | 'dispense'>('aspirate') const additionalEquipmentEntities = useSelector( getAdditionalEquipmentEntities ) const enableReturnTip = useSelector(getEnableReturnTip) const labwares = useSelector(getLabwareEntities) const pipettes = useSelector(getPipetteEntities) + const addFieldNamePrefix = makeAddFieldNamePrefix(tab) + const isWasteChuteSelected = + propsForFields.dispense_labware?.value != null + ? additionalEquipmentEntities[ + String(propsForFields.dispense_labware.value) + ]?.name === 'wasteChute' + : false + const isTrashBinSelected = + propsForFields.dispense_labware?.value != null + ? additionalEquipmentEntities[ + String(propsForFields.dispense_labware.value) + ]?.name === 'trashBin' + : false const userSelectedPickUpTipLocation = labwares[String(propsForFields.pickUpTip_location.value)] != null const userSelectedDropTipLocation = @@ -46,6 +88,24 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { additionalEquipmentEntities[String(propsForFields.dispense_labware.value)] ?.name === 'trashBin' + const aspirateTab = { + text: t('aspirate'), + isActive: tab === 'aspirate', + onClick: () => { + setTab('aspirate') + }, + } + const dispenseTab = { + text: t('dispense'), + + isActive: tab === 'dispense', + onClick: () => { + setTab('dispense') + }, + } + const hideWellOrderField = + tab === 'dispense' && (isWasteChuteSelected || isTrashBinSelected) + return toolboxStep === 0 ? ( @@ -131,7 +191,243 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { ) : ( - // TODO: wire up the second page -
    wire this up
    + + + + + + + + + + {hideWellOrderField ? null : ( + + )} + + + + + + {t('protocol_steps:advanced_settings')} + + {tab === 'aspirate' ? ( + + ) : null} + + {formData[`${tab}_mix_checkbox`] === true ? ( + + + + + ) : null} + + + {formData[`${tab}_delay_checkbox`] === true ? ( + + + + + ) : null} + + {tab === 'dispense' ? ( + + {formData.blowout_checkbox === true ? ( + + + + + + ) : null} + + ) : null} + + {formData[`${tab}_touchTip_checkbox`] === true ? ( + + ) : null} + + + {formData[`${tab}_airGap_checkbox`] === true ? ( + + ) : null} + + {path === 'multiDispense' && tab === 'dispense' && ( + + )} + + ) } From 5ac2933fbf8a6e75e28bb1050dfed14ead61b9f4 Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Thu, 17 Oct 2024 13:16:50 -0400 Subject: [PATCH 076/101] fix(labware-library): make labware creator accessible via external links (#16480) Fixes https://opentrons.atlassian.net/browse/RESC-339 --- labware-library/Makefile | 1 - labware-library/create/index.html | 16 +++++ .../cypress/e2e/labware-creator/create.cy.js | 2 +- .../e2e/labware-creator/customTubeRack.cy.js | 2 +- .../e2e/labware-creator/fileImport.cy.js | 2 +- .../e2e/labware-creator/reservoir.cy.js | 2 +- .../cypress/e2e/labware-creator/tipRack.cy.js | 2 +- .../e2e/labware-creator/tubesBlock.cy.js | 4 +- .../e2e/labware-creator/tubesRack.cy.js | 6 +- .../e2e/labware-creator/wellPlate.cy.js | 2 +- labware-library/renderStatic.js | 62 ------------------- .../LabwareList/CustomLabwareCard.tsx | 3 +- .../components/LabwareList/LabwareCard.tsx | 3 +- .../src/components/Nav/Breadcrumbs.tsx | 3 +- .../src/components/Sidebar/LabwareGuide.tsx | 6 +- .../website-navigation/SubdomainNav.tsx | 3 +- labware-library/src/filters.tsx | 3 +- labware-library/src/index.tsx | 11 ++-- .../labware-creator/components/IntroCopy.tsx | 5 +- labware-library/src/public-path.ts | 14 ----- labware-library/vite.config.mts | 7 +++ 21 files changed, 46 insertions(+), 113 deletions(-) create mode 100644 labware-library/create/index.html delete mode 100644 labware-library/renderStatic.js delete mode 100644 labware-library/src/public-path.ts diff --git a/labware-library/Makefile b/labware-library/Makefile index 2ccca5f45a8..a074edd4092 100644 --- a/labware-library/Makefile +++ b/labware-library/Makefile @@ -25,7 +25,6 @@ clean: dist: export NODE_ENV := production dist: vite build - node ./renderStatic.js # development assets server .PHONY: dev diff --git a/labware-library/create/index.html b/labware-library/create/index.html new file mode 100644 index 00000000000..84c7c4cb7d3 --- /dev/null +++ b/labware-library/create/index.html @@ -0,0 +1,16 @@ + + + + + + Redirecting to Labware Creator + + + +

    Redirecting to Labware Creator...

    + + \ No newline at end of file diff --git a/labware-library/cypress/e2e/labware-creator/create.cy.js b/labware-library/cypress/e2e/labware-creator/create.cy.js index b81026d003a..299a3444a86 100644 --- a/labware-library/cypress/e2e/labware-creator/create.cy.js +++ b/labware-library/cypress/e2e/labware-creator/create.cy.js @@ -4,7 +4,7 @@ context('The Labware Creator Landing Page', () => { beforeEach(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') }) diff --git a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js index f3c195030be..319e7f4ea81 100644 --- a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js @@ -6,7 +6,7 @@ const expectedExportFixture = context('Tubes and Rack', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') }) diff --git a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js index 408e6fc1ea2..e0fc480107f 100644 --- a/labware-library/cypress/e2e/labware-creator/fileImport.cy.js +++ b/labware-library/cypress/e2e/labware-creator/fileImport.cy.js @@ -4,7 +4,7 @@ const importedLabwareFile = 'TestLabwareDefinition.json' describe('File Import', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') }) diff --git a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js index c88044d1678..75197208859 100644 --- a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js +++ b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js @@ -4,7 +4,7 @@ context('Reservoirs', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') }) diff --git a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js index 4d633ffd5f6..e69e3dd7285 100644 --- a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js @@ -5,7 +5,7 @@ const expectedExportFixture = '../fixtures/generic_1_tiprack_20ul.json' describe('Create a Tip Rack', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') }) it('Should create a tip rack', () => { diff --git a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js index b891aedafd2..66ea8d0dedc 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js @@ -4,7 +4,7 @@ context('Tubes and Block', () => { beforeEach(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') cy.get('label') @@ -445,7 +445,7 @@ context('Tubes and Block', () => { }) it('tests the whole form and file export', () => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') cy.get('label') .contains('What type of labware are you creating?') diff --git a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js index 738124ee2e8..4214f215dc0 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js @@ -5,7 +5,7 @@ context('Tubes and Rack', () => { describe('Six tubes', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') cy.get('label') .contains('What type of labware are you creating?') @@ -137,7 +137,7 @@ context('Tubes and Rack', () => { describe('Fifteen tubes', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') cy.get('label') @@ -268,7 +268,7 @@ context('Tubes and Rack', () => { describe('Twentyfour tubes', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') cy.get('label') diff --git a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js index d586f8040b6..5b27cfcfd72 100644 --- a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js +++ b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js @@ -8,7 +8,7 @@ context('Well Plates', () => { before(() => { - cy.visit('/create') + cy.visit('/#/create') cy.viewport('macbook-15') }) diff --git a/labware-library/renderStatic.js b/labware-library/renderStatic.js deleted file mode 100644 index b3fa4262d3c..00000000000 --- a/labware-library/renderStatic.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict' -// Use react-snap to crawl from the specified URL paths and prerender HTML for those pages. -// Since paths to JS/CSS assets are relative to webpack publicPath, and not relative to -// the location of the page being prerendered, those src/href paths need to be prefixed with -// the correct number of `../`'s to reference the project root. -// -// For example, the output of react-snap for a page at http://localhost:PORT/path/to/page/ -// will have a