Skip to content

Commit be7d987

Browse files
authored
feat(protocol-designer): add designerApplication comment to py file a… (#17938)
…nd allow reupload This PR adds the `DESIGNER_APPLICATION` variable which stringifies the designer application key at the end of a python file. When you export, the python file now has a long variable at the end with the string. When you import back into PD, it now accepts the python file and extracts the stringified blob back into a json.
1 parent 60b55b9 commit be7d987

File tree

5 files changed

+230
-21
lines changed

5 files changed

+230
-21
lines changed

protocol-designer/src/file-data/__tests__/createFile.test.ts

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,20 @@ describe('createFile selector', () => {
104104
// @ts-expect-error(sa, 2021-6-15): resultFunc not part of Selector type
105105
const result = createPythonFile.resultFunc(
106106
fileMetadata,
107-
OT2_ROBOT_TYPE,
108-
entities,
109107
v7Fixture.initialRobotState,
110108
v7Fixture.robotStateTimeline,
109+
OT2_ROBOT_TYPE,
110+
dismissedWarnings,
111111
ingredLocations,
112-
labwareNicknamesById
112+
v7Fixture.savedStepForms,
113+
v7Fixture.orderedStepIds,
114+
labwareNicknamesById,
115+
entities
113116
)
114117
// This is just a quick smoke test to make sure createPythonFile() produces
115118
// something that looks like a Python file. The individual sections of the
116119
// generated Python will be tested in separate unit tests.
117-
expect(result).toBe(
120+
expect(result.pythonProtocol).toBe(
118121
`
119122
from contextlib import nullcontext as pd_step
120123
from opentrons import protocol_api, types
@@ -166,6 +169,106 @@ def run(protocol: protocol_api.ProtocolContext):
166169
pass
167170
`.trimStart()
168171
)
172+
173+
expect(result.designerApplication).toEqual({
174+
designerApplication: {
175+
data: {
176+
dismissedWarnings: {
177+
form: [],
178+
timeline: [],
179+
},
180+
ingredLocations: {},
181+
ingredients: {},
182+
labware: {
183+
fixedTrash: {
184+
displayName: 'Trash',
185+
labwareDefURI: 'opentrons/opentrons_1_trash_1100ml_fixed/1',
186+
},
187+
plateId: {
188+
displayName: 'NEST 96 Well Plate 100 µL PCR Full Skirt',
189+
labwareDefURI:
190+
'opentrons/nest_96_wellplate_100ul_pcr_full_skirt/1',
191+
},
192+
tiprackId: {
193+
displayName: 'Opentrons 96 Tip Rack 10 µL',
194+
labwareDefURI: 'opentrons/opentrons_96_tiprack_10ul/1',
195+
},
196+
},
197+
modules: {},
198+
orderedStepIds: ['moveLiquidStepId'],
199+
pipetteTiprackAssignments: {
200+
pipetteId: ['opentrons/opentrons_96_tiprack_10ul/1'],
201+
},
202+
pipettes: {
203+
pipetteId: {
204+
pipetteName: 'p10_single',
205+
},
206+
},
207+
savedStepForms: {
208+
__INITIAL_DECK_SETUP_STEP__: {
209+
id: '__INITIAL_DECK_SETUP_STEP__',
210+
labwareLocationUpdate: {
211+
fixedTrash: '12',
212+
plateId: '1',
213+
tiprackId: '2',
214+
},
215+
moduleLocationUpdate: {},
216+
pipetteLocationUpdate: {
217+
pipetteId: 'left',
218+
},
219+
stepType: 'manualIntervention',
220+
},
221+
moveLiquidStepId: {
222+
aspirate_airGap_checkbox: true,
223+
aspirate_airGap_volume: '1',
224+
aspirate_delay_checkbox: true,
225+
aspirate_delay_mmFromBottom: '1',
226+
aspirate_delay_seconds: '1',
227+
aspirate_flowRate: null,
228+
aspirate_labwareId: 'plateId',
229+
aspirate_mix_checkbox: false,
230+
aspirate_mix_times: null,
231+
aspirate_mix_volume: null,
232+
aspirate_mmFromBottom: '1',
233+
aspirate_touchTip_checkbox: false,
234+
aspirate_wellOrder_first: 't2b',
235+
aspirate_wellOrder_second: 'l2r',
236+
aspirate_wells: ['A1', 'B1'],
237+
aspirate_wells_grouped: false,
238+
blowout_checkbox: false,
239+
blowout_location: 'fixedTrash',
240+
changeTip: 'always',
241+
dispense_delay_checkbox: false,
242+
dispense_delay_mmFromBottom: '0.5',
243+
dispense_delay_seconds: '1',
244+
dispense_flowRate: null,
245+
dispense_labwareId: 'plateId',
246+
dispense_mix_checkbox: false,
247+
dispense_mix_times: null,
248+
dispense_mix_volume: null,
249+
dispense_mmFromBottom: '0.5',
250+
dispense_touchTip_checkbox: false,
251+
dispense_wellOrder_first: 't2b',
252+
dispense_wellOrder_second: 'l2r',
253+
dispense_wells: ['A12', 'B12'],
254+
disposalVolume_checkbox: true,
255+
disposalVolume_volume: '1',
256+
id: 'moveLiquidStepId',
257+
path: 'single',
258+
pipetteId: 'pipetteId',
259+
preWetTip: false,
260+
stepDetails: '',
261+
stepName: 'transfer',
262+
stepType: 'moveLiquid',
263+
volume: '5',
264+
},
265+
},
266+
},
267+
version: '8.5.0',
268+
name: 'opentrons/protocol-designer',
269+
},
270+
robot: { model: OT2_ROBOT_TYPE },
271+
})
169272
})
170273
})
171274

protocol-designer/src/file-data/selectors/fileCreator.ts

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ import type {
5252
} from '@opentrons/shared-data'
5353
import type { LabwareDefByDefURI } from '../../labware-defs'
5454
import type { Selector } from '../../types'
55-
import type { PDMetadata } from '../../file-types'
55+
import type {
56+
PDMetadata,
57+
PDPythonFile,
58+
PythonDesignerApplication,
59+
} from '../../file-types'
5660

5761
// TODO: BC: 2018-02-21 uncomment this assert, causes test failures
5862
// console.assert(!isEmpty(process.env.OT_PD_VERSION), 'Could not find application version!')
@@ -305,24 +309,75 @@ export const createFile: Selector<ProtocolFile> = createSelector(
305309
}
306310
)
307311

308-
export const createPythonFile: Selector<string> = createSelector(
312+
export const createPythonFile: Selector<PDPythonFile> = createSelector(
309313
getFileMetadata,
310-
getRobotType,
311-
stepFormSelectors.getInvariantContext,
312314
getInitialRobotState,
313315
getRobotStateTimeline,
316+
getRobotType,
317+
dismissSelectors.getAllDismissedWarnings,
314318
ingredSelectors.getLiquidsByLabwareId,
319+
stepFormSelectors.getSavedStepForms,
320+
stepFormSelectors.getOrderedStepIds,
315321
uiLabwareSelectors.getLabwareNicknamesById,
322+
stepFormSelectors.getInvariantContext,
316323
(
317324
fileMetadata,
318-
robotType,
319-
invariantContext,
320325
robotState,
321326
robotStateTimeline,
322-
liquidsByLabwareId,
323-
labwareNicknamesById
327+
robotType,
328+
dismissedWarnings,
329+
ingredLocations,
330+
savedStepForms,
331+
orderedStepIds,
332+
labwareNicknamesById,
333+
invariantContext
324334
) => {
325-
return (
335+
const {
336+
pipetteEntities,
337+
moduleEntities,
338+
labwareEntities,
339+
liquidEntities,
340+
} = invariantContext
341+
342+
const savedOrderedStepIds = orderedStepIds.filter(
343+
stepId => savedStepForms[stepId]
344+
)
345+
346+
const ingredients: Ingredients = Object.fromEntries(
347+
Object.entries(
348+
liquidEntities
349+
).map(([liquidId, { pythonName, ...rest }]) => [liquidId, rest])
350+
)
351+
352+
const designerApplication: PythonDesignerApplication = {
353+
robot: {
354+
model: robotType,
355+
},
356+
designerApplication: {
357+
name: 'opentrons/protocol-designer',
358+
// hardcoding this version in to avoid unnecessary migrating
359+
// TODO: remember to update to the applicationVersion const
360+
version: '8.5.0',
361+
data: {
362+
pipetteTiprackAssignments: mapValues(
363+
pipetteEntities,
364+
(
365+
p: typeof pipetteEntities[keyof typeof pipetteEntities]
366+
): string[] => p.tiprackDefURI
367+
),
368+
dismissedWarnings,
369+
ingredients,
370+
ingredLocations,
371+
savedStepForms,
372+
orderedStepIds: savedOrderedStepIds,
373+
pipettes: getPipettesLoadInfo(pipetteEntities),
374+
modules: getModulesLoadInfo(moduleEntities),
375+
labware: getLabwareLoadInfo(labwareEntities, labwareNicknamesById),
376+
},
377+
},
378+
}
379+
380+
const pythonProtocol =
326381
[
327382
// Here are the sections of the Python file:
328383
pythonImports(),
@@ -332,13 +387,14 @@ export const createPythonFile: Selector<string> = createSelector(
332387
invariantContext,
333388
robotState,
334389
robotStateTimeline,
335-
liquidsByLabwareId,
390+
ingredLocations,
336391
labwareNicknamesById,
337392
robotType
338393
),
339394
]
340395
.filter(section => section) // skip any blank sections
341396
.join('\n\n') + '\n'
342-
)
397+
398+
return { pythonProtocol, designerApplication }
343399
}
344400
)

protocol-designer/src/file-types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
ModuleModel,
33
PipetteName,
44
ProtocolFile,
5+
RobotType,
56
} from '@opentrons/shared-data'
67
import type { Ingredients } from '@opentrons/step-generation'
78
import type { RootState as IngredRoot } from './labware-ingred/reducers'
@@ -34,6 +35,21 @@ export interface PDMetadata {
3435
labware: Labware
3536
}
3637

38+
export interface DesignerApplication {
39+
designerApplication: { version: string; name: string; data: PDMetadata }
40+
}
41+
42+
export interface PythonDesignerApplication extends DesignerApplication {
43+
robot: {
44+
model: RobotType
45+
}
46+
}
47+
48+
export interface PDPythonFile {
49+
pythonProtocol: string
50+
designerApplication: PythonDesignerApplication
51+
}
52+
3753
export type PDProtocolFile = ProtocolFile<PDMetadata>
3854

3955
export function getPDMetadata(file: PDProtocolFile): PDMetadata {

protocol-designer/src/load-file/actions.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { selectors as fileDataSelectors } from '../file-data'
33
import { saveFile, savePythonFile } from './utils'
44

55
import type { SyntheticEvent } from 'react'
6-
import type { PDProtocolFile } from '../file-types'
6+
import type { PDProtocolFile, PDPythonFile } from '../file-types'
77
import type { GetState, ThunkAction, ThunkDispatch } from '../types'
88
import type {
99
FileUploadErrorType,
@@ -28,7 +28,9 @@ export const dismissFileUploadMessage = (): DismissFileUploadMessageAction => ({
2828
type: 'DISMISS_FILE_UPLOAD_MESSAGE',
2929
})
3030
// expects valid, parsed JSON protocol.
31-
export const loadFileAction = (payload: PDProtocolFile): LoadFileAction => ({
31+
export const loadFileAction = (
32+
payload: PDProtocolFile | PDPythonFile
33+
): LoadFileAction => ({
3234
type: 'LOAD_FILE',
3335
payload: migration(payload),
3436
})
@@ -54,9 +56,9 @@ export const loadProtocolFile = (
5456
// reset the state of the input to allow file re-uploads
5557
event.currentTarget.value = ''
5658

57-
if (!file.name.endsWith('.json')) {
59+
if (!file.name.endsWith('.json') && !file.name.endsWith('.py')) {
5860
fileError('INVALID_FILE_TYPE')
59-
} else {
61+
} else if (file.name.endsWith('.json')) {
6062
reader.onload = readEvent => {
6163
const result = ((readEvent.currentTarget as any) as FileReader).result
6264
let parsedProtocol: PDProtocolFile | null | undefined
@@ -73,6 +75,31 @@ export const loadProtocolFile = (
7375
}
7476
}
7577

78+
reader.readAsText(file)
79+
} else {
80+
reader.onload = readEvent => {
81+
const result = (readEvent.currentTarget as FileReader).result as string
82+
83+
try {
84+
// Extract designer application blob
85+
const designerApplication = result.match(
86+
/^DESIGNER_APPLICATION\s?=\s?"""(.*)"""/m
87+
)
88+
if (designerApplication != null && designerApplication[1]) {
89+
const designerApplicationString = designerApplication[1]
90+
const designerApplicationJson = JSON.parse(designerApplicationString) // Convert to JSON
91+
dispatch(loadFileAction(designerApplicationJson as PDPythonFile))
92+
} else {
93+
console.warn('No blob found in file.')
94+
}
95+
} catch (error) {
96+
console.error('Error extracting blob:', error)
97+
if (error instanceof Error) {
98+
fileError('INVALID_FILE_TYPE', error.message)
99+
}
100+
}
101+
}
102+
76103
reader.readAsText(file)
77104
}
78105
}

protocol-designer/src/load-file/utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { saveAs } from 'file-saver'
22
import type { ProtocolFile } from '@opentrons/shared-data'
3+
import type { PDPythonFile } from '../file-types'
4+
35
export const saveFile = (fileData: ProtocolFile, fileName: string): void => {
46
const blob = new Blob([JSON.stringify(fileData, null, 2)], {
57
type: 'application/json',
68
})
79
saveAs(blob, fileName)
810
}
9-
export const savePythonFile = (fileData: string, fileName: string): void => {
10-
const blob = new Blob([fileData], { type: 'text/x-python;charset=UTF-8' })
11+
export const savePythonFile = (file: PDPythonFile, fileName: string): void => {
12+
const fileData = file.pythonProtocol
13+
const stringifiedBlob = JSON.stringify(file.designerApplication)
14+
const designerApplicationBlob = `\nDESIGNER_APPLICATION = """${stringifiedBlob}"""\n`
15+
const blob = new Blob([fileData, designerApplicationBlob], {
16+
type: 'text/x-python;charset=UTF-8',
17+
})
1118
// For now, show the generated Python in a new window instead of saving it to a file.
1219
// (A saved Python file wouldn't be runnable anyway until we finish this project.)
1320
window.open(URL.createObjectURL(blob), '_blank')

0 commit comments

Comments
 (0)