Custom transforms#

This tutorial will demonstrate the integration of an external software tool, fastANI, into the Metasmith framework.

The Jupyter notebook for this tutorial can be obtained by:

Terminal#
$ msm get tutorials/custom_transforms.ipynb

The completed fastANI transform can be obtained by:

Terminal#
$ msm get transforms/fastani.py

Prerequisites#

Note

If you are following along outside of Jupyter (for example as plain Python scripts), set WORKSPACE = Path("./") and MLIB = WORKSPACE/"MetasmithLibraries" at the top of each script, and treat any ipynbButtonLink(...) calls as illustrative — they are notebook helpers and can be skipped.

The shape of this tutorial#

A transform earns its keep by connecting existing transforms — turning two disconnected halves of the type graph into a single chain that the solver can plan over.

Upstream of fastANI, the standard library already provides getNcbiAssembly (in the logistics transform library), which fetches genomes from NCBI and produces sequences::assembly.

Downstream of fastANI, we would like to visualise the all-vs-all comparison as a pairwise heatmap — one cell per genome pair, color-coded by ANI. There is no transform for that today either, so we will add a tiny companion stub called ani_heatmap that consumes ani::table and produces pangenome::heatmap.

Today, sequences::assembly and pangenome::heatmap are disconnected: nothing turns a group of genomes into an ANI matrix. By adding fastani (assembly + pangenome → ani::table), both connections light up at once: getNcbiAssembly can feed into fastani, and fastani can feed into ani_heatmap. Below we will render each of these two connections as its own DAG, and close with a short remark on chaining all three transforms end to end.

Modelling a new transform#

Metasmith models each tool as a transform between data types. Each transform is described in terms of a contact consisting of required inputs and promised outputs. fastANI calculates the average nucleotide identity (ANI) between two nucleotide sequences, typically genomes. The contract for fastANI should therefore include that it requires a list of genomes and produces ANI values. The data type sequences::assembly already exists for genomes, but we will need to create a new one for ANI.

To begin, we will create a TransformInstanceLibrary and add a stub for fastANI.

Jupyter#
1ani_transforms_path = WORKSPACE/"ani_transforms"
2ani_transforms = TransformInstanceLibrary(ani_transforms_path)
3ani_transforms.AddStub("fastani")
4ani_transforms.Save()

Find and open the newly generated stub, which was just created under the folder ani_transforms_path.

Jupyter#
1ipynbButtonLink(url=ani_transforms_path/"fastani.py")

The stub consists of 4 parts:

fastani.py#
 1# First, the Metasmith API is imported.
 2from metasmith.python_api import *
 3
 4# Second, the contract is defined.
 5lib     = TransformInstanceLibrary.ResolveParentLibrary(__file__)
 6model   = Transform()
 7dep     = model.AddRequirement(lib.GetType("transforms::example input"))
 8out     = model.AddProduct(lib.GetType("transforms::example output"))
 9
10# Third, the protocol for executing the tool is defined as a function.
11def protocol(context: ExecutionContext):
12    dep_path = context.Input(dep)
13    out_path = context.Output(out)
14    context.external_shell.Exec(f"touch {out_path.external}")
15    return ExecutionResult(
16        manifest=[
17            {
18                out: out_path.local,
19            },
20        ],
21        success=out_path.local.exists()
22    )
23
24# Fourth, the above components are brought together
25# to create the actual transform that Metasmith will use.
26TransformInstance(
27    protocol=protocol,
28    model=model,
29    group_by=dep,
30)

The contract#

Have a look at the contracts of two transforms used in My first agent tutorial.

getNcbiAssembly

example#
 1# ...
 2lib     = TransformInstanceLibrary.ResolveParentLibrary(__file__)
 3model   = Transform()
 4dep     = model.AddRequirement(lib.GetType("ncbi::assembly_accession"))
 5image   = model.AddRequirement(lib.GetType("containers::ncbi-datasets.oci"))
 6fna     = model.AddProduct(lib.GetType("sequences::assembly"))
 7faa     = model.AddProduct(lib.GetType("sequences::orfs"))
 8gff     = model.AddProduct(lib.GetType("sequences::gff"))
 9gbk     = model.AddProduct(lib.GetType("sequences::gbk"))
10# ...

ppanggolin

example#
1# ...
2lib     = TransformInstanceLibrary.ResolveParentLibrary(__file__)
3model   = Transform()
4pan     = model.AddRequirement(lib.GetType("pangenome::pangenome"))
5gbk     = model.AddRequirement(lib.GetType("sequences::gbk"), parents={pan})
6image   = model.AddRequirement(lib.GetType("containers::ppanggolin.oci"))
7matrix  = model.AddProduct(lib.GetType("pangenome::ppanggolin_matrix"))
8pg      = model.AddProduct(lib.GetType("pangenome::ppanggolin_raw"))
9# ...

The ppanggolin contract askss for a sequences::gbk, which we see is one of the four products of getNcbiAssembly. Both also specify a container image to provide the software tool itself.

Tip

Type names are written as “namespace::type”, where the namespace is the name of a collection of related types within a DataTypeLibrary.

More on data types

Let’s take a peek at the sequences namespace provided by the standard library.

Jupyter#
1ipynbButtonLink(url=MLIB/"data_types/sequences.yml")
example#
 1types:
 2    assembly:
 3        properties:
 4            Format: FASTA
 5            Data: DNA sequence
 6            ext: fna
 7    gbk:
 8        properties:
 9            Format: genbank file
10            ext: gbk
11    # ...

The assembly type is described as a fasta file of nucleotide sequences, which matches the expected input of fastANI. By using sequences::assembly, we would expect fastANI to be able to plug directly into the output of getNcbiAssembly and any other tools in the ecosystem that produce a sequences::assembly, without further mental effort.

Let’s modify the stub based on what we’ve learned.

fastani.py#
1# ...
2lib     = TransformInstanceLibrary.ResolveParentLibrary(__file__)
3model   = Transform()
4pan     = model.AddRequirement(lib.GetType("pangenome::pangenome"))
5asm     = model.AddRequirement(lib.GetType("sequences::assembly"), parents={pan})
6image   = model.AddRequirement(lib.GetType("ani::fastani.oci"))
7out     = model.AddProduct(lib.GetType("ani::table"))
8# ...

We could add an ani_table to the pangenome namespace and the container image fastani.oci to the containers namespace, but let’s create a new ani namespace for the sake of this tutorial.

Jupyter#
1ani_types_path = WORKSPACE/"ani_types.yml"
2ani_types_path.touch()
3ipynbButtonLink(url=ani_types_path)

Add the following to the newly created empty file. Since we are not currently concerned with creating other transforms that will consume the ANI table, we will simply create a single property to describe ani::table, effectively making it “atomic”. Setting the file extension ext: tsv will tell Metasmith to create instances of ani::table in the form of *.tsv and is purely cosmetic.

ani_types.yml#
1types:
2    table:
3        properties:
4            _: average nucleotide identity table
5            ext: tsv
6    fastani.oci:
7        properties:
8            _: url for fastani container image

Our new namespace ani has two types.

Jupyter#
1ani_types = DataTypeLibrary.Load(ani_types_path)
2for name, model in ani_types:
3    print(name, model)
4
5# prints:
6
7# table <{_:[average nucleotide identity],ext:tsv}:ubCCa4JV>
8# fastani.oci <[url for fastani container image]:BkrOOCzA>

We will need to inform the TransformInstanceLibrary of available types. The following will error because the fastANI contract doesn’t agree with the rest of the transform.

Jupyter#
1ani_transforms_path = WORKSPACE/"ani_transforms"
2ani_transforms = TransformInstanceLibrary(ani_transforms_path)
3ani_transforms.AddTypeLibrary(lib=ani_types, namespace="ani")   # new
4ani_transforms.AddTypeLibrary(MLIB/"data_types/sequences.yml")  # new
5ani_transforms.AddTypeLibrary(MLIB/"data_types/pangenome.yml")  # new
6ani_transforms.AddStub("fastani")
7ani_transforms.Save()

The protocol#

The protocol is executed to fullfill the contract. First, we get instances for each of the expected and promised data.

fastani.py#
1# ...
2def protocol(context: ExecutionContext):
3    ipan = context.Input(pan)
4    iasm = context.InputGroup(asm)
5    iout = context.Output(out)

Next, we have to coerce the input files into what is expected by fastANI. The documentation suggests that a pairwise, all vs all comparison requires a file specifying the file path to each genome per line. No problem, we can write a bit of python to create the file.

fastani.py#
1genomes = "genomes.list"
2with open(genomes, "w") as f:
3    for path in iasm:
4        f.write(str(path.container)+"\n")

Since iasm is an InputGroup, the group can be iterated on to work with each element:

example#
1for instance in iasm:
2    # do something with instance

The most important information held in these instance objects is the path to the given data, or the expected path to the output data, where:

  • iout.local is the path within the current protocol

  • iout.external is the absolute path on the filesystem

  • iout.container is the path when viewed from inside a container

We can now use context.ExecWithContainer(...) to specify how fastANI will be run with the newly created genomes file.

fastani.py#
1threads = context.params.get('cpus')
2threads = "" if threads is None else f"--threads {threads}"
3context.ExecWithContainer(
4    image = image,
5    cmd = f"""
6        fastANI {threads} --queryList {genomes} --refList {genomes} --output {iout.container}
7    """,
8)

At the end of the protocol, we will report on the results by returning a manifest of outputs and indicating success.

fastani.py#
1    return ExecutionResult(
2        manifest=[
3            {
4                out: iout.local,
5            },
6        ],
7        success=iout.local.exists(),
8    )

Create the transform#

With both the protocol and contract created, we can formally define the transform for fastANI, along with default resource requests such as cpu, memory, and upper bound for runtime. The group_by parameter specifies how to group consecutive inputs. In this case, all other inputs will be grouped by the pangenome, which we only expect to affect sequence::assembly.

fastani.py#
 1# ...
 2TransformInstance(
 3    protocol=protocol,
 4    model=model, # the contract
 5    group_by=pan,
 6    resources=Resources(
 7        cpus=4,
 8        memory=Size.GB(8),
 9        duration=Duration(hours=3),
10    )
11)

The full fastani.py.

fastani.py#
 1from metasmith.python_api import *
 2
 3lib     = TransformInstanceLibrary.ResolveParentLibrary(__file__)
 4model   = Transform()
 5pan     = model.AddRequirement(lib.GetType("pangenome::pangenome"))
 6asm     = model.AddRequirement(lib.GetType("sequences::assembly"), parents={pan})
 7image   = model.AddRequirement(lib.GetType("ani::fastani.oci"))
 8out     = model.AddProduct(lib.GetType("ani::table"))
 9
10def protocol(context: ExecutionContext):
11    ipan = context.Input(pan)
12    iasm = context.InputGroup(asm)
13    iout = context.Output(out)
14
15    genomes = "genomes.list"
16    with open(genomes, "w") as f:
17        for path in iasm:
18            f.write(str(path.container)+"\n")
19
20    threads = context.params.get('cpus')
21    threads = "" if threads is None else f"--threads {threads}"
22    context.ExecWithContainer(
23        image = image,
24        cmd = f"""
25            fastANI {threads} --queryList {genomes} --refList {genomes} --output {iout.container}
26        """,
27    )
28
29    return ExecutionResult(
30        manifest=[
31            {
32                out: iout.local,
33            },
34        ],
35        success=iout.local.exists()
36    )
37
38TransformInstance(
39    protocol=protocol,
40    model=model, # the contract
41    group_by=pan,
42    resources=Resources(
43        cpus=4,
44        memory=Size.GB(8),
45        duration=Duration(hours=3),
46    )
47)

Testing#

Our TransformInstanceLibrary should now save sucessfully.

Jupyter#
1ani_transforms_path = WORKSPACE/"ani_transforms"
2ani_transforms = TransformInstanceLibrary(ani_transforms_path)
3ani_transforms.AddTypeLibrary(lib=ani_types, namespace="ani")
4ani_transforms.AddTypeLibrary(MLIB/"data_types/sequences.yml")
5ani_transforms.AddTypeLibrary(MLIB/"data_types/pangenome.yml")
6ani_transforms.AddStub("fastani")
7ani_transforms.AddStub("ani_heatmap")
8ani_transforms.Save()

Note

The ani_heatmap stub is added so the solver can find a downstream consumer of ani::table. We won’t run it — the default stub body just touch-es an output file, which is enough for the DAG demonstrations below. A real ani_heatmap would render an SVG from the all-vs-all similarity table.

Open the generated ani_heatmap.py and replace its contract lines with the following (leave the default protocol body and TransformInstance(...) call untouched):

ani_heatmap.py#
lib   = TransformInstanceLibrary.ResolveParentLibrary(__file__)
model = Transform()
ani   = model.AddRequirement(lib.GetType("ani::table"))
out   = model.AddProduct(lib.GetType("pangenome::heatmap"))

To test fastANI, we will need to prepare inputs and the container image.

Jupyter#
 1inputs_path = WORKSPACE/"ani_test_inputs.xgdb"
 2try:
 3    inputs = DataInstanceLibrary.Load(inputs_path)
 4except:
 5    inputs = DataInstanceLibrary(inputs_path)
 6    # add data types
 7    inputs.AddTypeLibrary(MLIB/"data_types/pangenome.yml")
 8    inputs.AddTypeLibrary(MLIB/"data_types/ncbi.yml")
 9    inputs.AddTypeLibrary(ani_types, namespace="ani")
10
11    # register inputs
12    group = inputs.AddValue("pangenome", "e coli", "pangenome::pangenome")
13    inputs.AddValue("DH10b", "GCF_000019425.1", "ncbi::assembly_accession", parents={group})
14    inputs.AddValue("K12", "GCF_000005845.2", "ncbi::assembly_accession", parents={group})
15    inputs.AddValue("EPI300", "GCF_049667475.1", "ncbi::assembly_accession", parents={group})
16    inputs.AddValue("fastani.oci", "docker://staphb/fastani:1.34", "ani::fastani.oci")
17    inputs.Save()

We will prepare resources and transforms using the same method shown in the My first agent tutorial.

Jupyter#
 1resources = [
 2    DataInstanceLibrary.Load(MLIB/f"resources/{n}")
 3    for n in ["containers"]
 4] + [
 5    view
 6    for view in inputs.AsSamples("ani::fastani.oci")
 7]
 8
 9transforms = [
10    TransformInstanceLibrary.Load(MLIB/f"transforms/{n}")
11    for n in ["logistics"]
12] + [
13    ani_transforms
14]

Upstream chain#

The first DAG demonstrates how fastani plugs into the existing getNcbiAssembly transform. We start from NCBI accessions and target ani::table; the solver discovers the chain getNcbiAssembly fastani automatically.

Jupyter#
 1agent_home = Source.FromLocal(WORKSPACE/"msm_home")
 2smith = Agent(
 3    home = agent_home,
 4    runtime=ContainerRuntime.DOCKER,
 5)
 6
 7upstream_targets = TargetBuilder()
 8upstream_targets.Add("ani::table")
 9task = smith.GenerateWorkflow(
10    samples=inputs.AsSamples("ncbi::assembly_accession"),
11    resources=resources,
12    transforms=transforms,
13    targets=upstream_targets,
14)
15
16upstream_dag = task.plan.RenderDAG(WORKSPACE/"ani_dag_upstream.svg")
17ipynbButtonLink(upstream_dag)
the upstream chain — getNcbiAssembly feeds fastani

getNcbiAssembly produces sequences::assembly, which fastani consumes to produce ani::table.#

Downstream chain#

The second DAG demonstrates how fastani plugs into the new ani_heatmap stub. We mock a starting set of sequences::assembly samples and target pangenome::heatmap; the solver discovers the chain fastani ani_heatmap.

Jupyter#
 1ani_demo_inputs_path = WORKSPACE/"ani_demo_inputs.xgdb"
 2try:
 3    ani_demo_inputs = DataInstanceLibrary.Load(ani_demo_inputs_path)
 4except:
 5    ani_demo_inputs = DataInstanceLibrary(ani_demo_inputs_path)
 6    ani_demo_inputs.AddTypeLibrary(MLIB/"data_types/pangenome.yml")
 7    ani_demo_inputs.AddTypeLibrary(MLIB/"data_types/sequences.yml")
 8    ani_demo_inputs.AddTypeLibrary(ani_types, namespace="ani")
 9
10    # mock assemblies — the solver only needs samples to plan a DAG;
11    # the values are placeholder paths, never read
12    group = ani_demo_inputs.AddValue("pangenome", "e coli", "pangenome::pangenome")
13    ani_demo_inputs.AddValue("DH10b", "mock_DH10b.fna", "sequences::assembly", parents={group})
14    ani_demo_inputs.AddValue("K12", "mock_K12.fna", "sequences::assembly", parents={group})
15    ani_demo_inputs.AddValue("fastani.oci", "docker://staphb/fastani:1.34", "ani::fastani.oci")
16    ani_demo_inputs.Save()
17
18downstream_resources = [
19    view for view in ani_demo_inputs.AsSamples("ani::fastani.oci")
20]
21downstream_targets = TargetBuilder()
22downstream_targets.Add("pangenome::heatmap")
23downstream_task = smith.GenerateWorkflow(
24    samples=ani_demo_inputs.AsSamples("sequences::assembly"),
25    resources=downstream_resources,
26    transforms=transforms,
27    targets=downstream_targets,
28)
29
30downstream_dag = downstream_task.plan.RenderDAG(WORKSPACE/"ani_dag_downstream.svg")
31ipynbButtonLink(downstream_dag)

The rendered DAG shows fastani producing ani::table and ani_heatmap consuming it to produce pangenome::heatmap.

Chaining all three#

Tip

Targeting pangenome::heatmap from ncbi::assembly_accession sources directly would produce the full chain accession assembly ani::table heatmap in a single DAG. The construction is identical — change only the target on the upstream plan above and let the solver discover the full chain. We don’t demonstrate it here; the two demonstrations above already cover both of the new connections in isolation.

Stage and run#

Finally, we can stage and run the upstream task to actually compute an ANI table. The following also includes a bit of resource tweaks to let the three download steps execute concurrently.

Jupyter#
 1smith.StageWorkflow(task, on_exist="update")
 2
 3smith.RunWorkflow(
 4    task,
 5    config_file=smith.GetNxfConfigPresets()["local"],
 6    params= dict(
 7        executor=dict(
 8            cpus=14,
 9            queueSize=3, # explicitly set 3 jobs to run in parallel
10        ),
11        process=dict(
12            tries=1,
13        ),
14    ),
15    resource_overrides={
16        "*": Resources(
17            memory=Size.GB(1),
18        ),
19        "fastani": Resources(
20            cpus=14, # give fastANI all the threads
21        )
22    }
23)

Tip

It is possible to perform a dry run by setting stub_delay to a positive number. This will have nextflow execute mock protocols for each process.

1smith.RunWorkflow(
2    # ...
3    stub_delay=3.0,
4)

Once complete, we can have a look at the results.

Jupyter#
1results_path = smith.GetResultSource(task).GetPath()
2results = DataInstanceLibrary.Load(results_path)
3
4ipynbButtonLink(results_path/"_metadata/logs.latest/nxf_report.html")
5
6for path, type_name, endpoint in results.Iterate():
7    if path.is_absolute(): continue # inputs have absolute paths
8    ipynbButtonLink(results_path/path, f'view {type_name} {path.name}')

Next steps#

Other tutorials are available in the section panel on the left.