From 8920578a860a82e960770a63897bcb1e56103af0 Mon Sep 17 00:00:00 2001 From: David Chau Date: Wed, 18 Dec 2024 19:16:53 -0500 Subject: [PATCH] Demo of Python export from PD. --- .../__tests__/pythonProtocol.test.tsx | 17 + .../src/file-data/selectors/fileCreator.ts | 15 + .../src/file-data/selectors/pythonProtocol.ts | 364 ++++++++++++++++++ protocol-designer/src/load-file/utils.ts | 5 + 4 files changed, 401 insertions(+) create mode 100644 protocol-designer/src/file-data/__tests__/pythonProtocol.test.tsx create mode 100644 protocol-designer/src/file-data/selectors/pythonProtocol.ts diff --git a/protocol-designer/src/file-data/__tests__/pythonProtocol.test.tsx b/protocol-designer/src/file-data/__tests__/pythonProtocol.test.tsx new file mode 100644 index 00000000000..f70bfaa577d --- /dev/null +++ b/protocol-designer/src/file-data/__tests__/pythonProtocol.test.tsx @@ -0,0 +1,17 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import genPythonProtocol from '../selectors/pythonProtocol' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data'; + +describe('Python', () => { + + it('should render Python protocol', () => { + console.log(genPythonProtocol({ + fileMetadata: { + protocolName: 'My Protocol', + author: [1.23, 'cat', true, false, null, undefined, -1./0, {nested: 42, hm: 43}, 0./0], + }, + robotType: FLEX_ROBOT_TYPE, + })); + }); + +}) diff --git a/protocol-designer/src/file-data/selectors/fileCreator.ts b/protocol-designer/src/file-data/selectors/fileCreator.ts index 89b059e8fc7..d9103481f49 100644 --- a/protocol-designer/src/file-data/selectors/fileCreator.ts +++ b/protocol-designer/src/file-data/selectors/fileCreator.ts @@ -59,6 +59,7 @@ import type { LabwareDefByDefURI } from '../../labware-defs' import type { Selector } from '../../types' import type { DesignerApplicationData } from '../../load-file/migration/utils/getLoadLiquidCommands' import type { SecondOrderCommandAnnotation } from '@opentrons/shared-data/commandAnnotation/types' +import genPythonProtocol from './pythonProtocol' // TODO: BC: 2018-02-21 uncomment this assert, causes test failures // console.assert(!isEmpty(process.env.OT_PD_VERSION), 'Could not find application version!') @@ -432,6 +433,19 @@ export const createFile: Selector = createSelector( designerApplication, } + const pythonProtocol = genPythonProtocol( + fileMetadata, + robotType, + initialRobotState, + labwareEntities, + labwareNicknamesById, + moduleEntities, + pipetteEntities, + ingredients, + ingredLocations, + ); + console.log(pythonProtocol); + return { ...protocolBase, ...deckStructure, @@ -439,6 +453,7 @@ export const createFile: Selector = createSelector( ...liquidV1Mixin, ...commandv8Mixin, ...commandAnnotionaV1Mixin, + PYTHONFILE: pythonProtocol, } } ) diff --git a/protocol-designer/src/file-data/selectors/pythonProtocol.ts b/protocol-designer/src/file-data/selectors/pythonProtocol.ts new file mode 100644 index 00000000000..f911a4bd6a4 --- /dev/null +++ b/protocol-designer/src/file-data/selectors/pythonProtocol.ts @@ -0,0 +1,364 @@ +import { File } from 'vitest'; +import type { FileMetadataFields } from '../types' +import { FLEX_ROBOT_TYPE, LabwareDisplayCategory, LoadLabwareCreateCommand, OT2_ROBOT_TYPE, RobotType } from '@opentrons/shared-data'; +import { LabwareEntities, RobotState, ModuleState, PipetteEntities, LabwareLiquidState } from '@opentrons/step-generation'; +import { ModuleEntities } from '../../step-forms'; +import { LiquidGroupsById } from '../../labware-ingred/types' + +// Generally, any text emitted in the Python file is quoted with ``. +// Functions that generate Python are named genSomething(). + +const INDENT = ` `; + +function indentLines(text: string) { + return text.split('\n').map(line => line.length ? INDENT + line : line).join(`\n`); +} + +export default function genPythonProtocol( + fileMetadata: FileMetadataFields, + robotType: RobotType, + robotState: RobotState, + labwareEntities: LabwareEntities, + labwareNicknamesById: Record, + moduleEntities: ModuleEntities, + pipetteEntities: PipetteEntities, + liquidGroups: LiquidGroupsById, + liquidState: LabwareLiquidState, +): string { + console.log('Robot state', robotState); + return [ + genImports(), + genMetadata(fileMetadata), + genRequirements(robotType), + genDefRun(robotState, labwareEntities, labwareNicknamesById, moduleEntities, pipetteEntities, liquidGroups, liquidState), + ].join(`\n\n`); +} + +export function genImports(): string { + return [ + `from opentrons import protocol_api`, + // `from opentrons.types import Mount`, + ].join(`\n`); +} + +export function genMetadata(metadata: FileMetadataFields): string { + // FileMetadataFields is not usable directly because Python only allows string fields, not numbers or None. + const pythonMetadata = { + ...metadata.protocolName && {protocolName: metadata.protocolName}, + ...metadata.author && {author: metadata.author}, + ...metadata.description && {description: metadata.description}, + ...metadata.created && {created: new Date(metadata.created).toString()}, + ...metadata.lastModified && {lastModified: new Date(metadata.lastModified).toString()}, + ...metadata.category && {category: metadata.category}, + ...metadata.subcategory && {subcategory: metadata.subcategory}, + ...metadata.tags?.length && {tags: metadata.tags.join(`, `)}, + } + return `metadata = ${genPyDict(pythonMetadata)}`; +} + +export function genRequirements(robotType: RobotType): string { + const ROBOTTYPE_TO_PAPI_NAME = { + [OT2_ROBOT_TYPE]: 'OT-2', + [FLEX_ROBOT_TYPE]: 'Flex', + } + const requirements = {robotType: ROBOTTYPE_TO_PAPI_NAME[robotType], apiLevel: '2.21'} + return `requirements = ${genPyDict(requirements)}`; +} + +export function genDefRun( + robotState: RobotState, + labwareEntities: LabwareEntities, + labwareNicknamesById: Record, + moduleEntities: ModuleEntities, + pipetteEntities: PipetteEntities, + liquidGroups: LiquidGroupsById, + liquidState: LabwareLiquidState, +): string { + const protocolContextName = 'protocol'; + // Pick nice names for all the labware. + const moduleIdToPythonName = pythonNamesForModules(robotState.modules ?? {}); + const labwareIdToPythonName = pythonNamesForLabware(robotState.labware ?? {}, labwareEntities); + const pipetteIdToPythonName = pythonNamesForPipettes(robotState.pipettes ?? {}); + const liquidIdToPythonName = pythonNamesForLiquids(liquidGroups ?? {}); + + console.log(liquidState); + + return `def run(${protocolContextName}: protocol_api.ProtocolContext):\n${indentLines( + [ + genLoadModules(robotState.modules, moduleEntities, protocolContextName, moduleIdToPythonName), + genLoadLabware(robotState.labware, labwareEntities, labwareNicknamesById, protocolContextName, labwareIdToPythonName, moduleIdToPythonName), + genLoadInstruments(robotState.pipettes, pipetteEntities, protocolContextName, pipetteIdToPythonName, labwareIdToPythonName), + genDefineLiquids(liquidGroups, protocolContextName, liquidIdToPythonName), + genLoadLiquids(liquidGroups, liquidState, labwareIdToPythonName, liquidIdToPythonName), + ].join(`\n\n`) + )}`; +} + +export function genLoadModules( + robotStateModules: RobotState['modules'], + moduleEntities: ModuleEntities, + protocolContextName: string, + moduleIdToPythonName: Record, +): string { + if (!robotStateModules) { return ``; } + const loadModulesCode = Object.entries(robotStateModules).map( + ([moduleId, moduleProps]) => `${moduleIdToPythonName[moduleId]} = ${protocolContextName}.load_module(${[ + genPyStr(moduleEntities[moduleId].model), // TODO: Find correct names! + genPyStr(moduleProps.slot), + ].join(`, `)})` + ).join(`\n`); + return `# Load modules.\n` + loadModulesCode; +} + +export function genLoadLabware( + robotStateLabware: RobotState['labware'], + labwareEntities: LabwareEntities, + labwareNicknamesById: Record, + protocolContextName: string, + labwareIdToPythonName: Record, + moduleIdToPythonName: Record, +): string { + // From the robotStateLabware, we have to generate commands to load adapters, labware, trashbins. + + // Sort adapters before other modules because other modules can be loaded onto adapters: + // Maybe it's already sorted? + // const labwareWithAdaptersFirst = Object.entries(robotState.labware).toSorted( + // ([labwareAId], [labwareBId]) => { + // const labwareAIsAdapter = labwareEntities[labwareAId].def.allowedRoles?.includes('adapter'); + // const labwareBIsAdapter = labwareEntities[labwareBId].def.allowedRoles?.includes('adapter'); + // return +!!labwareBIsAdapter - +!!labwareAIsAdapter; // ugh + // } + // ); + + const loadLabwareCode = Object.entries(robotStateLabware).map( + ([labwareId, labwareProps]) => { + const labwareEnt = labwareEntities[labwareId].def; + const isAdapter = labwareEnt.allowedRoles?.includes('adapter'); + const loadOnto = labwareIdToPythonName[labwareProps.slot] || moduleIdToPythonName[labwareProps.slot]; + return `${labwareIdToPythonName[labwareId]} = ${loadOnto || protocolContextName}.load_${isAdapter ? `adapter`: `labware`}(\n` + + indentLines([ + genPyStr(labwareEnt.parameters.loadName), + ...(!loadOnto ? [genPyStr(labwareProps.slot)]: []), + ...(!isAdapter && labwareNicknamesById[labwareId] ? [`label=${genPyStr(labwareNicknamesById[labwareId])}`]: []), + `namespace=${genPyStr(labwareEnt.namespace)}`, + `version=${labwareEnt.version}`, + ].join(`,\n`)) + + `,\n)` + } + ).join(`\n`); + + // TODO: TRASH BINS! + + return `# Load labware.\n` + loadLabwareCode; +} + +// From _PIPETTE_NAMES_MAP in api/src/opentrons/protocol_api/validation.py: +const PIPETTE_NAME_TO_PAPI_LOAD_NAME = { + 'p10_single': 'p10_single', + 'p10_multi': 'p10_multi', + 'p20_single_gen2': 'p20_single_gen2', + 'p20_multi_gen2': 'p20_multi_gen2', + 'p50_single': 'p50_single', + 'p50_multi': 'p50_multi', + 'p300_single': 'p300_single', + 'p300_multi': 'p300_multi', + 'p300_single_gen2': 'p300_single_gen2', + 'p300_multi_gen2': 'p300_multi_gen2', + 'p1000_single': 'p1000_single', + 'p1000_single_gen2': 'p1000_single_gen2', + 'p50_single_flex': 'flex_1channel_50', + 'p50_multi_flex': 'flex_8channel_50', + 'p1000_single_flex': 'flex_1channel_1000', + 'p1000_multi_flex': 'flex_8channel_1000', + 'p1000_multi_em_flex': 'flex_8channel_1000_em', + 'p1000_96': 'flex_96channel_1000', + 'p200_96': 'flex_96channel_200', +} + +function genLoadInstruments( + robotStatePipettes: RobotState['pipettes'], + pipetteEntities: PipetteEntities, + protocolContextName: string, + pipetteIdToPythonName: Record, + labwareIdToPythonName: Record, +): string { + // The labwareIdToPythonName look like `UUID:tiprackURI`. We need the tiprackURI without the UUID. + const labwareUriToPythonName = Object.fromEntries(Object.entries(labwareIdToPythonName).map( + ([labwareId, pythonName]) => [labwareId.replace(/^[^:]*:/, ''), pythonName] + )); + const loadInstrumentsCode = Object.entries(robotStatePipettes).map( + ([pipetteId, pipetteProps]) => { + const pipetteEnt = pipetteEntities[pipetteId]; + return `${pipetteIdToPythonName[pipetteId]} = ${protocolContextName}.load_instrument(${[ + genPyStr(PIPETTE_NAME_TO_PAPI_LOAD_NAME[pipetteEnt.name]), + genPyStr(pipetteProps.mount), + ...( + pipetteEnt.tiprackDefURI.length ? + [`tip_racks=[${pipetteEnt.tiprackDefURI.map(tiprackId => labwareUriToPythonName[tiprackId]).join(`, `)}]`] : + [] + ), + ].join(`, `)})`; + }).join(`\n`); + return `# Load pipettes.\n` + loadInstrumentsCode; + + // load_instrument(self, instrument_name: 'str', mount: 'Union[Mount, str, None]' = None, tip_racks: 'Optional[List[Labware]]' = None, replace: 'bool' = False, liquid_presence_detection: 'Optional[bool]' = None) → 'InstrumentContext' + + // pipetteTiprackAssignments: mapValues( + // pipetteEntities, + // (p: typeof pipetteEntities[keyof typeof pipetteEntities]): string[] => + // p.tiprackDefURI + // ), +} + +function genDefineLiquids( + liquidGroups: LiquidGroupsById, + protocolContextName: string, + liquidIdToPythonName: Record, +): string { + const defineLiquidsCode = Object.entries(liquidGroups).map( + ([liquidId, liquidGroup]) => { + return `${liquidIdToPythonName[liquidId]} = ${protocolContextName}.define_liquid(\n` + + indentLines([ + genPyStr(liquidGroup.name || `Liquid ${liquidId}`), + ...(liquidGroup.description ? [`description=${genPyStr(liquidGroup.description)}`] : []), + ...(liquidGroup.displayColor ? [`display_color=${genPyStr(liquidGroup.displayColor)}`] : []), + ].join(`,\n`)) + + `,\n)` + } + ).join(`\n`); + return `# Define liquids.\n` + defineLiquidsCode; +} + +function genLoadLiquids( + liquidGroups: LiquidGroupsById, + liquidState: LabwareLiquidState, + labwareIdToPythonName: Record, + liquidIdToPythonName: Record +): string { + console.log(liquidState); + const loadLiquidsCode = Object.entries(liquidState).map( + ([labwareId, labwareLiquidState]) => Object.entries(labwareLiquidState).map( + ([well, wellLiquidState]) => Object.entries(wellLiquidState).map( + ([liquidId, {volume}]) => `${labwareIdToPythonName[labwareId]}[${genPyStr(well)}].load_liquid(${liquidIdToPythonName[liquidId]}, ${volume})` + ).join(`\n`) + ).join(`\n`) + ).join(`\n`); + return `# Specify initial liquids in wells.\n` + loadLiquidsCode; +} + + +function pythonNamesForLabware( + robotStateLabware: RobotState['labware'], + labwareEntities: LabwareEntities, +): Record { + // We will use names like `wellplate_1`, where `wellplate` are the types from LabwareDisplayCategory. + const labwareIds = Object.keys(robotStateLabware); + const labwareCountsByCategory: Partial> = labwareIds.map( + labwareId => labwareEntities[labwareId].def.metadata.displayCategory + ).reduce>>( + (counts, category) => { counts[category] = (counts[category] ?? 0) + 1; return counts; }, + {} + ); + const labwareIdxByCategory: Partial> = {}; + return Object.fromEntries(labwareIds.map( + labwareId => { + const category = labwareEntities[labwareId].def.metadata.displayCategory; + const countForCategory = labwareCountsByCategory[category] ?? 0; + const idx = labwareIdxByCategory[category] = (labwareIdxByCategory[category] ?? 0) + 1; + return [labwareId, category.toLowerCase() + (countForCategory > 1 ? `_${idx}` : '')]; + } + )); +} + +function pythonNamesForModules( + robotStateModules: RobotState['modules'], +): Record { + // We will use names like `wellplate_1`, where `wellplate` are the types from LabwareDisplayCategory. + const moduleCountsByType = Object.values(robotStateModules).map( + module => module.moduleState.type + ).reduce>>( + (counts, moduleType) => { counts[moduleType] = (counts[moduleType] ?? 0) + 1; return counts; }, + {} + ); + const moduleIdxByType: Partial> = {}; + return Object.fromEntries(Object.entries(robotStateModules).map( + ([moduleId, module]) => { + const moduleType = module.moduleState.type; + const countForType = moduleCountsByType[moduleType] ?? 0; + const idx = moduleIdxByType[moduleType] = (moduleIdxByType[moduleType] ?? 0) + 1; + const pythonModuleType = ( + moduleType.endsWith('Type') ? moduleType.slice(0, -4): moduleType + ).replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); + return [moduleId, pythonModuleType + (countForType > 1 ? `_${idx}` : '')]; + } + )); +} + +function pythonNamesForPipettes( + robotStatePipettes: RobotState['pipettes'] +): Record { + // If 1 pipette, just call it `pipette`, else call them `pipette_left` and `pipette_right`. + const pipetteEntries = Object.entries(robotStatePipettes); + if (pipetteEntries.length === 0) { + return {}; + } else if (pipetteEntries.length == 1) { + const [pipetteId] = pipetteEntries[0]; + return {[pipetteId]: 'pipette'}; + } else { + return Object.fromEntries( + pipetteEntries.map(([pipetteId, pipette]) => [pipetteId, `pipette_${pipette.mount}`]) + ); + } +} + +function pythonNamesForLiquids( + liquidGroups: LiquidGroupsById, +): Record { + // TODO: Do something fancier + return Object.fromEntries(Object.entries(liquidGroups).map( + ([liquidId, liquidGroup]) => [liquidId, `liquid_${parseInt(liquidId) + 1}`] + )); +} + +export function genPyValue(value: any): string { + switch (typeof value) { + case 'undefined': + return `None`; + case 'boolean': + return value ? `True` : `False`; + case 'number': // float("-Infinity") and float("Nan") are valid in Python + return Number.isFinite(value) ? `${value}` : `float("${value}")`; + case 'string': + return genPyStr(value); + case 'object': + if (value === null) { + return `None`; + } else if (Array.isArray(value)) { + return genPyList(value) + } else { + return genPyDict(value); + } + default: + throw Error('Cannot render value as Python', {cause: value}); + } +} + +export function genPyList(list: Array): string { + return `[${list.map(value => genPyValue(value)).join(`, `)}]` +} + +export function genPyDict(dict: Record): string { + const dictEntries = Object.entries(dict); + const openingBrace = dictEntries.length > 1 ? `{\n` : `{`; + const closingBrace = dictEntries.length > 1 ? `,\n}` : `}`; + const separator = dictEntries.length > 1 ? `,\n`: `, `; + const entriesString = dictEntries.map( + ([key, value]) => `${genPyStr(key)}: ${genPyValue(value)}` + ).join(separator); + const indentedEntries = dictEntries.length > 1 ? indentLines(entriesString) : entriesString; + return `${openingBrace}${indentedEntries}${closingBrace}`; +} + +export function genPyStr(str: string): string { + return JSON.stringify(str); // takes care of escaping +} diff --git a/protocol-designer/src/load-file/utils.ts b/protocol-designer/src/load-file/utils.ts index 0d90087a4dd..c8adca16c49 100644 --- a/protocol-designer/src/load-file/utils.ts +++ b/protocol-designer/src/load-file/utils.ts @@ -1,6 +1,11 @@ import { saveAs } from 'file-saver' import type { ProtocolFile } from '@opentrons/shared-data' export const saveFile = (fileData: ProtocolFile, fileName: string): void => { + const pyBlob = new Blob([fileData.PYTHONFILE + `\n`], { type: 'text/x-python;charset=UTF-8' }); + var fileURL = URL.createObjectURL(pyBlob); + window.open(fileURL, '_blank'); + return; + const blob = new Blob([JSON.stringify(fileData, null, 2)], { type: 'application/json', })