diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index b39dba5df13..3249768955c 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -2,6 +2,7 @@ import contextlib import logging import pathlib +import numpy as np # type: ignore from collections import OrderedDict from typing import Dict, Union, List, Optional, Tuple, TYPE_CHECKING @@ -9,10 +10,11 @@ from opentrons import types as top_types from opentrons.util import linal +from functools import lru_cache from opentrons.config import robot_configs, pipette_config from opentrons.drivers.types import MoveSplit -from .util import use_or_initialize_loop +from .util import use_or_initialize_loop, DeckTransformState from .pipette import Pipette from .controller import Controller from .simulator import Simulator @@ -219,6 +221,45 @@ def is_simulator(self): """ `True` if this is a simulator; `False` otherwise. """ return isinstance(self._backend, Simulator) + def validate_calibration(self) -> DeckTransformState: + """ + The lru cache decorator is currently not supported by the + ThreadManager. To work around this, we need to wrap the + actualy function around a dummy outer function. + + Once decorators are more fully supported, we can remove this. + """ + return self._calculate_valid_calibration() + + @lru_cache(maxsize=1) + def _calculate_valid_calibration(self) -> DeckTransformState: + """ + This function determines whether the current gantry + calibration is valid or not based on the following use-cases: + + """ + curr_cal = np.array(self._config.gantry_calibration) + row, col = curr_cal.shape + rank = np.linalg.matrix_rank(curr_cal) + + id_matrix = linal.identity_deck_transform() + + z = abs(curr_cal[2][-1]) + + outofrange = z < 16 or z > 34 + if row != rank: + # Check that the matrix is non-singular + return DeckTransformState.SINGULARITY + elif np.array_equal(curr_cal, id_matrix): + # Check that the matrix is not an identity + return DeckTransformState.IDENTITY + elif outofrange: + # Check that the matrix is not out of range. + return DeckTransformState.BAD_CALIBRATION + else: + # Transform as it stands is sufficient. + return DeckTransformState.OK + async def register_callback(self, cb): """ Allows the caller to register a callback, and returns a closure that can be used to unregister the provided callback @@ -1000,6 +1041,8 @@ async def update_config(self, **kwargs): Documentation on keys can be found in the documentation for :py:class:`.robot_config`. """ + if kwargs.get('gantry_calibration'): + self._calculate_valid_calibration.cache_clear() self._config = self._config._replace(**kwargs) # type: ignore async def update_deck_calibration(self, new_transform): diff --git a/api/src/opentrons/hardware_control/simulator.py b/api/src/opentrons/hardware_control/simulator.py index 36a69a6091f..01a94e0ac7d 100644 --- a/api/src/opentrons/hardware_control/simulator.py +++ b/api/src/opentrons/hardware_control/simulator.py @@ -95,7 +95,7 @@ def __init__( requesting instruments that _are_ present get the full number. """ - self._config = config + self.config = config self._loop = loop def _sanitize_attached_instrument( diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index f995594bf1e..ab1922d4967 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -95,6 +95,9 @@ def door_state(self) -> DoorState: def door_state(self, door_state: DoorState) -> DoorState: ... + def validate_calibration(self): + ... + class BoardRevision(enum.Enum): UNKNOWN = enum.auto() diff --git a/api/src/opentrons/hardware_control/util.py b/api/src/opentrons/hardware_control/util.py index f85ead26451..9555c345692 100644 --- a/api/src/opentrons/hardware_control/util.py +++ b/api/src/opentrons/hardware_control/util.py @@ -1,6 +1,7 @@ """ Utility functions and classes for the hardware controller""" import asyncio import logging +from enum import Enum from typing import Dict, Any, Optional, List, Tuple from .types import CriticalPoint @@ -37,3 +38,13 @@ def plan_arc( for wp in checked_wp]\ + [(dest_point._replace(z=z_height), dest_cp), (dest_point, dest_cp)] + + +class DeckTransformState(Enum): + OK = "OK" + IDENTITY = "IDENTITY" + BAD_CALIBRATION = "BAD_CALIBRATION" + SINGULARITY = "SINGULARITY" + + def __str__(self): + return self.name diff --git a/api/tests/opentrons/hardware_control/test_api_helpers.py b/api/tests/opentrons/hardware_control/test_api_helpers.py new file mode 100644 index 00000000000..228463172b2 --- /dev/null +++ b/api/tests/opentrons/hardware_control/test_api_helpers.py @@ -0,0 +1,31 @@ +from opentrons.hardware_control.util import DeckTransformState + + +async def test_validating_calibration(hardware): + + singular_matrix = [ + [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] + + await hardware.update_config(gantry_calibration=singular_matrix) + + assert hardware.validate_calibration() == DeckTransformState.SINGULARITY + + identity_matrix = [ + [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] + await hardware.update_config(gantry_calibration=identity_matrix) + + assert hardware.validate_calibration() == DeckTransformState.IDENTITY + + outofrange_matrix = [ + [1, 0, 0, 5], [0, 1, 0, 4], [0, 0, 1, 0], [0, 0, 0, 1]] + await hardware.update_config(gantry_calibration=outofrange_matrix) + + assert hardware.validate_calibration() ==\ + DeckTransformState.BAD_CALIBRATION + + inrange_matrix = [ + [1, 0, 0, 1], [0, 1, 0, 2], [0, 0, 1, -25], [0, 0, 0, 1]] + await hardware.update_config(gantry_calibration=inrange_matrix) + hardware.validate_calibration() + + assert hardware.validate_calibration() == DeckTransformState.OK diff --git a/app/src/components/RobotSettings/CheckCalibrationControl.js b/app/src/components/RobotSettings/CheckCalibrationControl.js index 015fcbcb5de..881fa0559bc 100644 --- a/app/src/components/RobotSettings/CheckCalibrationControl.js +++ b/app/src/components/RobotSettings/CheckCalibrationControl.js @@ -19,6 +19,10 @@ import { COLOR_WARNING, FONT_SIZE_BODY_1, FONT_WEIGHT_SEMIBOLD, + Tooltip, + useHoverTooltip, + TOOLTIP_BOTTOM, + TOOLTIP_FIXED, } from '@opentrons/components' import { Portal } from '../portal' @@ -39,6 +43,9 @@ const COULD_NOT_START = 'Could not start Robot Calibration Check' const PLEASE_TRY_AGAIN = 'Please try again or contact support if you continue to experience issues' +const DECK_CAL_TOOL_TIP_MESSAGE = + 'Perform a deck calibration to enable this feature.' + export function CheckCalibrationControl({ robotName, disabled, @@ -65,11 +72,19 @@ export function CheckCalibrationControl({ ) + const [targetProps, tooltipProps] = useHoverTooltip({ + placement: TOOLTIP_BOTTOM, + strategy: TOOLTIP_FIXED, + }) + + const calCheckDisabled = false + React.useEffect(() => { if (requestStatus === RobotApi.SUCCESS) setShowWizard(true) }, [requestStatus]) // TODO(mc, 2020-06-17): extract alert presentational stuff + // TODO(lc, 2020-06-18): edit calCheckDisabled to check for a bad calibration status from the new endpoint return ( <> + {calCheckDisabled && ( + {DECK_CAL_TOOL_TIP_MESSAGE} + )} {requestState && requestState.status === RobotApi.FAILURE && ( - {ff.enableRobotCalCheck && ( - - )} - - {CALIBRATE_DECK_DESCRIPTION} - + + Control lights on deck. + + {ff.enableRobotCalCheck && ( + + )} ) } diff --git a/app/src/components/RobotSettings/DeckCalibrationWarning.js b/app/src/components/RobotSettings/DeckCalibrationWarning.js new file mode 100644 index 00000000000..697a0e02077 --- /dev/null +++ b/app/src/components/RobotSettings/DeckCalibrationWarning.js @@ -0,0 +1,54 @@ +// @flow +import * as React from 'react' +import { + Icon, + Box, + Flex, + Text, + FONT_SIZE_BODY_1, + COLOR_WARNING, + COLOR_ERROR, + SPACING_AUTO, + SPACING_1, + ALIGN_CENTER, +} from '@opentrons/components' + +import styles from './styles.css' + +export type DeckCalibrationWarningProps = {| + calibrationStatus: string, +|} + +const ROBOT_CAL_WARNING = "This robot's deck has not yet been calibrated." +const ROBOT_CAL_RESOLUTION = + 'Please perform a deck calibration prior to uploading a protocol.' +const ROBOT_CAL_ERROR = + 'Bad deck calibration detected! This robot is likely to experience a crash.' + +export function DeckCalibrationWarning({ + calibrationStatus, +}: DeckCalibrationWarningProps): React.Node { + const isVisible = calibrationStatus !== 'OK' + const isNoCalibration = calibrationStatus === 'IDENTITY' + const message = isNoCalibration ? ROBOT_CAL_WARNING : ROBOT_CAL_ERROR + const colorType = isNoCalibration ? COLOR_WARNING : COLOR_ERROR + const styleType = isNoCalibration + ? styles.cal_check_warning_icon + : styles.cal_check_error_icon + + if (!isVisible) return null + + return ( + + + + + {message} + + + {ROBOT_CAL_RESOLUTION} + + + + ) +} diff --git a/app/src/components/RobotSettings/__tests__/ControlsCard.test.js b/app/src/components/RobotSettings/__tests__/ControlsCard.test.js index 2df22093ebc..1e81707503d 100644 --- a/app/src/components/RobotSettings/__tests__/ControlsCard.test.js +++ b/app/src/components/RobotSettings/__tests__/ControlsCard.test.js @@ -11,6 +11,7 @@ import { ControlsCard } from '../ControlsCard' import { CheckCalibrationControl } from '../CheckCalibrationControl' import { LabeledToggle, LabeledButton } from '@opentrons/components' import { CONNECTABLE, UNREACHABLE } from '../../../discovery' +import { DeckCalibrationWarning } from '../DeckCalibrationWarning' import type { State } from '../../../types' import type { ViewableRobot } from '../../../discovery/types' @@ -55,10 +56,7 @@ describe('ControlsCard', () => { let render const getDeckCalButton = wrapper => - wrapper - .find({ label: 'Calibrate deck' }) - .find(LabeledButton) - .find('button') + wrapper.find('TitledButton[title="Calibrate deck"]').find('button') const getCheckCalibrationControl = wrapper => wrapper.find(CheckCalibrationControl) @@ -203,4 +201,21 @@ describe('ControlsCard', () => { expect(wrapper.exists(CheckCalibrationControl)).toBe(false) }) + + it('Check cal button is disabled if deck calibration is bad', () => { + const wrapper = render() + + // TODO(lc, 2020-06-18): Mock out the new transform status such that + // this should evaluate to true. + expect(getCheckCalibrationControl(wrapper).prop('disabled')).toBe(false) + }) + + it('DeckCalibrationWarning component renders if deck calibration is bad', () => { + const wrapper = render() + + // check that the deck calibration warning component is not null + // TODO(lc, 2020-06-18): Mock out the new transform status such that + // this should evaluate to true. + expect(wrapper.exists(DeckCalibrationWarning)).toBe(true) + }) }) diff --git a/app/src/components/RobotSettings/__tests__/DeckCalibrationWarning.test.js b/app/src/components/RobotSettings/__tests__/DeckCalibrationWarning.test.js new file mode 100644 index 00000000000..3deb9bbf516 --- /dev/null +++ b/app/src/components/RobotSettings/__tests__/DeckCalibrationWarning.test.js @@ -0,0 +1,78 @@ +// @flow +import * as React from 'react' +import { Provider } from 'react-redux' +import { mount } from 'enzyme' + +import { DeckCalibrationWarning } from '../DeckCalibrationWarning' +import { Icon, Flex, ALIGN_CENTER, Box } from '@opentrons/components' + +describe('Calibration Warning Component', () => { + let mockStore + let render + + beforeEach(() => { + mockStore = { + subscribe: () => {}, + getState: () => ({ + mockState: true, + }), + dispatch: jest.fn(), + } + + render = (status: string = 'OK') => { + return mount(, { + wrappingComponent: Provider, + wrappingComponentProps: { store: mockStore }, + }) + } + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('Check nothing renders when calibration is OK', () => { + const wrapper = render() + expect(wrapper).toEqual({}) + }) + + it('Check warning generates specific components', () => { + const wrapper = render('IDENTITY') + const flex = wrapper.find(Flex) + const icon = wrapper.find(Icon) + const box = wrapper.find(Box) + const fullText = box.text() + const toSplit = fullText.split('.') + + expect(flex.prop('alignItems')).toBe(ALIGN_CENTER) + expect(icon.prop('name')).toEqual('alert-circle') + expect(toSplit[1]).toEqual(expect.stringContaining('Please perform a deck')) + }) + + it('Check calibration is identity', () => { + const wrapper = render('IDENTITY') + const icon = wrapper.find(Icon) + const box = wrapper.find(Box) + const fullText = box.text() + const toSplit = fullText.split('.') + + expect(icon.prop('className')).toEqual('cal_check_warning_icon') + expect(toSplit[0]).toEqual( + expect.stringContaining('not yet been calibrated') + ) + }) + + it('Check calibration is singular or bad', () => { + const wrapper = render('SINGULARITY') + + const icon = wrapper.find(Icon) + const box = wrapper.find(Box) + const fullText = box.text() + const toSplit = fullText.split('.') + + expect(icon.prop('className')).toEqual('cal_check_error_icon') + expect(toSplit[0]).toEqual( + expect.stringContaining('Bad deck calibration detected') + ) + }) +}) diff --git a/app/src/components/RobotSettings/styles.css b/app/src/components/RobotSettings/styles.css index 25b477807a6..697254df56b 100644 --- a/app/src/components/RobotSettings/styles.css +++ b/app/src/components/RobotSettings/styles.css @@ -94,3 +94,18 @@ margin-right: auto; } } + +.cal_check_error_icon { + color: var(--c-error); + width: 1.5rem; + height: 1.5rem; + padding-right: 0.25rem; +} + +.cal_check_warning_icon { + color: var(--c-warning); + width: 1.5rem; + height: 1.5rem; + padding-right: 0.25rem; + padding-left: 0.25rem; +} diff --git a/app/src/components/TitledButton/index.js b/app/src/components/TitledButton/index.js index fd58bcda180..5f669b486f4 100644 --- a/app/src/components/TitledButton/index.js +++ b/app/src/components/TitledButton/index.js @@ -34,7 +34,7 @@ export function TitledButton({ return ( - + CalibrationStatus: + robot_conf = robot_configs.load() + return CalibrationStatus( + deckCalibration=DeckCalibrationStatus( + status=hardware.validate_calibration(), + data=robot_conf.gantry_calibration), + instrumentCalibration=robot_conf.instrument_offset) diff --git a/robot-server/robot_server/service/legacy/routers/health.py b/robot-server/robot_server/service/legacy/routers/health.py index 49a8d79aa64..607edd71bec 100644 --- a/robot-server/robot_server/service/legacy/routers/health.py +++ b/robot-server/robot_server/service/legacy/routers/health.py @@ -25,9 +25,9 @@ async def get_health( hardware: ThreadManager = Depends(get_hardware)) -> Health: static_paths = ['/logs/serial.log', '/logs/api.log'] - # This conditional handles the case where we have just changed the - # use protocol api v2 feature flag, so it does not match the type - # of hardware we're actually using. + # This conditional handles the case where we have just changed + # the use protocol api v2 feature flag, so it does not match + # the type of hardware we're actually using. fw_version = hardware.fw_version # type: ignore if inspect.isawaitable(fw_version): fw_version = await fw_version diff --git a/robot-server/tests/integration/test_deck_calibration.tavern.yaml b/robot-server/tests/integration/test_deck_calibration.tavern.yaml index b3643a63cf9..0d1de77d0e8 100644 --- a/robot-server/tests/integration/test_deck_calibration.tavern.yaml +++ b/robot-server/tests/integration/test_deck_calibration.tavern.yaml @@ -197,4 +197,36 @@ stages: response: status_code: 418 json: - message: "Session must be started before issuing commands" \ No newline at end of file + message: "Session must be started before issuing commands" +--- +test_name: Get calibration status +marks: + - usefixtures: + - run_server +stages: + - name: Get the status + request: + url: "{host:s}:{port:d}/calibration/status" + method: GET + response: + status_code: 200 + json: + deckCalibration: + status: IDENTITY + data: + - &matrix_row + - !anyfloat + - !anyfloat + - !anyfloat + - !anyfloat + - *matrix_row + - *matrix_row + - *matrix_row + instrumentCalibration: + right: &inst + single: &vector + - !anyfloat + - !anyfloat + - !anyfloat + multi: *vector + left: *inst
{CALIBRATE_DECK_DESCRIPTION}
Control lights on deck.