synbio-buildcompiler 0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ from .sbol2build import * # noqa: F403
@@ -0,0 +1,346 @@
1
+ import sbol2
2
+ import itertools
3
+ from typing import Dict, List, Union
4
+ from .constants import FUSION_SITES
5
+
6
+
7
+ class MocloPlasmid:
8
+ def __init__(
9
+ self, name: str, definition: sbol2.ComponentDefinition, doc: sbol2.document
10
+ ):
11
+ self.definition = definition
12
+ self.fusion_sites = self.match_fusion_sites(doc)
13
+ self.name = name + "".join(f"_{s}" for s in self.fusion_sites)
14
+
15
+ def match_fusion_sites(self, doc: sbol2.document) -> List[str]:
16
+ fusion_site_definitions = extract_fusion_sites(self.definition, doc)
17
+ fusion_sites = []
18
+ for site in fusion_site_definitions:
19
+ sequence_obj = doc.getSequence(site.sequences[0])
20
+ sequence = sequence_obj.elements
21
+
22
+ for key, seq in FUSION_SITES.items():
23
+ if seq == sequence.upper():
24
+ fusion_sites.append(key)
25
+
26
+ fusion_sites.sort()
27
+ return fusion_sites
28
+
29
+ def __repr__(self) -> str:
30
+ return (
31
+ f"MocloPlasmid:\n"
32
+ f" Name: {self.name}\n"
33
+ f" Definition: {self.definition.identity}\n"
34
+ f" Fusion Sites: {self.fusion_sites or 'Not found'}"
35
+ )
36
+
37
+ def __eq__(self, other):
38
+ if not isinstance(other, MocloPlasmid):
39
+ return False
40
+ return self.definition == other.definition
41
+
42
+ def __hash__(self):
43
+ return hash(self.definition)
44
+
45
+
46
+ def extract_fusion_sites(
47
+ plasmid: sbol2.ComponentDefinition, doc: sbol2.Document
48
+ ) -> List[sbol2.ComponentDefinition]:
49
+ """
50
+ Returns all fusion site component definitions from a plasmid.
51
+
52
+ Args:
53
+ plasmid: :class:`sbol2.ComponentDefinition` representing the plasmid.
54
+ doc: :class:`sbol2.Document` containing component definitions.
55
+
56
+ Returns:
57
+ A list of fusion site component definitions.
58
+ """
59
+ fusion_sites = []
60
+ for component in plasmid.components:
61
+ definition = doc.getComponentDefinition(component.definition)
62
+ if "http://identifiers.org/so/SO:0001953" in definition.roles:
63
+ fusion_sites.append(definition)
64
+
65
+ return fusion_sites
66
+
67
+
68
+ def extract_design_parts(
69
+ design: sbol2.ComponentDefinition, doc: sbol2.Document
70
+ ) -> List[sbol2.ComponentDefinition]:
71
+ """
72
+ Returns definitions of parts in a design in sequential order.
73
+
74
+ Args:
75
+ design: :class:`sbol2.ComponentDefinition` to extract parts from.
76
+ doc: :class:`sbol2.Document` containing all component definitions.
77
+
78
+ Returns:
79
+ A list of component definitions in sequential order.
80
+ """
81
+ component_list = [c for c in design.getInSequentialOrder()]
82
+ return [
83
+ doc.getComponentDefinition(component.definition) for component in component_list
84
+ ]
85
+
86
+
87
+ def copy_sequences(component_definition, target_doc, collection_doc):
88
+ """Copy all sequences referenced by a ComponentDefinition into target_doc."""
89
+ subdefinitions = extract_design_parts(component_definition, collection_doc)
90
+
91
+ for seq_uri in component_definition.sequences:
92
+ seq_obj = component_definition.doc.find(seq_uri)
93
+ if seq_obj is not None:
94
+ seq_obj.copy(target_doc)
95
+
96
+ for subdefinition in subdefinitions:
97
+ print(subdefinition.displayId)
98
+ subdefinition.copy(target_doc)
99
+ for seq_uri in subdefinition.sequences:
100
+ seq_obj = component_definition.doc.find(seq_uri)
101
+ if seq_obj is not None:
102
+ seq_obj.copy(target_doc)
103
+
104
+
105
+ def extract_combinatorial_design_parts(
106
+ design: sbol2.ComponentDefinition, doc: sbol2.Document, plasmid_doc
107
+ ) -> Dict[str, List[sbol2.ComponentDefinition]]:
108
+ """
109
+ Extracts and returns a mapping of component definitions from a combinatorial design, in order.
110
+ Variants of combinatinatorial components are entered in a list corresponding to the URI of the component in the abstract design.
111
+
112
+ Args:
113
+ design:
114
+ The :class:`sbol2.ComponentDefinition` representing the top-level design
115
+ from which to extract parts.
116
+ doc:
117
+ The primary :class:`sbol2.Document` containing the base component definitions
118
+ and combinatorial derivations.
119
+ plasmid_doc:
120
+ An additional :class:`sbol2.Document` used to resolve component variants
121
+ (plasmid-specific variants referenced by combinatorial derivations).
122
+
123
+ Returns:
124
+ Dict[str, List[sbol2.ComponentDefinition]]:
125
+ A dictionary mapping component identities to lists
126
+ of variable component definitions.
127
+
128
+ - Sequential design components map to lists containing a single definition.
129
+ - Combinatorial variable components map to lists of variant definitions.
130
+ """
131
+ component_list = [c for c in design.getInSequentialOrder()]
132
+ component_dict = {
133
+ component.identity: [doc.getComponentDefinition(component.definition)]
134
+ for component in component_list
135
+ }
136
+
137
+ for deriv in doc.combinatorialderivations:
138
+ for component in deriv.variableComponents:
139
+ component_dict[component.variable] = [
140
+ plasmid_doc.getComponentDefinition(var) for var in component.variants
141
+ ]
142
+
143
+ return component_dict
144
+
145
+
146
+ def extract_toplevel_definition(doc: sbol2.Document) -> sbol2.ComponentDefinition:
147
+ return doc.componentDefinitions[0]
148
+
149
+
150
+ def enumerate_design_variants(component_dict):
151
+ """
152
+ Given a dict mapping variable component identities to lists of ComponentDefinitions,
153
+ generate all possible design combinations as lists of ComponentDefinitions
154
+ (in consistent order of keys).
155
+ """
156
+ keys = list(component_dict.keys())
157
+ variant_lists = [component_dict[k] for k in keys]
158
+
159
+ # Cartesian product across all variant lists
160
+ all_variants = list(itertools.product(*variant_lists))
161
+
162
+ all_variants = [list(combo) for combo in all_variants]
163
+
164
+ return all_variants
165
+
166
+
167
+ def construct_plasmid_dict(
168
+ part_list: List[sbol2.ComponentDefinition], plasmid_collection: sbol2.Document
169
+ ) -> Dict[str, List[MocloPlasmid]]:
170
+ """
171
+ Builds a mapping from part display IDs to lists of compatible MoCloPlasmid objects.
172
+
173
+ For each part in the given list, this function searches the provided plasmid
174
+ collection for plasmids that contain the part as a component.
175
+ Each matching plasmid is wrapped in a `MocloPlasmid` object and added to the
176
+ dictionary under the part's display ID.
177
+
178
+ Args:
179
+ part_list:
180
+ List of :class:`sbol2.ComponentDefinition` objects representing
181
+ the parts to match.
182
+ plasmid_collection:
183
+ The :class:`sbol2.Document` containing plasmids to search through.
184
+
185
+ Returns:
186
+ Dict[str, List[MocloPlasmid]]:
187
+ A dictionary mapping each part display ID to a list of corresponding
188
+ `MocloPlasmid` objects found in the collection.
189
+ """
190
+ plasmid_dict = {}
191
+ for part in part_list:
192
+ for plasmid in plasmid_collection.componentDefinitions:
193
+ if "http://identifiers.org/so/SO:0000637" in plasmid.roles:
194
+ for component in plasmid.components:
195
+ if (
196
+ component.definition == str(part)
197
+ ): # TODO make sure this is not a composite plasmid, i.e. plasmid just contains singular part of interest
198
+ fusion_sites = [
199
+ site.name
200
+ for site in extract_fusion_sites(
201
+ plasmid, plasmid_collection
202
+ )
203
+ ]
204
+ print(
205
+ f"found: {component.definition} in {plasmid} with {fusion_sites}"
206
+ ) # TODO switch to logger for backend tracing?
207
+ plasmid_dict.setdefault(part.displayId, [])
208
+
209
+ componentName = plasmid_collection.getComponentDefinition(
210
+ component.definition
211
+ ).name
212
+
213
+ plasmid_dict[part.displayId].append(
214
+ MocloPlasmid(componentName, plasmid, plasmid_collection)
215
+ )
216
+
217
+ return plasmid_dict
218
+
219
+
220
+ def get_compatible_plasmids(
221
+ plasmid_dict: Dict[str, List[MocloPlasmid]], backbone: MocloPlasmid
222
+ ) -> List[MocloPlasmid]:
223
+ """
224
+ Returns a list of MocloPlasmid objects that can form a compatible assembly
225
+ with the given backbone plasmid. The function selects one plasmid from each
226
+ entry in the dictionary, ensuring that adjacent plasmids have matching MoClo fusion sites,
227
+ and that the first and last plasmids are compatible with the backbone.
228
+
229
+ Args:
230
+ plasmid_dict: A dictionary mapping assembly positions or categories to lists
231
+ of MocloPlasmid objects.
232
+ backbone: The backbone MocloPlasmid whose fusion sites define compatibility.
233
+
234
+ Returns:
235
+ A list of compatible MocloPlasmid objects forming a sequential assembly.
236
+ """
237
+ selected_plasmids = []
238
+ match_to = backbone
239
+ match_idx = 0
240
+
241
+ for i, key in enumerate(plasmid_dict):
242
+ for plasmid in plasmid_dict[key]:
243
+ if (
244
+ i == len(plasmid_dict) - 1
245
+ and plasmid.fusion_sites[0] == match_to.fusion_sites[match_idx]
246
+ and plasmid.fusion_sites[1] == backbone.fusion_sites[1]
247
+ ):
248
+ print(
249
+ f"matched final component {plasmid.name} with {match_to.name} and {backbone.name} on fusion sites ({plasmid.fusion_sites[0]}, {plasmid.fusion_sites[1]})!"
250
+ )
251
+ selected_plasmids.append(plasmid)
252
+ break
253
+ elif (
254
+ i < len(plasmid_dict) - 1
255
+ and plasmid.fusion_sites[0] == match_to.fusion_sites[match_idx]
256
+ ): # TODO add error handling if no compatible plasmid found
257
+ print(
258
+ f"matched {plasmid.name} with {match_to.name} on fusion site {plasmid.fusion_sites[0]}!"
259
+ )
260
+ selected_plasmids.append(plasmid)
261
+ match_to = plasmid
262
+ match_idx = 1
263
+ break
264
+ # TODO edge case where second fusion site does not match terminator fusion site will not be caught by current logic
265
+ # 10/14: rethink implementation, will likely need to be different for combinatorial designs
266
+
267
+ return selected_plasmids
268
+
269
+
270
+ def translate_abstract_to_plasmids(
271
+ abstract_design: Union[sbol2.ComponentDefinition, sbol2.CombinatorialDerivation],
272
+ plasmid_collection: sbol2.Collection,
273
+ acceptor_backbone: sbol2.Document,
274
+ ) -> List[MocloPlasmid]:
275
+ """
276
+ Translates an abstract SBOLCanvas design into a set of compatible MoClo plasmid assemblies.
277
+
278
+ Takes an abstract design, identifies the appropriate component
279
+ definitions and combinatorial derivations, and produces all possible plasmid
280
+ combinations that can be assembled using the provided backbone and plasmid
281
+ collection.
282
+
283
+ Args:
284
+ abstract_design_doc:
285
+ The :class:`sbol2.Document` representing the abstract genetic design.
286
+ May include either a single component definition (generic design) or
287
+ one or more combinatorial derivations (combinatorial design).
288
+ plasmid_collection:
289
+ The :class:`sbol2.Document` containing the available MoClo plasmid
290
+ components used for matching and assembly.
291
+ backbone_doc:
292
+ The :class:`sbol2.Document` defining the backbone plasmid into which
293
+ parts are assembled.
294
+
295
+ Returns:
296
+ List[MocloPlasmid]:
297
+ - For combinatorial designs: a list of unique compatible plasmids
298
+ (`MocloPlasmid` objects) representing all enumerated design variants.
299
+ - For generic designs: a list of compatible plasmids for the single
300
+ design instance.
301
+ """
302
+ backbone_def = extract_toplevel_definition(acceptor_backbone)
303
+
304
+
305
+ backbone_plasmid = MocloPlasmid(backbone_def.displayId, backbone_def, acceptor_backbone)
306
+
307
+ # combinatorial design
308
+ if len(abstract_design.combinatorialderivations) > 0:
309
+ abstract_design_def = abstract_design.getComponentDefinition(
310
+ abstract_design.combinatorialderivations[0].masterTemplate
311
+ )
312
+
313
+ combinatorial_part_dict = extract_combinatorial_design_parts(
314
+ abstract_design_def, abstract_design , plasmid_collection
315
+ )
316
+ enumerated_part_list = enumerate_design_variants(combinatorial_part_dict)
317
+
318
+ seen = set()
319
+ ordered_unique_plasmids = []
320
+
321
+ for design in enumerated_part_list:
322
+ plasmid_dict = construct_plasmid_dict(design, plasmid_collection)
323
+ compatible_plasmids = get_compatible_plasmids(
324
+ plasmid_dict, backbone_plasmid
325
+ )
326
+
327
+ for plasmid in compatible_plasmids:
328
+ if plasmid not in seen:
329
+ seen.add(plasmid)
330
+ ordered_unique_plasmids.append(plasmid)
331
+
332
+ return ordered_unique_plasmids
333
+
334
+ # generic design
335
+ else:
336
+ abstract_design_def = extract_toplevel_definition(abstract_design)
337
+
338
+ ordered_part_definitions = extract_design_parts(
339
+ abstract_design_def, abstract_design
340
+ )
341
+
342
+ plasmid_dict = construct_plasmid_dict(
343
+ ordered_part_definitions, plasmid_collection
344
+ )
345
+
346
+ return get_compatible_plasmids(plasmid_dict, backbone_plasmid)
@@ -0,0 +1,49 @@
1
+ import sbol2
2
+ from typing import Union, List
3
+ import zipfile
4
+ from buildcompiler.abstract_translator import translate_abstract_to_plasmids
5
+ from buildcompiler.sbol2build import golden_gate_assembly_plan
6
+ from buildcompiler.robotutils import assembly_plan_RDF_to_JSON, run_opentrons_script_with_json_to_zip
7
+
8
+
9
+ # function which input is an abstract design and output build specifications by creating an assembly plan, and a zip file with a run_sbol2assembly.py, an automated_assembly_log.txt, assemblyplan_output.JSON, and assembly_protocol.xlsx
10
+
11
+ def assembly_compiler(document: sbol2.Document,
12
+ abstract_design: str,
13
+ plasmids_collection: str,
14
+ plasmid_acceptor_backbone: str,
15
+ files_path: str) -> zipfile.ZipFile:
16
+ """
17
+ Compiles an abstract design into build specifications.
18
+
19
+ Args:
20
+ abstract_design (Union[sbol2.Component, sbol2.CombinatorialDerivation]): The abstract design to be compiled.
21
+ specifications (sbol2.Component): The component to store the build specifications.
22
+ Returns:
23
+ zipfile.ZipFile: A zip file containing the build specifications and assembly plan.
24
+ """
25
+ restriction_enzyme = "BsaI"
26
+ # Translate abstract design to plasmids
27
+ list_of_plasmids = translate_abstract_to_plasmids(abstract_design_doc = abstract_design,
28
+ plasmid_collection = plasmids_collection,
29
+ backbone_doc= plasmid_acceptor_backbone)
30
+
31
+
32
+
33
+ # Create assembly plan
34
+ assembly_plan = golden_gate_assembly_plan(name = "Assembly_Plan",
35
+ parts_in_backbone= list_of_plasmids,
36
+ plasmid_acceptor_backbone= plasmid_acceptor_backbone,
37
+ restriction_enzyme= restriction_enzyme,
38
+ document= document)
39
+
40
+ # Generate build specifications JSON
41
+ build_specs_JSON = assembly_plan_RDF_to_JSON(assembly_plan)
42
+
43
+ # Create zip file with required files
44
+ zip_file = run_opentrons_script_with_json_to_zip(opentrons_script_path= files_path + "/run_sbol2assembly_libre.py",
45
+ json_file_path= files_path + "/assemblyplan_output.json",
46
+ zip_name= "buildcompiler.zip",
47
+ overwrite= True)
48
+
49
+ return assembly_plan, zip_file
@@ -0,0 +1,15 @@
1
+ FUSION_SITES = {
2
+ "A": "GGAG",
3
+ "B": "TACT",
4
+ "C": "AATG",
5
+ "D": "AGGT",
6
+ "E": "GCTT",
7
+ "F": "CGCT",
8
+ "G": "TGCC",
9
+ "H": "ACTA",
10
+ }
11
+
12
+ DNA_TYPES = { # TODO see about restricting dna types to only accept dna
13
+ "http://www.biopax.org/release/biopax-level3.owl#Dna",
14
+ "http://www.biopax.org/release/biopax-level3.owl#DnaRegion",
15
+ }
@@ -0,0 +1,151 @@
1
+ import sbol2
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ def assembly_plan_RDF_to_JSON(file):
11
+ if type(file)==sbol2.Document:
12
+ doc = file
13
+ else:
14
+ sbol2.Config.setOption('sbol_typed_uris', False)
15
+ doc = sbol2.Document()
16
+ doc.read(file)
17
+
18
+ # Known SO roles
19
+ PRODUCT_ROLE = 'http://identifiers.org/so/SO:0000804'
20
+ BackBone_ROLE = 'http://identifiers.org/so/SO:0000755'
21
+ ENZYME_ROLE = 'http://identifiers.org/obi/OBI:0000732'
22
+
23
+ PARTS_ROLE_LIST = [
24
+ 'http://identifiers.org/so/SO:0000031', 'http://identifiers.org/so/SO:0000316',
25
+ 'http://identifiers.org/so/SO:0001977', 'http://identifiers.org/so/SO:0001956',
26
+ 'http://identifiers.org/so/SO:0000188', 'http://identifiers.org/so/SO:0000839',
27
+ 'http://identifiers.org/so/SO:0000167', 'http://identifiers.org/so/SO:0000139',
28
+ 'http://identifiers.org/so/SO:0001979', 'http://identifiers.org/so/SO:0001955',
29
+ 'http://identifiers.org/so/SO:0001546', 'http://identifiers.org/so/SO:0001263',
30
+ 'http://identifiers.org/SO:0000141', 'http://identifiers.org/so/SO:0000141'
31
+ ]
32
+
33
+ product_dicts = []
34
+ globalEnzyme = None
35
+
36
+ for cd in doc.componentDefinitions:
37
+ print(f"\n🔍 Checking Component: {cd.displayId}")
38
+ print(f" Types: {cd.types}")
39
+ print(f" Roles: {cd.roles}")
40
+
41
+ if ENZYME_ROLE in cd.roles:
42
+ globalEnzyme = cd.identity
43
+ print(f"✅ Found enzyme definition: {globalEnzyme}")
44
+
45
+ if PRODUCT_ROLE in cd.roles:
46
+ result = {
47
+ 'Product': cd.identity,
48
+ 'Backbone': None,
49
+ 'PartsList': [],
50
+ 'Restriction Enzyme': None
51
+ }
52
+
53
+ for comp in cd.components:
54
+ sub_cd = doc.componentDefinitions.get(comp.definition)
55
+ if sub_cd is None:
56
+ print(f"⚠️ Component definition for {comp.displayId} not found.")
57
+ continue
58
+
59
+ print(f" → Subcomponent: {sub_cd.displayId}")
60
+ print(f" Roles: {sub_cd.roles}")
61
+
62
+ if BackBone_ROLE in sub_cd.roles:
63
+ result['Backbone'] = sub_cd.identity
64
+ print(f" 🧬 Assigned Backbone: {sub_cd.identity}")
65
+
66
+ if any(role in PARTS_ROLE_LIST for role in sub_cd.roles):
67
+ result['PartsList'].append(sub_cd.identity)
68
+ print(f" 🧩 Added Part: {sub_cd.identity}")
69
+
70
+ if not result['Backbone']:
71
+ print(f"⚠️ No backbone found for product {cd.displayId}")
72
+ if not result['PartsList']:
73
+ print(f"⚠️ No parts found for product {cd.displayId}")
74
+
75
+ product_dicts.append(result)
76
+
77
+ for entry in product_dicts:
78
+ entry['Restriction Enzyme'] = globalEnzyme
79
+
80
+ with open('output.json', 'w') as json_file:
81
+ json.dump(product_dicts, json_file, indent=4)
82
+
83
+ return product_dicts
84
+
85
+
86
+ def run_opentrons_script_with_json_to_zip(
87
+ opentrons_script_path: str,
88
+ json_file_path: str,
89
+ zip_name: str | None = None,
90
+ overwrite: bool = False,
91
+ ) -> Path:
92
+ """
93
+ Runs `opentrons_simulate` on an Opentrons script + JSON, captures stdout/stderr,
94
+ and writes a ZIP file *next to the original opentrons script*.
95
+
96
+ Returns: Path to the created zip file.
97
+ """
98
+ script_path = Path(opentrons_script_path).resolve()
99
+ json_path = Path(json_file_path).resolve()
100
+
101
+ if not script_path.exists():
102
+ raise FileNotFoundError(f"Opentrons script not found: {script_path}")
103
+ if not json_path.exists():
104
+ raise FileNotFoundError(f"JSON file not found: {json_path}")
105
+
106
+ out_dir = script_path.parent
107
+ base_name = zip_name or f"{script_path.stem}_opentrons_simulation.zip"
108
+ out_zip = out_dir / base_name
109
+
110
+ if out_zip.exists() and not overwrite:
111
+ # avoid clobbering: foo.zip -> foo_1.zip -> foo_2.zip ...
112
+ stem = out_zip.stem
113
+ suffix = out_zip.suffix
114
+ i = 1
115
+ while True:
116
+ candidate = out_dir / f"{stem}_{i}{suffix}"
117
+ if not candidate.exists():
118
+ out_zip = candidate
119
+ break
120
+ i += 1
121
+
122
+ with tempfile.TemporaryDirectory() as tmpdirname:
123
+ tmpdir = Path(tmpdirname)
124
+
125
+ # Copy inputs into temp dir
126
+ tmp_script = tmpdir / script_path.name
127
+ tmp_json = tmpdir / json_path.name
128
+ shutil.copy2(script_path, tmp_script)
129
+ shutil.copy2(json_path, tmp_json)
130
+
131
+ # Run inside temp dir so relative-path outputs land in tmpdir (and get zipped)
132
+
133
+ # Run script (which has opentrons script hardcoded) using JSON file
134
+ log = subprocess.run(["opentrons_simulate", opentrons_script_path, json_file_path], capture_output=True).stdout
135
+
136
+ # Save log to a file in the temporary directory
137
+ with open(os.path.join(tmpdir, "build_log.txt"), "wb") as log_file:
138
+ log_file.write(log)
139
+
140
+ # Always include logs in the zip
141
+ #(tmpdir / "simulate_stdout.txt").write_text(proc.stdout or "", encoding="utf-8", errors="replace")
142
+ #(tmpdir / "simulate_stderr.txt").write_text(proc.stderr or "", encoding="utf-8", errors="replace")
143
+ #(tmpdir / "simulate_returncode.txt").write_text(str(proc.returncode), encoding="utf-8")
144
+
145
+ # Create the ZIP on disk next to the original script
146
+ with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as z:
147
+ for p in tmpdir.rglob("*"):
148
+ if p.is_file():
149
+ z.write(p, arcname=p.relative_to(tmpdir))
150
+
151
+ return out_zip