Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs(protocol-designer): proposal for adding Python to step generation #17330

Open
wants to merge 3 commits into
base: edge
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions protocol-designer/docs/PYTHON_STEP_GENERATION.md
Original file line number Diff line number Diff line change
@@ -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 // <<<ADD
}
```

Here and elsewhere, we make `python` an optional field so that we don't have to rewrite all the existing code to specify it when creating objects.

The new `python` field contains one or more lines of Python commands. It behaves analogously to the JSON `commands`. When reducing `CurriedCommandCreator`s together, we concatenate their JSON `commands`, so now we will also concatenate their `python` commands too:

```typescript
export const reduceCommandCreators = (...): CommandCreatorResult => {
const result = commandCreators.reduce(
(prev: CCReducerAcc, reducerFn: CurriedCommandCreator): CCReducerAcc => {
const allCommands = [...prev.commands, ...next.commands]
const allPython = [prev.python, next.python].join('\n') // <<<NEW
return {
commands: allCommands,
python: allPython, // <<<NEW
}
},
...
)
}
```

## Data flow

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 {
timeline: CommandsAndRobotState[]
errors?: CommandCreatorError[] | null
}

export interface CommandsAndRobotState {
commands: CreateCommand[]
robotState: RobotState
warnings?: CommandCreatorWarning[]
python?: string // <<<ADD
}
```

## Generating JSON and Python in parallel

In the easy case, one JSON command corresponds to one Python command, and we can just emit them side-by-side, like:

```typescript
export const aspirate: CommandCreator<...> = (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 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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could also have new command creators for commands that are sometimes used to generate json but not python that emit empty python strings - the converse of pythonOnlyMix, so I guess jsonOnlyAspirate.

Copy link
Contributor Author

@ddcc4 ddcc4 Jan 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could also have new command creators for commands that are sometimes used to generate json but not python that emit empty python strings - the converse of pythonOnlyMix, so I guess jsonOnlyAspirate.

Hm, I'm worried that that could lead to a lot of duplicate code between the existing aspirate and jsonOnlyAspirate.

There's another approach I considered, but I don't know if you would consider it too intrusive: We could add a generatePython flag to the Args that are passed to the CommandCreators. This feels intrusive because generatePython isn't logically part of the step parameters, but I think it would work. (Adding generatePython to the Args is also messier because some of the Args classes don't share a common ancestor, so I'd have to add the flag in lots of places.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of these approaches, I sort of prefer suppressPython. I agree that a jsonOnlyAspirate could lead to a lot of duplicate code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that if you factored out the common parts of jsonOnlyAspirate and aspirate you wouldn't have that much duplicate code


```typescript
[
curryCommandCreator(pythonOnlyMix, {...}),
curryCommandCreator(aspirate, {...}, suppressPython=true),
curryCommandCreator(dispense, {...}, suppressPython=true),
]
```

Now this sequence works for generating both JSON and Python.
10 changes: 10 additions & 0 deletions protocol-designer/src/file-data/selectors/fileCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,16 @@ export const createFile: Selector<ProtocolFile> = 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,
Expand Down
1 change: 1 addition & 0 deletions step-generation/src/commandCreators/atomic/aspirate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,5 +261,6 @@ export const aspirate: CommandCreator<ExtendedAspirateParams> = (
]
return {
commands,
python: `blah.aspirate(volume=${volume}, ...etc...)`,
}
}
27 changes: 24 additions & 3 deletions step-generation/src/commandCreators/compound/mix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +69,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
? [
Expand All @@ -81,9 +90,16 @@ export function mixUtil(args: {
]
: []

return repeatArray(
// 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(
[
curryCommandCreator(aspirate, {
innerCurryCommandCreator(aspirate, {
pipette,
volume,
labware,
Expand All @@ -96,7 +112,7 @@ export function mixUtil(args: {
nozzles: null,
}),
...getDelayCommand(aspirateDelaySeconds),
curryCommandCreator(dispense, {
innerCurryCommandCreator(dispense, {
pipette,
volume,
labware,
Expand All @@ -112,6 +128,11 @@ export function mixUtil(args: {
],
times
)

return [
...(noDelay ? [curryCommandCreator(pythonMixCommand, {})] : []),
...commands,
]
}
export const mix: CommandCreator<MixArgs> = (
data,
Expand Down
2 changes: 2 additions & 0 deletions step-generation/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@ export interface CommandsAndRobotState {
commands: CreateCommand[]
robotState: RobotState
warnings?: CommandCreatorWarning[]
python?: string
}

export interface CommandCreatorErrorResponse {
Expand All @@ -634,6 +635,7 @@ export interface CommandCreatorErrorResponse {
export interface CommandsAndWarnings {
commands: CreateCommand[]
warnings?: CommandCreatorWarning[]
python?: string
}
export type CommandCreatorResult =
| CommandsAndWarnings
Expand Down
1 change: 1 addition & 0 deletions step-generation/src/utils/commandCreatorsTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const commandCreatorsTimeline = (
commands: commandCreatorResult.commands,
robotState: nextRobotStateAndWarnings.robotState,
warnings: commandCreatorResult.warnings,
python: commandCreatorResult.python,
}
return {
timeline: [...acc.timeline, nextResult],
Expand Down
18 changes: 18 additions & 0 deletions step-generation/src/utils/curryCommandCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,21 @@ export function curryCommandCreator<Args>(
return (_invariantContext, _prevRobotState) =>
commandCreator(args, _invariantContext, _prevRobotState)
}

export function curryCommandCreatorNoPython<Args>(
commandCreator: CommandCreator<Args>,
args: Args
): CurriedCommandCreator {
return (_invariantContext, _prevRobotState) => {
const commandCreatorResult = commandCreator(
args,
_invariantContext,
_prevRobotState
)
if ('python' in commandCreatorResult) {
const { python, ...withoutPython } = commandCreatorResult
return withoutPython
}
return commandCreatorResult
}
}
7 changes: 7 additions & 0 deletions step-generation/src/utils/reduceCommandCreators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface CCReducerAcc {
commands: CreateCommand[]
errors: CommandCreatorError[]
warnings: CommandCreatorWarning[]
python?: string
}
export const reduceCommandCreators = (
commandCreators: CurriedCommandCreator[],
Expand Down Expand Up @@ -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,
Expand All @@ -50,6 +55,7 @@ export const reduceCommandCreators = (
...(next.warnings || []),
...updates.warnings,
],
...(allPython && { python: allPython }),
}
},
{
Expand All @@ -69,5 +75,6 @@ export const reduceCommandCreators = (
return {
commands: result.commands,
warnings: result.warnings,
python: result.python,
}
}
Loading