From 33e11a282cf6f17b2bee9e9c1662fbbebec918c3 Mon Sep 17 00:00:00 2001 From: Gonzalo Vidal <35148159+Gonza10V@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:07:22 -0600 Subject: [PATCH] Implement BuildCompiler transformation workflow stage --- README.md | 1 + src/buildcompiler/__init__.py | 1 + src/buildcompiler/buildcompiler.py | 162 ++++++++++++++++++++- tests/test_buildcompiler_transformation.py | 69 +++++++++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 tests/test_buildcompiler_transformation.py diff --git a/README.md b/README.md index 4335060..99a57e1 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Creates missing parts as linear DNA and generates protocols to insert them into ### 5. Transformation - Converts DNA into engineered strains - Generates automated chemical transformation protocols +- API entry point: `BuildCompiler.transformation(...)` ### 6. Plating - Creates dilution series diff --git a/src/buildcompiler/__init__.py b/src/buildcompiler/__init__.py index c31ac6d..e7475dd 100644 --- a/src/buildcompiler/__init__.py +++ b/src/buildcompiler/__init__.py @@ -1 +1,2 @@ from .sbol2build import * # noqa: F403 +from .buildcompiler import BuildCompiler as BuildCompiler diff --git a/src/buildcompiler/buildcompiler.py b/src/buildcompiler/buildcompiler.py index 607a242..f10db8f 100644 --- a/src/buildcompiler/buildcompiler.py +++ b/src/buildcompiler/buildcompiler.py @@ -1,7 +1,7 @@ import sbol2 import random import warnings -from typing import List, Dict +from typing import Any, Dict, List from buildcompiler.plasmid import Plasmid from buildcompiler.sbol2build import Assembly, dna_componentdefinition_with_sequence @@ -363,6 +363,166 @@ def assembly_lvl2( return protocol + def transformation( + self, + assembly_products: List[Plasmid] | None = None, + plasmid_inputs: List[sbol2.ComponentDefinition] | None = None, + chassis_name: str = "E_coli_DH5alpha", + ) -> Dict[str, Any]: + """Generate a deterministic transformation plan for one or more plasmids. + + The method accepts either assembled :class:`Plasmid` objects (preferred) or + standalone plasmid ``ComponentDefinition`` inputs and returns structured + transformation artifacts for downstream orchestration. + + :param assembly_products: Assembled plasmid objects from an upstream assembly stage. + :param plasmid_inputs: Plasmid definitions when assembly outputs are unavailable. + :param chassis_name: Label used to name chassis/transformed-strain artifacts. + :raises ValueError: If neither, or both, plasmid input channels are supplied. + :returns: Structured transformation output including SBOL references, JSON plan, + and protocol placeholders. + :rtype: Dict[str, Any] + """ + plasmids = self._resolve_transformation_inputs(assembly_products, plasmid_inputs) + + transformation_md = sbol2.ModuleDefinition(f"{chassis_name}_transformation") + transformation_md.name = f"{chassis_name} chemical transformation" + self.sbol_doc.add(transformation_md) + + transformed_strains: List[Dict[str, str]] = [] + transformation_records: List[Dict[str, Any]] = [] + + for index, plasmid in enumerate(plasmids, start=1): + plasmid_identity = plasmid.plasmid_definition.identity + plasmid_display_id = plasmid.plasmid_definition.displayId + + activity = sbol2.Activity( + f"{chassis_name}_transform_{plasmid_display_id}_{index}" + ) + activity.name = f"Chemical transformation of {plasmid_display_id}" + activity.types = ["http://sbols.org/v2#build"] + self.sbol_doc.add(activity) + + transformed_strain = sbol2.ModuleDefinition( + f"{chassis_name}_with_{plasmid_display_id}_{index}" + ) + transformed_strain.name = ( + f"{chassis_name} transformed with {plasmid_display_id}" + ) + transformed_strain.roles = [ORGANISM_STRAIN] + + plasmid_fc = transformed_strain.functionalComponents.create( + f"{plasmid_display_id}_engineered_plasmid" + ) + plasmid_fc.definition = plasmid_identity + + transformed_impl = sbol2.Implementation( + f"{transformed_strain.displayId}_impl" + ) + transformed_impl.built = transformed_strain.identity + transformed_impl.wasGeneratedBy = activity.identity + + self.sbol_doc.add_list([transformed_strain, transformed_impl]) + + transformation_records.append( + { + "reaction_id": f"transform_{index}", + "plasmid": plasmid_display_id, + "plasmid_identity": plasmid_identity, + "destination_strain": transformed_strain.displayId, + "total_volume_ul": 50, + "plasmid_volume_ul": 2, + "competent_cells_volume_ul": 48, + "outgrowth_minutes": 60, + } + ) + + transformed_strains.append( + { + "module_definition": transformed_strain.identity, + "implementation": transformed_impl.identity, + "activity": activity.identity, + } + ) + + robot_json = { + "stage": "transformation", + "protocol": "chemical_transformation", + "chassis": chassis_name, + "reactions": transformation_records, + } + protocol_bundle = self._build_transformation_protocol_bundle( + chassis_name=chassis_name, reactions=transformation_records + ) + + return { + "stage": "transformation", + "inputs": [plasmid.plasmid_definition.identity for plasmid in plasmids], + "sbol": { + "transformation_module": transformation_md.identity, + "transformed_strains": transformed_strains, + }, + "json": robot_json, + "artifacts": protocol_bundle, + } + + def _resolve_transformation_inputs( + self, + assembly_products: List[Plasmid] | None, + plasmid_inputs: List[sbol2.ComponentDefinition] | None, + ) -> List[Plasmid]: + if bool(assembly_products) == bool(plasmid_inputs): + raise ValueError( + "Provide either assembly_products or plasmid_inputs (exactly one)." + ) + + if assembly_products: + return assembly_products + + resolved_plasmids: List[Plasmid] = [] + for plasmid_definition in plasmid_inputs or []: + existing_plasmid = self._get_indexed_plasmid( + self.indexed_plasmids, plasmid_definition + ) + if existing_plasmid: + resolved_plasmids.append(existing_plasmid) + continue + + generated_impl = sbol2.Implementation(f"{plasmid_definition.displayId}_impl") + generated_impl.built = plasmid_definition.identity + self.sbol_doc.add(generated_impl) + + resolved_plasmids.append( + Plasmid( + plasmid_definition, + None, + [generated_impl], + [], + self.sbol_doc, + ) + ) + + return resolved_plasmids + + def _build_transformation_protocol_bundle( + self, chassis_name: str, reactions: List[Dict[str, Any]] + ) -> Dict[str, Any]: + instruction_lines = [ + f"1. Thaw competent {chassis_name} cells on ice.", + "2. Add 48 µL competent cells + 2 µL assembly product for each reaction.", + "3. Incubate on ice for 30 minutes.", + "4. Heat shock at 42°C for 45 seconds, then return to ice for 2 minutes.", + "5. Add 450 µL SOC media and recover at 37°C for 60 minutes.", + ] + return { + "protocol_name": "chemical_transformation", + "opentrons_template": "ot2_chemical_transformation_v1", + "instructions": instruction_lines, + "log": [ + f"Prepared {len(reactions)} transformation reaction(s) for {chassis_name}." + ], + } + def _extract_plasmids_from_strain( self, strain: sbol2.ModuleDefinition, diff --git a/tests/test_buildcompiler_transformation.py b/tests/test_buildcompiler_transformation.py new file mode 100644 index 0000000..87c5c83 --- /dev/null +++ b/tests/test_buildcompiler_transformation.py @@ -0,0 +1,69 @@ +import os +import sys +import unittest + +import sbol2 + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +from buildcompiler.buildcompiler import BuildCompiler +from buildcompiler.constants import ENGINEERED_PLASMID +from buildcompiler.plasmid import Plasmid + + +class TestBuildCompilerTransformation(unittest.TestCase): + def setUp(self): + self.doc = sbol2.Document() + self.compiler = BuildCompiler( + collections=[], + sbh_registry="https://synbiohub.org", + auth_token="", + sbol_doc=self.doc, + ) + + def _build_plasmid(self, display_id: str) -> Plasmid: + plasmid_definition = sbol2.ComponentDefinition(display_id) + plasmid_definition.roles = [ENGINEERED_PLASMID] + self.doc.add(plasmid_definition) + + plasmid_impl = sbol2.Implementation(f"{display_id}_impl") + plasmid_impl.built = plasmid_definition.identity + self.doc.add(plasmid_impl) + + return Plasmid(plasmid_definition, None, [plasmid_impl], [], self.doc) + + def test_transformation_with_assembly_products(self): + plasmid = self._build_plasmid("assembled_gene") + + result = self.compiler.transformation(assembly_products=[plasmid]) + + self.assertEqual(result["stage"], "transformation") + self.assertEqual(len(result["json"]["reactions"]), 1) + self.assertEqual(result["json"]["reactions"][0]["plasmid"], "assembled_gene") + self.assertEqual(len(result["sbol"]["transformed_strains"]), 1) + + def test_transformation_with_plasmid_inputs(self): + plasmid_definition = sbol2.ComponentDefinition("input_plasmid") + plasmid_definition.roles = [ENGINEERED_PLASMID] + self.doc.add(plasmid_definition) + + result = self.compiler.transformation(plasmid_inputs=[plasmid_definition]) + + self.assertEqual(result["stage"], "transformation") + self.assertEqual(result["inputs"], [plasmid_definition.identity]) + self.assertEqual(result["json"]["reactions"][0]["destination_strain"], "E_coli_DH5alpha_with_input_plasmid_1") + + def test_transformation_requires_single_input_channel(self): + with self.assertRaises(ValueError): + self.compiler.transformation() + + plasmid = self._build_plasmid("dual_mode") + with self.assertRaises(ValueError): + self.compiler.transformation( + assembly_products=[plasmid], + plasmid_inputs=[plasmid.plasmid_definition], + ) + + +if __name__ == "__main__": + unittest.main()