From 3bde4a1c433f80a655a26056020568880fd3c87d Mon Sep 17 00:00:00 2001 From: David Chau Date: Tue, 21 Jan 2025 20:21:39 -0500 Subject: [PATCH 1/3] Proposal for adding Python to step generation --- .../docs/PYTHON_STEP_GENERATION.md | 110 ++++++++++++++++++ .../src/file-data/selectors/fileCreator.ts | 10 ++ .../src/commandCreators/atomic/aspirate.ts | 1 + .../src/commandCreators/compound/mix.ts | 85 +++++++++----- step-generation/src/types.ts | 2 + .../src/utils/commandCreatorsTimeline.ts | 1 + .../src/utils/curryCommandCreator.ts | 17 ++- .../src/utils/reduceCommandCreators.ts | 7 ++ 8 files changed, 199 insertions(+), 34 deletions(-) create mode 100644 protocol-designer/docs/PYTHON_STEP_GENERATION.md diff --git a/protocol-designer/docs/PYTHON_STEP_GENERATION.md b/protocol-designer/docs/PYTHON_STEP_GENERATION.md new file mode 100644 index 00000000000..95e62e54d31 --- /dev/null +++ b/protocol-designer/docs/PYTHON_STEP_GENERATION.md @@ -0,0 +1,110 @@ +# Python Step Generation + +We want to add Python generation to `step-generation` without severely changing the architecture and rewriting all the code. + +## Where does the Python go? + +The command creators produce a `CommandCreatorResult`. We'll augment that to include Python commands: + +```typescript +export type CommandCreatorResult = + | CommandsAndWarnings + | CommandCreatorErrorResponse + +export interface CommandsAndWarnings { + commands: CreateCommand[] + warnings?: CommandCreatorWarning[] + python?: string // << { + const result = commandCreators.reduce( + (prev: CCReducerAcc, reducerFn: CurriedCommandCreator): CCReducerAcc => { + const allCommands = [...prev.commands, ...next.commands] + const allPython = [prev.python, next.python].join('\n') // << = (args, invariantContext, prevRobotState) => { + return { + commands: [ { commandType: 'aspirate', params: {...} } ], + python: `some_pipette.aspirate(...)`, + } +} +``` + +Sometimes, we want to emit a Python command that doesn't correspond to any single JSON command. For example, the command sequence for a Mix step has something like: + +```typescript +[ + curryCommandCreator(aspirate, {...}), + curryCommandCreator(dispense, {...}), +] +``` + +The Python API has a `mix()` that implements both aspirate and dispense. We can generate it by adding a `CommandCreator` that emits the Python `mix()` command with no JSON command: + +```typescript +[ + curryCommandCreator(pythonOnlyMix, {...}), + curryCommandCreator(aspirate, {...}), + curryCommandCreator(dispense, {...}), +] + +const pythonOnlyMix: CommandCreator<...> = (...) => { + return { + commands: [], // emits no JSON + python: `some_pipette.mix(...)`, + } +} +``` + +When the reducer runs, it strings together all the non-empty JSON `commands` to get the final JSON output, and it'll string together all the non-empty `python` commands to get the final Python output. + +We need one more tool to make this work: because the Python `mix()` command replaces both the aspirate and dispense, we need to _suppress_ Python generation from aspirate and dispense. We'll do that by adding a new flag to `curryCommandCreator`, so the final sequence becomes: + +```typescript +[ + curryCommandCreator(pythonOnlyMix, {...}), + curryCommandCreator(aspirate, {...}, suppressPython=true), + curryCommandCreator(dispense, {...}, suppressPython=true), +] +``` + +Now this sequence works for generating both JSON and Python. diff --git a/protocol-designer/src/file-data/selectors/fileCreator.ts b/protocol-designer/src/file-data/selectors/fileCreator.ts index 89b059e8fc7..cfea68cb927 100644 --- a/protocol-designer/src/file-data/selectors/fileCreator.ts +++ b/protocol-designer/src/file-data/selectors/fileCreator.ts @@ -356,6 +356,16 @@ export const createFile: Selector = createSelector( const commands = [...loadCommands, ...nonLoadCommands] + // Print the combined Python commands for every timeline step: + console.log( + robotStateTimeline.timeline + .map( + (timelineFrame, idx) => + `# Step ${idx + 1}\n${timelineFrame.python || ''}` + ) + .join('\n\n') + ) + const flexDeckSpec: OT3RobotMixin = { robot: { model: FLEX_ROBOT_TYPE, diff --git a/step-generation/src/commandCreators/atomic/aspirate.ts b/step-generation/src/commandCreators/atomic/aspirate.ts index 3d6726b8be2..aeebab70317 100644 --- a/step-generation/src/commandCreators/atomic/aspirate.ts +++ b/step-generation/src/commandCreators/atomic/aspirate.ts @@ -261,5 +261,6 @@ export const aspirate: CommandCreator = ( ] return { commands, + python: `blah.aspirate(volume=${volume}, ...etc...)`, } } diff --git a/step-generation/src/commandCreators/compound/mix.ts b/step-generation/src/commandCreators/compound/mix.ts index 15ceb73221c..b20a7042844 100644 --- a/step-generation/src/commandCreators/compound/mix.ts +++ b/step-generation/src/commandCreators/compound/mix.ts @@ -68,6 +68,14 @@ export function mixUtil(args: { nozzles, } = args + // This CommandCreator produces a Python command with no JSON command. + const pythonMixCommand: CommandCreator<{}> = () => { + return { + commands: [], + python: `blah.mix(repititions=${times}, location=blah.['${well}'], volume=${volume}, ...etc...)`, + } + } + const getDelayCommand = (seconds?: number | null): CurriedCommandCreator[] => seconds ? [ @@ -81,37 +89,52 @@ export function mixUtil(args: { ] : [] - return repeatArray( - [ - curryCommandCreator(aspirate, { - pipette, - volume, - labware, - well, - offsetFromBottomMm: aspirateOffsetFromBottomMm, - flowRate: aspirateFlowRateUlSec, - tipRack, - xOffset: aspirateXOffset, - yOffset: aspirateYOffset, - nozzles: null, - }), - ...getDelayCommand(aspirateDelaySeconds), - curryCommandCreator(dispense, { - pipette, - volume, - labware, - well, - offsetFromBottomMm: dispenseOffsetFromBottomMm, - flowRate: dispenseFlowRateUlSec, - xOffset: dispenseXOffset, - yOffset: dispenseYOffset, - tipRack, - nozzles: nozzles, - }), - ...getDelayCommand(dispenseDelaySeconds), - ], - times - ) + // If there is no delay, then we can issue a single Python mix() command, so we want to suppresss + // the Python from the individual aspirate dispense commands. + const noDelay = !aspirateDelaySeconds && !dispenseDelaySeconds + + return [ + ...(noDelay ? [curryCommandCreator(pythonMixCommand, {})] : []), + ...repeatArray( + [ + curryCommandCreator( + aspirate, + { + pipette, + volume, + labware, + well, + offsetFromBottomMm: aspirateOffsetFromBottomMm, + flowRate: aspirateFlowRateUlSec, + tipRack, + xOffset: aspirateXOffset, + yOffset: aspirateYOffset, + nozzles: null, + }, + noDelay + ), + ...getDelayCommand(aspirateDelaySeconds), + curryCommandCreator( + dispense, + { + pipette, + volume, + labware, + well, + offsetFromBottomMm: dispenseOffsetFromBottomMm, + flowRate: dispenseFlowRateUlSec, + xOffset: dispenseXOffset, + yOffset: dispenseYOffset, + tipRack, + nozzles: nozzles, + }, + noDelay + ), + ...getDelayCommand(dispenseDelaySeconds), + ], + times + ), + ] } export const mix: CommandCreator = ( data, diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index 881fda45e42..2171027ca99 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -624,6 +624,7 @@ export interface CommandsAndRobotState { commands: CreateCommand[] robotState: RobotState warnings?: CommandCreatorWarning[] + python?: string } export interface CommandCreatorErrorResponse { @@ -634,6 +635,7 @@ export interface CommandCreatorErrorResponse { export interface CommandsAndWarnings { commands: CreateCommand[] warnings?: CommandCreatorWarning[] + python?: string } export type CommandCreatorResult = | CommandsAndWarnings diff --git a/step-generation/src/utils/commandCreatorsTimeline.ts b/step-generation/src/utils/commandCreatorsTimeline.ts index 878368e6a25..c5728a754ae 100644 --- a/step-generation/src/utils/commandCreatorsTimeline.ts +++ b/step-generation/src/utils/commandCreatorsTimeline.ts @@ -53,6 +53,7 @@ export const commandCreatorsTimeline = ( commands: commandCreatorResult.commands, robotState: nextRobotStateAndWarnings.robotState, warnings: commandCreatorResult.warnings, + python: commandCreatorResult.python, } return { timeline: [...acc.timeline, nextResult], diff --git a/step-generation/src/utils/curryCommandCreator.ts b/step-generation/src/utils/curryCommandCreator.ts index 32f02ed4746..77deb8d4fd8 100644 --- a/step-generation/src/utils/curryCommandCreator.ts +++ b/step-generation/src/utils/curryCommandCreator.ts @@ -4,8 +4,19 @@ import type { CommandCreator, CurriedCommandCreator } from '../types' * but it is still open to receiving different input states */ export function curryCommandCreator( commandCreator: CommandCreator, - args: Args + args: Args, + suppressPython?: boolean ): CurriedCommandCreator { - return (_invariantContext, _prevRobotState) => - commandCreator(args, _invariantContext, _prevRobotState) + return (_invariantContext, _prevRobotState) => { + const commandCreatorResult = commandCreator( + args, + _invariantContext, + _prevRobotState + ) + if (suppressPython && 'python' in commandCreatorResult) { + const { python, ...withoutPython } = commandCreatorResult + return withoutPython + } + return commandCreatorResult + } } diff --git a/step-generation/src/utils/reduceCommandCreators.ts b/step-generation/src/utils/reduceCommandCreators.ts index 03a5814b46d..bac0b15dfda 100644 --- a/step-generation/src/utils/reduceCommandCreators.ts +++ b/step-generation/src/utils/reduceCommandCreators.ts @@ -13,6 +13,7 @@ interface CCReducerAcc { commands: CreateCommand[] errors: CommandCreatorError[] warnings: CommandCreatorWarning[] + python?: string } export const reduceCommandCreators = ( commandCreators: CurriedCommandCreator[], @@ -41,6 +42,10 @@ export const reduceCommandCreators = ( invariantContext, prev.robotState ) + const allPython = [ + ...(prev.python ? [prev.python] : []), + ...(next.python ? [next.python] : []), + ].join('\n') return { ...prev, robotState: updates.robotState, @@ -50,6 +55,7 @@ export const reduceCommandCreators = ( ...(next.warnings || []), ...updates.warnings, ], + ...(allPython && { python: allPython }), } }, { @@ -69,5 +75,6 @@ export const reduceCommandCreators = ( return { commands: result.commands, warnings: result.warnings, + python: result.python, } } From 515bcb715ff5e72faef67ba753e133a909051ed6 Mon Sep 17 00:00:00 2001 From: David Chau Date: Wed, 22 Jan 2025 17:23:34 -0500 Subject: [PATCH 2/3] Edits --- protocol-designer/docs/PYTHON_STEP_GENERATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol-designer/docs/PYTHON_STEP_GENERATION.md b/protocol-designer/docs/PYTHON_STEP_GENERATION.md index 95e62e54d31..89a0ab57d67 100644 --- a/protocol-designer/docs/PYTHON_STEP_GENERATION.md +++ b/protocol-designer/docs/PYTHON_STEP_GENERATION.md @@ -40,7 +40,7 @@ export const reduceCommandCreators = (...): CommandCreatorResult => { ## Data flow -The JSON commands in the `CommandCreatorResult`s get propagated to the Timeline, which is where we ultimately get the commands from to write out to the exported JSON file. By analogy, we'll add the Python commands to the Timeline as well: +The JSON commands from the `CommandCreatorResult`s get propagated to the `Timeline`, which is where we ultimately get the commands from to write out to the exported JSON file. By analogy, we'll add the Python commands to the `Timeline` as well: ```typescript export interface Timeline { @@ -95,7 +95,7 @@ const pythonOnlyMix: CommandCreator<...> = (...) => { } ``` -When the reducer runs, it strings together all the non-empty JSON `commands` to get the final JSON output, and it'll string together all the non-empty `python` commands to get the final Python output. +When the reducer runs, it joins together all the non-empty JSON `commands` to get the final JSON output, and it'll join together all the non-empty `python` commands to get the final Python output. We need one more tool to make this work: because the Python `mix()` command replaces both the aspirate and dispense, we need to _suppress_ Python generation from aspirate and dispense. We'll do that by adding a new flag to `curryCommandCreator`, so the final sequence becomes: From 73c2b7232d4608ff8c85626dae1e864cd9b9812e Mon Sep 17 00:00:00 2001 From: David Chau Date: Wed, 22 Jan 2025 18:41:46 -0500 Subject: [PATCH 3/3] Refactor --- .../src/commandCreators/compound/mix.ts | 76 +++++++++---------- .../src/utils/curryCommandCreator.ts | 13 +++- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/step-generation/src/commandCreators/compound/mix.ts b/step-generation/src/commandCreators/compound/mix.ts index b20a7042844..5bad785fbba 100644 --- a/step-generation/src/commandCreators/compound/mix.ts +++ b/step-generation/src/commandCreators/compound/mix.ts @@ -28,6 +28,7 @@ import type { CommandCreator, CurriedCommandCreator, } from '../../types' +import { curryCommandCreatorNoPython } from '../../utils/curryCommandCreator' /** Helper fn to make mix command creators w/ minimal arguments */ export function mixUtil(args: { pipette: string @@ -92,48 +93,45 @@ export function mixUtil(args: { // If there is no delay, then we can issue a single Python mix() command, so we want to suppresss // the Python from the individual aspirate dispense commands. const noDelay = !aspirateDelaySeconds && !dispenseDelaySeconds + const innerCurryCommandCreator = noDelay + ? curryCommandCreatorNoPython + : curryCommandCreator + + const commands = repeatArray( + [ + innerCurryCommandCreator(aspirate, { + pipette, + volume, + labware, + well, + offsetFromBottomMm: aspirateOffsetFromBottomMm, + flowRate: aspirateFlowRateUlSec, + tipRack, + xOffset: aspirateXOffset, + yOffset: aspirateYOffset, + nozzles: null, + }), + ...getDelayCommand(aspirateDelaySeconds), + innerCurryCommandCreator(dispense, { + pipette, + volume, + labware, + well, + offsetFromBottomMm: dispenseOffsetFromBottomMm, + flowRate: dispenseFlowRateUlSec, + xOffset: dispenseXOffset, + yOffset: dispenseYOffset, + tipRack, + nozzles: nozzles, + }), + ...getDelayCommand(dispenseDelaySeconds), + ], + times + ) return [ ...(noDelay ? [curryCommandCreator(pythonMixCommand, {})] : []), - ...repeatArray( - [ - curryCommandCreator( - aspirate, - { - pipette, - volume, - labware, - well, - offsetFromBottomMm: aspirateOffsetFromBottomMm, - flowRate: aspirateFlowRateUlSec, - tipRack, - xOffset: aspirateXOffset, - yOffset: aspirateYOffset, - nozzles: null, - }, - noDelay - ), - ...getDelayCommand(aspirateDelaySeconds), - curryCommandCreator( - dispense, - { - pipette, - volume, - labware, - well, - offsetFromBottomMm: dispenseOffsetFromBottomMm, - flowRate: dispenseFlowRateUlSec, - xOffset: dispenseXOffset, - yOffset: dispenseYOffset, - tipRack, - nozzles: nozzles, - }, - noDelay - ), - ...getDelayCommand(dispenseDelaySeconds), - ], - times - ), + ...commands, ] } export const mix: CommandCreator = ( diff --git a/step-generation/src/utils/curryCommandCreator.ts b/step-generation/src/utils/curryCommandCreator.ts index 77deb8d4fd8..4600a1ff06e 100644 --- a/step-generation/src/utils/curryCommandCreator.ts +++ b/step-generation/src/utils/curryCommandCreator.ts @@ -4,8 +4,15 @@ import type { CommandCreator, CurriedCommandCreator } from '../types' * but it is still open to receiving different input states */ export function curryCommandCreator( commandCreator: CommandCreator, - args: Args, - suppressPython?: boolean + args: Args +): CurriedCommandCreator { + return (_invariantContext, _prevRobotState) => + commandCreator(args, _invariantContext, _prevRobotState) +} + +export function curryCommandCreatorNoPython( + commandCreator: CommandCreator, + args: Args ): CurriedCommandCreator { return (_invariantContext, _prevRobotState) => { const commandCreatorResult = commandCreator( @@ -13,7 +20,7 @@ export function curryCommandCreator( _invariantContext, _prevRobotState ) - if (suppressPython && 'python' in commandCreatorResult) { + if ('python' in commandCreatorResult) { const { python, ...withoutPython } = commandCreatorResult return withoutPython }