Skip to content

API reference

Built from the docstrings in src/liulab_mbio/, so this page and the code cannot drift apart. Write the docstring; this page follows.

What to import

liulab_mbio itself re-exports only __version__. Import a name from the module that owns it:

from liulab_mbio.io import read_record
from liulab_mbio.goldengate import plan_assembly

Two names are spelled twice across the package on purpose — Check and Junction each mean something different in the two modules that define them. A liulab_mbio.checks.Check is the judged check, with the value it measured. A liulab_mbio.protocol.Check is how a protocol page shows one. A flat re-export would have to rename one of each pair, and would import every dependency the moment you imported the package. So the module path is the name.

liulab_mbio.goldengate re-exports the pipeline's entry point and its result types. design and ligase are not re-exported: reach them at liulab_mbio.goldengate.design and liulab_mbio.goldengate.ligase.

The examples are tests

pixi run check runs the Examples blocks in src/liulab_mbio/, so an example that no longer matches its code fails the tests.

Write one where it makes the object easier to use, and leave it out where it would not. An example nobody keeps up to date is worse than none.

Keep an example cheap, offline and deterministic. It has to give the same answer on any machine, with no network. A line that cannot do that needs # doctest: +SKIP at the end of that line.

Two things about that marker are easy to get backwards:

  • It covers only the line it sits on. It does not carry to the line below. In a block that mixes lines that run with lines that cannot, each line that cannot run needs its own.
  • A trailing comment written as plain prose looks just like a marker and is not one. Only the # doctest: form is read as one.

The sequence model

Everything else reads and writes these. Coordinates are 0-based and half-open, and a span across the origin of a circular record ends past the record's length — see the coordinates decision.

liulab_mbio.sequence

The sequence model every other module reads and writes.

Coordinates are 0-based and half-open: Segment(start, end) holds bases start to end - 1. A segment across the origin of a circular record ends past the record's length. File formats convert at their own boundary; see docs/adr/0001-coordinates.md.

BindingSite dataclass

Where a primer's 3' part anneals to a record.

Parameters:

Name Type Description Default
start int

0-based, half-open, as for Segment. The primer's 5' tail lies outside.

required
end int

0-based, half-open, as for Segment. The primer's 5' tail lies outside.

required
strand Strand

Strand.FORWARD when the primer reads along the top strand, towards higher coordinates; Strand.REVERSE when it reads along the bottom strand.

required

Raises:

Type Description
ValueError

Unless 0 <= start < end and strand is forward or reverse.

Source code in src/liulab_mbio/sequence.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
@dataclass(frozen=True, slots=True)
class BindingSite:
    """Where a primer's 3' part anneals to a record.

    Parameters
    ----------
    start, end
        0-based, half-open, as for `Segment`. The primer's 5' tail lies outside.
    strand
        `Strand.FORWARD` when the primer reads along the top strand, towards higher
        coordinates; `Strand.REVERSE` when it reads along the bottom strand.

    Raises
    ------
    ValueError
        Unless ``0 <= start < end`` and `strand` is forward or reverse.
    """

    start: int
    end: int
    strand: Strand

    def __post_init__(self) -> None:
        """Refuse an empty span or a strand that is neither forward nor reverse."""
        _check_span(self.start, self.end)
        if self.strand not in (Strand.FORWARD, Strand.REVERSE):
            raise ValueError(f"a binding site needs a forward or reverse strand, got {self.strand}")

__post_init__

__post_init__() -> None

Refuse an empty span or a strand that is neither forward nor reverse.

Source code in src/liulab_mbio/sequence.py
149
150
151
152
153
def __post_init__(self) -> None:
    """Refuse an empty span or a strand that is neither forward nor reverse."""
    _check_span(self.start, self.end)
    if self.strand not in (Strand.FORWARD, Strand.REVERSE):
        raise ValueError(f"a binding site needs a forward or reverse strand, got {self.strand}")

Feature dataclass

A named, typed annotation over one or more segments.

Parameters:

Name Type Description Default
name str

The label shown on a map.

required
type str

A GenBank feature key, such as "CDS" or "promoter".

required
segments tuple[Segment, ...]

In top-strand order, whatever the strand.

required
strand Strand

The strand the feature reads along.

NONE
qualifiers Mapping[str, tuple[str | int, ...]]

GenBank-style qualifiers, each holding one or more values.

dict()
color str | None

"#rrggbb", or None for the writer's default.

None

Raises:

Type Description
ValueError

If segments is empty.

Source code in src/liulab_mbio/sequence.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@dataclass(frozen=True, slots=True)
class Feature:
    """A named, typed annotation over one or more segments.

    Parameters
    ----------
    name
        The label shown on a map.
    type
        A GenBank feature key, such as ``"CDS"`` or ``"promoter"``.
    segments
        In top-strand order, whatever the strand.
    strand
        The strand the feature reads along.
    qualifiers
        GenBank-style qualifiers, each holding one or more values.
    color
        ``"#rrggbb"``, or ``None`` for the writer's default.

    Raises
    ------
    ValueError
        If `segments` is empty.
    """

    name: str
    type: str
    segments: tuple[Segment, ...]
    _: KW_ONLY
    strand: Strand = Strand.NONE
    qualifiers: Mapping[str, tuple[str | int, ...]] = field(default_factory=dict, hash=False)
    color: str | None = None

    def __post_init__(self) -> None:
        """Refuse a feature with no segment."""
        if not self.segments:
            raise ValueError(f"feature {self.name!r} has no segment")

__post_init__

__post_init__() -> None

Refuse a feature with no segment.

Source code in src/liulab_mbio/sequence.py
121
122
123
124
def __post_init__(self) -> None:
    """Refuse a feature with no segment."""
    if not self.segments:
        raise ValueError(f"feature {self.name!r} has no segment")

Primer dataclass

A named oligonucleotide.

Parameters:

Name Type Description Default
name str

The name it is ordered under.

required
sequence str

5' to 3', tail included. Stored upper-case.

required
binding_sites tuple[BindingSite, ...]

Where it anneals to the record that carries it.

()
description str

Free text.

''

Raises:

Type Description
ValueError

If sequence holds a letter outside IUPAC_DNA.

Source code in src/liulab_mbio/sequence.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
@dataclass(frozen=True, slots=True)
class Primer:
    """A named oligonucleotide.

    Parameters
    ----------
    name
        The name it is ordered under.
    sequence
        5' to 3', tail included. Stored upper-case.
    binding_sites
        Where it anneals to the record that carries it.
    description
        Free text.

    Raises
    ------
    ValueError
        If `sequence` holds a letter outside `IUPAC_DNA`.
    """

    name: str
    sequence: str
    _: KW_ONLY
    binding_sites: tuple[BindingSite, ...] = ()
    description: str = ""

    def __post_init__(self) -> None:
        """Upper-case and check the sequence."""
        object.__setattr__(self, "sequence", _dna(self.sequence))

__post_init__

__post_init__() -> None

Upper-case and check the sequence.

Source code in src/liulab_mbio/sequence.py
183
184
185
def __post_init__(self) -> None:
    """Upper-case and check the sequence."""
    object.__setattr__(self, "sequence", _dna(self.sequence))

Segment dataclass

One contiguous span of a feature.

Parameters:

Name Type Description Default
start int

0-based, half-open. end passes the length of a circular record when the segment crosses the origin.

required
end int

0-based, half-open. end passes the length of a circular record when the segment crosses the origin.

required
name str

A label for this segment alone, such as a promoter's "-10".

''
color str | None

"#rrggbb", or None to take the feature's colour.

None

Raises:

Type Description
ValueError

Unless 0 <= start < end.

Source code in src/liulab_mbio/sequence.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@dataclass(frozen=True, slots=True)
class Segment:
    """One contiguous span of a feature.

    Parameters
    ----------
    start, end
        0-based, half-open. `end` passes the length of a circular record when the segment
        crosses the origin.
    name
        A label for this segment alone, such as a promoter's ``"-10"``.
    color
        ``"#rrggbb"``, or ``None`` to take the feature's colour.

    Raises
    ------
    ValueError
        Unless ``0 <= start < end``.
    """

    start: int
    end: int
    _: KW_ONLY
    name: str = ""
    color: str | None = None

    def __post_init__(self) -> None:
        """Refuse an empty or negative span."""
        _check_span(self.start, self.end)

__post_init__

__post_init__() -> None

Refuse an empty or negative span.

Source code in src/liulab_mbio/sequence.py
83
84
85
def __post_init__(self) -> None:
    """Refuse an empty or negative span."""
    _check_span(self.start, self.end)

SequenceRecord dataclass

A DNA sequence with its topology, features, primers and notes.

Parameters:

Name Type Description Default
sequence str

Stored upper-case.

required
topology Topology

"linear" or "circular".

'linear'
name str

The name shown on a map.

''
features tuple[Feature, ...]

Every feature segment and primer binding site must fit the sequence under its topology.

()
primers tuple[Feature, ...]

Every feature segment and primer binding site must fit the sequence under its topology.

()
notes Mapping[str, str]

Descriptive fields, such as "Description", keyed by name.

dict()
extras Mapping[str, object]

Format-specific data a reader keeps for its writer. Ignored by ==.

dict()

Raises:

Type Description
ValueError

If sequence holds a letter outside IUPAC_DNA, topology is unknown, or a span does not fit.

Source code in src/liulab_mbio/sequence.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
@dataclass(frozen=True, slots=True)
class SequenceRecord:
    """A DNA sequence with its topology, features, primers and notes.

    Parameters
    ----------
    sequence
        Stored upper-case.
    topology
        ``"linear"`` or ``"circular"``.
    name
        The name shown on a map.
    features, primers
        Every feature segment and primer binding site must fit the sequence under its topology.
    notes
        Descriptive fields, such as ``"Description"``, keyed by name.
    extras
        Format-specific data a reader keeps for its writer. Ignored by ``==``.

    Raises
    ------
    ValueError
        If `sequence` holds a letter outside `IUPAC_DNA`, `topology` is unknown, or a span does
        not fit.
    """

    sequence: str
    _: KW_ONLY
    topology: Topology = "linear"
    name: str = ""
    features: tuple[Feature, ...] = ()
    primers: tuple[Primer, ...] = ()
    notes: Mapping[str, str] = field(default_factory=dict, hash=False)
    extras: Mapping[str, object] = field(default_factory=dict, hash=False, compare=False)

    def __post_init__(self) -> None:
        """Upper-case the sequence and check every span fits."""
        if self.topology not in ("linear", "circular"):
            raise ValueError(f"topology must be 'linear' or 'circular', got {self.topology!r}")
        object.__setattr__(self, "sequence", _dna(self.sequence))
        for feature in self.features:
            for segment in feature.segments:
                self._check_fits(segment.start, segment.end, f"feature {feature.name!r}")
        for primer in self.primers:
            for site in primer.binding_sites:
                self._check_fits(site.start, site.end, f"primer {primer.name!r}")

    def __len__(self) -> int:
        """Return the number of bases."""
        return len(self.sequence)

    def extract(self, span: Feature | Segment) -> str:
        """Return the bases under a feature or segment, reading across the origin.

        A reverse-strand feature reads as the reverse complement of its joined segments. A bare
        segment reads off the top strand.

        Raises
        ------
        ValueError
            If a segment does not fit this record.

        Examples
        --------
        >>> SequenceRecord("AACCGGTTAC", topology="circular").extract(Segment(8, 12))
        'ACAA'
        """
        segments = span.segments if isinstance(span, Feature) else (span,)
        bases = "".join(self._bases(segment) for segment in segments)
        if isinstance(span, Feature) and span.strand == Strand.REVERSE:
            return reverse_complement(bases)
        return bases

    def _bases(self, segment: Segment) -> str:
        self._check_fits(segment.start, segment.end, "extracted")
        n = len(self.sequence)
        if segment.end <= n:
            return self.sequence[segment.start : segment.end]
        return self.sequence[segment.start :] + self.sequence[: segment.end - n]

    def _check_fits(self, start: int, end: int, owner: str) -> None:
        n = len(self.sequence)
        span = f"{owner} span {start}-{end}"
        if self.topology == "linear":
            if end > n:
                raise ValueError(f"{span} runs past the end of a linear record of {n} bases")
        elif start >= n or end - start > n:
            raise ValueError(f"{span} does not fit a circular record of {n} bases")

__len__

__len__() -> int

Return the number of bases.

Source code in src/liulab_mbio/sequence.py
235
236
237
def __len__(self) -> int:
    """Return the number of bases."""
    return len(self.sequence)

__post_init__

__post_init__() -> None

Upper-case the sequence and check every span fits.

Source code in src/liulab_mbio/sequence.py
223
224
225
226
227
228
229
230
231
232
233
def __post_init__(self) -> None:
    """Upper-case the sequence and check every span fits."""
    if self.topology not in ("linear", "circular"):
        raise ValueError(f"topology must be 'linear' or 'circular', got {self.topology!r}")
    object.__setattr__(self, "sequence", _dna(self.sequence))
    for feature in self.features:
        for segment in feature.segments:
            self._check_fits(segment.start, segment.end, f"feature {feature.name!r}")
    for primer in self.primers:
        for site in primer.binding_sites:
            self._check_fits(site.start, site.end, f"primer {primer.name!r}")

extract

extract(span: Feature | Segment) -> str

Return the bases under a feature or segment, reading across the origin.

A reverse-strand feature reads as the reverse complement of its joined segments. A bare segment reads off the top strand.

Raises:

Type Description
ValueError

If a segment does not fit this record.

Examples:

>>> SequenceRecord("AACCGGTTAC", topology="circular").extract(Segment(8, 12))
'ACAA'
Source code in src/liulab_mbio/sequence.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def extract(self, span: Feature | Segment) -> str:
    """Return the bases under a feature or segment, reading across the origin.

    A reverse-strand feature reads as the reverse complement of its joined segments. A bare
    segment reads off the top strand.

    Raises
    ------
    ValueError
        If a segment does not fit this record.

    Examples
    --------
    >>> SequenceRecord("AACCGGTTAC", topology="circular").extract(Segment(8, 12))
    'ACAA'
    """
    segments = span.segments if isinstance(span, Feature) else (span,)
    bases = "".join(self._bases(segment) for segment in segments)
    if isinstance(span, Feature) and span.strand == Strand.REVERSE:
        return reverse_complement(bases)
    return bases

Strand

Bases: IntEnum

The strand a feature or primer binding site lies on.

Source code in src/liulab_mbio/sequence.py
47
48
49
50
51
52
53
54
class Strand(IntEnum):
    """The strand a feature or primer binding site lies on."""

    FORWARD = 1
    REVERSE = -1
    NONE = 0
    #: SnapGene's bidirectional.
    BOTH = 2

reverse_complement

reverse_complement(sequence: str) -> str

Return the reverse complement of an IUPAC DNA sequence, keeping its case.

Examples:

>>> reverse_complement("GGTCTC")
'GAGACC'
Source code in src/liulab_mbio/sequence.py
24
25
26
27
28
29
30
31
32
def reverse_complement(sequence: str) -> str:
    """Return the reverse complement of an IUPAC DNA sequence, keeping its case.

    Examples
    --------
    >>> reverse_complement("GGTCTC")
    'GAGACC'
    """
    return sequence.translate(_COMPLEMENT)[::-1]

Checks

liulab_mbio.checks

A check: one verdict and the value it judged, and the worst of several verdicts.

A check no sourced threshold judges carries no verdict, None, rather than a pass.

Check dataclass

One verdict on a primer, a pair or an assembled product, with the value it judged.

Parameters:

Name Type Description Default
name str

What was measured, such as "gc_clamp".

required
status Status | None

One of STATUSES, or None where no sourced threshold judges it.

required
value float

The measurement, in the unit the module that judged it documents.

required
detail str

What a reader needs besides the number.

''
Source code in src/liulab_mbio/checks.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@dataclass(frozen=True, slots=True)
class Check:
    """One verdict on a primer, a pair or an assembled product, with the value it judged.

    Parameters
    ----------
    name
        What was measured, such as ``"gc_clamp"``.
    status
        One of `STATUSES`, or ``None`` where no sourced threshold judges it.
    value
        The measurement, in the unit the module that judged it documents.
    detail
        What a reader needs besides the number.
    """

    name: str
    status: Status | None
    value: float
    detail: str = ""

worst

worst(statuses: Iterable[Status | None]) -> Status

Return the worst of these verdicts, passing over any that is None.

Nothing judged at all is a pass.

Examples:

>>> worst(("pass", None, "warn"))
'warn'
Source code in src/liulab_mbio/checks.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def worst(statuses: Iterable[Status | None]) -> Status:
    """Return the worst of these verdicts, passing over any that is ``None``.

    Nothing judged at all is a pass.

    Examples
    --------
    >>> worst(("pass", None, "warn"))
    'warn'
    """
    result: Status = "pass"
    for status in statuses:
        if status is not None and STATUSES.index(status) > STATUSES.index(result):
            result = status
    return result

Files

liulab_mbio.io

Read a sequence file into the shared model, choosing the reader by its suffix.

read_record

read_record(path: str | PathLike[str]) -> SequenceRecord

Read a .dna, GenBank or FASTA file holding one sequence.

Raises:

Type Description
ValueError

If the suffix names no reader, or the file holds no single record.

Source code in src/liulab_mbio/io.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def read_record(path: str | os.PathLike[str]) -> SequenceRecord:
    """Read a ``.dna``, GenBank or FASTA file holding one sequence.

    Raises
    ------
    ValueError
        If the suffix names no reader, or the file holds no single record.
    """
    suffix = Path(path).suffix.lower()
    if suffix == ".dna":
        from liulab_mbio.snapgene import read_dna

        return read_dna(path)
    if (fmt := _BIOPYTHON.get(suffix)) is None:
        raise ValueError(f"no reader for a '{suffix.lstrip('.')}' file: {os.fspath(path)}")
    from Bio import SeqIO

    return _converted(SeqIO.read(os.fspath(path), fmt))

liulab_mbio.snapgene

Read and write SnapGene .dna files as SequenceRecord.

A .dna file is a stream of packets: a type byte, a big-endian 4-byte length, then the payload. SnapGene's 1-based inclusive ranges are converted here and nowhere else.

What the model does not hold — the cut-site cache, history, display settings, and the extra attributes SnapGene writes on a feature — is kept in record.extras and written back verbatim. A packet that describes the bases is dropped once the sequence changes, so that SnapGene recomputes it.

read_dna

read_dna(path: str | PathLike[str]) -> SequenceRecord

Read a SnapGene .dna file.

SnapGene's HTML markup in note and qualifier text is kept as it is written.

Raises:

Type Description
ValueError

If the file is not a SnapGene DNA file.

Source code in src/liulab_mbio/snapgene.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def read_dna(path: str | os.PathLike[str]) -> SequenceRecord:
    """Read a SnapGene ``.dna`` file.

    SnapGene's HTML markup in note and qualifier text is kept as it is written.

    Raises
    ------
    ValueError
        If the file is not a SnapGene DNA file.
    """
    packets = list(_packets(Path(path).read_bytes()))
    if not packets or packets[0][0] != _COOKIE or not packets[0][1].startswith(b"SnapGene"):
        raise ValueError(f"{os.fspath(path)} is not a SnapGene file")
    sequences = [payload for kind, payload in packets if kind == _SEQUENCE]
    if len(sequences) != 1:
        raise ValueError(f"{os.fspath(path)} holds {len(sequences)} DNA sequence packets, not 1")
    flags, bases = sequences[0][0], sequences[0][1:].decode("ascii")
    topology: Topology = "circular" if flags & _CIRCULAR else "linear"
    by_kind = dict(packets)
    features, feature_xml = (
        _read_features(by_kind[_FEATURES], len(bases)) if _FEATURES in by_kind else ((), {})
    )
    primers, primer_xml, hybridization = (
        _read_primers(by_kind[_PRIMERS], len(bases))
        if _PRIMERS in by_kind
        else ((), {}, _HYBRIDIZATION)
    )
    name, notes = _read_notes(by_kind[_NOTES]) if _NOTES in by_kind else ("", {})
    kept = _Kept(
        sequence=bases,
        topology=topology,
        flags=flags,
        packets=tuple((kind, None if kind in _MODELLED else payload) for kind, payload in packets),
        features=feature_xml,
        primers=primer_xml,
        hybridization=hybridization,
        notes_packet=by_kind.get(_NOTES),
        name=name,
        notes=notes,
    )
    return SequenceRecord(
        bases,
        topology=topology,
        name=name,
        features=features,
        primers=primers,
        notes=notes,
        extras={"snapgene": kept},
    )

write_dna

write_dna(
    record: SequenceRecord, path: str | PathLike[str]
) -> None

Write record as a SnapGene .dna file.

A feature or primer unchanged since the file it was read from is written back verbatim, so that what the model does not hold survives. A feature with no colour takes SnapGene's default grey.

Source code in src/liulab_mbio/snapgene.py
244
245
246
247
248
249
250
251
def write_dna(record: SequenceRecord, path: str | os.PathLike[str]) -> None:
    """Write `record` as a SnapGene ``.dna`` file.

    A feature or primer unchanged since the file it was read from is written back verbatim, so
    that what the model does not hold survives. A feature with no colour takes SnapGene's
    default grey.
    """
    Path(path).write_bytes(_dump(record))

liulab_mbio.edits

Insert, delete and replace spans of a SequenceRecord, shifting what it annotates.

The functions here know no file format. Coordinates are the model's: 0-based, half-open, and a span across the origin of a circular record ends past the record's length.

EditReport dataclass

What an edit did to the features and binding sites it did not simply shift.

Every entry holds the feature or primer as it was before the edit.

Attributes:

Name Type Description
trimmed tuple[Feature, ...]

Features that lost bases, including any that lost a whole segment.

dropped tuple[Feature, ...]

Features left with no bases at all.

changed tuple[Feature, ...]

Features the edit fell inside: kept, and now spanning the new bases.

dropped_sites tuple[tuple[Primer, BindingSite], ...]

Binding sites the edit overlapped, with the primer they belong to. A primer no longer anneals where its bases changed, so such a site is dropped rather than trimmed.

Source code in src/liulab_mbio/edits.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@dataclass(frozen=True, slots=True)
class EditReport:
    """What an edit did to the features and binding sites it did not simply shift.

    Every entry holds the feature or primer as it was **before** the edit.

    Attributes
    ----------
    trimmed
        Features that lost bases, including any that lost a whole segment.
    dropped
        Features left with no bases at all.
    changed
        Features the edit fell inside: kept, and now spanning the new bases.
    dropped_sites
        Binding sites the edit overlapped, with the primer they belong to. A primer no longer
        anneals where its bases changed, so such a site is dropped rather than trimmed.
    """

    trimmed: tuple[Feature, ...] = ()
    dropped: tuple[Feature, ...] = ()
    changed: tuple[Feature, ...] = ()
    dropped_sites: tuple[tuple[Primer, BindingSite], ...] = ()

delete

delete(
    record: SequenceRecord, start: int, end: int
) -> tuple[SequenceRecord, EditReport]

Delete the bases in [start, end).

Source code in src/liulab_mbio/edits.py
60
61
62
def delete(record: SequenceRecord, start: int, end: int) -> tuple[SequenceRecord, EditReport]:
    """Delete the bases in ``[start, end)``."""
    return replace(record, start, end, "")

insert

insert(
    record: SequenceRecord, position: int, bases: str
) -> tuple[SequenceRecord, EditReport]

Insert bases before position, shifting everything after it.

Examples:

>>> record, report = insert(SequenceRecord("AACCGG"), 2, "TT")
>>> record.sequence
'AATTCCGG'
Source code in src/liulab_mbio/edits.py
48
49
50
51
52
53
54
55
56
57
def insert(record: SequenceRecord, position: int, bases: str) -> tuple[SequenceRecord, EditReport]:
    """Insert `bases` before `position`, shifting everything after it.

    Examples
    --------
    >>> record, report = insert(SequenceRecord("AACCGG"), 2, "TT")
    >>> record.sequence
    'AATTCCGG'
    """
    return replace(record, position, position, bases)

replace

replace(
    record: SequenceRecord, start: int, end: int, bases: str
) -> tuple[SequenceRecord, EditReport]

Replace the bases in [start, end) with bases.

On a circular record the span may run across the origin, ending past the record's length; the bases that remain keep their own indices where they can, so the origin moves only when the edit removes it.

Raises:

Type Description
ValueError

If the span does not fit the record under its topology.

Source code in src/liulab_mbio/edits.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def replace(
    record: SequenceRecord, start: int, end: int, bases: str
) -> tuple[SequenceRecord, EditReport]:
    """Replace the bases in ``[start, end)`` with `bases`.

    On a circular record the span may run across the origin, ending past the record's length;
    the bases that remain keep their own indices where they can, so the origin moves only when
    the edit removes it.

    Raises
    ------
    ValueError
        If the span does not fit the record under its topology.
    """
    length = len(record)
    _check_span(record, start, end)
    if record.topology == "linear":
        return _splice(record, record, start, end - start, bases, wrapped=False)
    turned = rotate(record, start)
    edited, report = _splice(turned, record, 0, end - start, bases, wrapped=True)
    origin = len(bases) + (length - end if end <= length else 0)
    return (rotate(edited, origin) if len(edited) else edited), report

rotate

rotate(
    record: SequenceRecord, origin: int
) -> SequenceRecord

Return the circular record read from origin, which becomes its first base.

Raises:

Type Description
ValueError

If the record is linear.

Examples:

>>> rotate(SequenceRecord("AACCGG", topology="circular"), 2).sequence
'CCGGAA'
Source code in src/liulab_mbio/edits.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def rotate(record: SequenceRecord, origin: int) -> SequenceRecord:
    """Return the circular `record` read from `origin`, which becomes its first base.

    Raises
    ------
    ValueError
        If the record is linear.

    Examples
    --------
    >>> rotate(SequenceRecord("AACCGG", topology="circular"), 2).sequence
    'CCGGAA'
    """
    if record.topology != "linear":
        length = len(record)
        start = origin % length
        return dataclasses.replace(
            record,
            sequence=record.sequence[start:] + record.sequence[:start],
            features=tuple(_turned_feature(feature, start, length) for feature in record.features),
            primers=tuple(_turned_primer(primer, start, length) for primer in record.primers),
        )
    raise ValueError("only a circular record has an origin to rotate")

Enzymes, sites and codons

liulab_mbio.enzymes

Restriction enzymes: the shipped records and how their cut offsets are written.

A cut offset is a 0-based boundary counted from the first base of the recognition site as written 5' to 3', like a slice point: offset k severs a strand between site bases k - 1 and k. Enzyme.top_cut is the cut in the strand carrying the site, Enzyme.bottom_cut the cut in the complementary strand, written in that same frame — the bottom strand is severed between the partners of bases k - 1 and k.

An offset outside 0..len(site) is a cut outside the site, which is what makes an enzyme Type IIS. REBASE's GGTCTC(1/5) for BsaI is top_cut=7, bottom_cut=11.

Enzyme dataclass

One restriction enzyme and where it cuts.

Parameters:

Name Type Description Default
name str

The REBASE name, such as "BsaI".

required
site str

The recognition site, IUPAC, 5' to 3'. Stored upper-case.

required
top_cut int

Cut offsets, as described in the module docstring.

required
bottom_cut int

Cut offsets, as described in the module docstring.

required
isoschizomers tuple[str, ...]

Enzymes a supplier sells that read and cut exactly this site. Neoschizomers — the same site cut elsewhere, as XmaI cuts SmaI's — are not among them.

()
commercial_name str | None

The product the other supplier fields describe, such as "BsaI-HFv2", "R3733".

None
catalog_number str | None

The product the other supplier fields describe, such as "BsaI-HFv2", "R3733".

None
supplier str | None

The product the other supplier fields describe, such as "BsaI-HFv2", "R3733".

None
incubation_celsius int | None

The supplier's digestion temperature, which is not always a protocol's temperature.

None
heat_inactivation_celsius int | None

None when the supplier says heat does not inactivate the enzyme, and also when it says nothing — which is what unverified then names.

None
heat_inactivation_minutes int | None

None when the supplier says heat does not inactivate the enzyme, and also when it says nothing — which is what unverified then names.

None
methylation Mapping[str, str]

The supplier's Dam, Dcm and CpG sensitivity, keyed "dam", "dcm", "cpg".

dict()
unverified tuple[str, ...]

Fields no source stated. Never a guess.

()

Raises:

Type Description
ValueError

If site is empty or holds a letter outside IUPAC_DNA.

Source code in src/liulab_mbio/enzymes.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@dataclass(frozen=True, slots=True)
class Enzyme:
    """One restriction enzyme and where it cuts.

    Parameters
    ----------
    name
        The REBASE name, such as ``"BsaI"``.
    site
        The recognition site, IUPAC, 5' to 3'. Stored upper-case.
    top_cut, bottom_cut
        Cut offsets, as described in the module docstring.
    isoschizomers
        Enzymes a supplier sells that read and cut exactly this site. Neoschizomers — the same
        site cut elsewhere, as XmaI cuts SmaI's — are not among them.
    commercial_name, catalog_number, supplier
        The product the other supplier fields describe, such as ``"BsaI-HFv2"``, ``"R3733"``.
    incubation_celsius
        The supplier's digestion temperature, which is not always a protocol's temperature.
    heat_inactivation_celsius, heat_inactivation_minutes
        ``None`` when the supplier says heat does not inactivate the enzyme, and also when it
        says nothing — which is what `unverified` then names.
    methylation
        The supplier's Dam, Dcm and CpG sensitivity, keyed ``"dam"``, ``"dcm"``, ``"cpg"``.
    unverified
        Fields no source stated. Never a guess.

    Raises
    ------
    ValueError
        If `site` is empty or holds a letter outside `IUPAC_DNA`.
    """

    name: str
    site: str
    _: KW_ONLY
    top_cut: int
    bottom_cut: int
    isoschizomers: tuple[str, ...] = ()
    commercial_name: str | None = None
    catalog_number: str | None = None
    supplier: str | None = None
    incubation_celsius: int | None = None
    heat_inactivation_celsius: int | None = None
    heat_inactivation_minutes: int | None = None
    methylation: Mapping[str, str] = field(default_factory=dict, hash=False)
    unverified: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        """Upper-case the site and refuse one that is not IUPAC DNA."""
        site = self.site.upper()
        if not site:
            raise ValueError(f"enzyme {self.name!r} has no recognition site")
        if bad := set(site) - IUPAC_DNA:
            raise ValueError(f"{self.name} site is not IUPAC DNA: {''.join(sorted(bad))}")
        object.__setattr__(self, "site", site)

    @property
    def type(self) -> EnzymeType:
        """``"IIS"`` when either cut falls outside the site, ``"II"`` otherwise."""
        return "IIS" if any(not 0 <= cut <= len(self.site) for cut in self.cut_offsets) else "II"

    @property
    def cut_offsets(self) -> tuple[int, int]:
        """The two cut offsets, top strand first."""
        return self.top_cut, self.bottom_cut

    @property
    def overhang_length(self) -> int:
        """How many bases the two cuts leave single-stranded."""
        return abs(self.bottom_cut - self.top_cut)

    @property
    def supplier_label(self) -> str:
        """The enzyme as its supplier sells it: the commercial name and the catalogue number.

        Examples
        --------
        >>> Enzyme("BsaI", "GGTCTC", top_cut=7, bottom_cut=11).supplier_label
        'BsaI'
        >>> sold = Enzyme("BsaI", "GGTCTC", top_cut=7, bottom_cut=11, commercial_name="BsaI-HFv2",
        ...               catalog_number="R3733")
        >>> sold.supplier_label
        'BsaI-HFv2 (R3733)'
        """
        name = self.commercial_name or self.name
        return f"{name} ({self.catalog_number})" if self.catalog_number else name

    @property
    def end(self) -> EndType:
        """The end left behind: ``"5'"``, ``"3'"`` or ``"blunt"``.

        Examples
        --------
        >>> Enzyme("KpnI", "GGTACC", top_cut=5, bottom_cut=1).end
        "3'"
        """
        if self.bottom_cut == self.top_cut:
            return "blunt"
        return "5'" if self.bottom_cut > self.top_cut else "3'"

    def cut_positions(self, start: int, strand: Strand = Strand.FORWARD) -> tuple[int, int]:
        """Where this enzyme cuts a record holding its site at `start`.

        Parameters
        ----------
        start
            The 0-based index where the site begins on the top strand. A reverse-strand site is
            the reverse complement of `site` there.
        strand
            Which strand carries the site.

        Returns
        -------
        tuple[int, int]
            The top-strand cut and the bottom-strand cut, in record coordinates and in that
            order whatever the strand. Either may fall outside the record, which is what a
            circular record's caller reduces modulo its length.

        Examples
        --------
        >>> Enzyme("BsaI", "GGTCTC", top_cut=7, bottom_cut=11).cut_positions(4)
        (11, 15)
        """
        if strand == Strand.REVERSE:
            end = start + len(self.site)
            return end - self.bottom_cut, end - self.top_cut
        return start + self.top_cut, start + self.bottom_cut

    def overhang(self, record: SequenceRecord, start: int, strand: Strand = Strand.FORWARD) -> str:
        """Return the top-strand bases this enzyme leaves single-stranded, 5' to 3'.

        Empty for a blunt cutter. On a circular record the overhang reads across the origin.

        Raises
        ------
        ValueError
            If either cut falls off the end of a linear record.
        """
        top, bottom = self.cut_positions(start, strand)
        low, high = min(top, bottom), max(top, bottom)
        if low == high:
            return ""
        length = len(record)
        if record.topology == "circular":
            low, high = low % length, low % length + (high - low)
        elif not (low >= 0 and high <= length):
            raise ValueError(f"{self.name} cut at {low}-{high} falls off a linear record")
        return record.extract(Segment(low, high))

cut_offsets property

cut_offsets: tuple[int, int]

The two cut offsets, top strand first.

end property

end: EndType

The end left behind: "5'", "3'" or "blunt".

Examples:

>>> Enzyme("KpnI", "GGTACC", top_cut=5, bottom_cut=1).end
"3'"

overhang_length property

overhang_length: int

How many bases the two cuts leave single-stranded.

supplier_label property

supplier_label: str

The enzyme as its supplier sells it: the commercial name and the catalogue number.

Examples:

>>> Enzyme("BsaI", "GGTCTC", top_cut=7, bottom_cut=11).supplier_label
'BsaI'
>>> sold = Enzyme("BsaI", "GGTCTC", top_cut=7, bottom_cut=11, commercial_name="BsaI-HFv2",
...               catalog_number="R3733")
>>> sold.supplier_label
'BsaI-HFv2 (R3733)'

type property

type: EnzymeType

"IIS" when either cut falls outside the site, "II" otherwise.

__post_init__

__post_init__() -> None

Upper-case the site and refuse one that is not IUPAC DNA.

Source code in src/liulab_mbio/enzymes.py
77
78
79
80
81
82
83
84
def __post_init__(self) -> None:
    """Upper-case the site and refuse one that is not IUPAC DNA."""
    site = self.site.upper()
    if not site:
        raise ValueError(f"enzyme {self.name!r} has no recognition site")
    if bad := set(site) - IUPAC_DNA:
        raise ValueError(f"{self.name} site is not IUPAC DNA: {''.join(sorted(bad))}")
    object.__setattr__(self, "site", site)

cut_positions

cut_positions(
    start: int, strand: Strand = FORWARD
) -> tuple[int, int]

Where this enzyme cuts a record holding its site at start.

Parameters:

Name Type Description Default
start int

The 0-based index where the site begins on the top strand. A reverse-strand site is the reverse complement of site there.

required
strand Strand

Which strand carries the site.

FORWARD

Returns:

Type Description
tuple[int, int]

The top-strand cut and the bottom-strand cut, in record coordinates and in that order whatever the strand. Either may fall outside the record, which is what a circular record's caller reduces modulo its length.

Examples:

>>> Enzyme("BsaI", "GGTCTC", top_cut=7, bottom_cut=11).cut_positions(4)
(11, 15)
Source code in src/liulab_mbio/enzymes.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def cut_positions(self, start: int, strand: Strand = Strand.FORWARD) -> tuple[int, int]:
    """Where this enzyme cuts a record holding its site at `start`.

    Parameters
    ----------
    start
        The 0-based index where the site begins on the top strand. A reverse-strand site is
        the reverse complement of `site` there.
    strand
        Which strand carries the site.

    Returns
    -------
    tuple[int, int]
        The top-strand cut and the bottom-strand cut, in record coordinates and in that
        order whatever the strand. Either may fall outside the record, which is what a
        circular record's caller reduces modulo its length.

    Examples
    --------
    >>> Enzyme("BsaI", "GGTCTC", top_cut=7, bottom_cut=11).cut_positions(4)
    (11, 15)
    """
    if strand == Strand.REVERSE:
        end = start + len(self.site)
        return end - self.bottom_cut, end - self.top_cut
    return start + self.top_cut, start + self.bottom_cut

overhang

overhang(
    record: SequenceRecord,
    start: int,
    strand: Strand = FORWARD,
) -> str

Return the top-strand bases this enzyme leaves single-stranded, 5' to 3'.

Empty for a blunt cutter. On a circular record the overhang reads across the origin.

Raises:

Type Description
ValueError

If either cut falls off the end of a linear record.

Source code in src/liulab_mbio/enzymes.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def overhang(self, record: SequenceRecord, start: int, strand: Strand = Strand.FORWARD) -> str:
    """Return the top-strand bases this enzyme leaves single-stranded, 5' to 3'.

    Empty for a blunt cutter. On a circular record the overhang reads across the origin.

    Raises
    ------
    ValueError
        If either cut falls off the end of a linear record.
    """
    top, bottom = self.cut_positions(start, strand)
    low, high = min(top, bottom), max(top, bottom)
    if low == high:
        return ""
    length = len(record)
    if record.topology == "circular":
        low, high = low % length, low % length + (high - low)
    elif not (low >= 0 and high <= length):
        raise ValueError(f"{self.name} cut at {low}-{high} falls off a linear record")
    return record.extract(Segment(low, high))

enzymes

enzymes() -> tuple[Enzyme, ...]

Return every shipped enzyme, ordered by name.

Source code in src/liulab_mbio/enzymes.py
214
215
216
def enzymes() -> tuple[Enzyme, ...]:
    """Return every shipped enzyme, ordered by name."""
    return _shipped()[0]

get_enzyme

get_enzyme(name: str) -> Enzyme

Return one enzyme by its name, its commercial name, or an isoschizomer's name.

Raises:

Type Description
KeyError

If no shipped enzyme answers to name, or if name is an isoschizomer of more than one of them, which would leave the supplier properties ambiguous.

Examples:

>>> get_enzyme("BsaI-HFv2").site
'GGTCTC'
Source code in src/liulab_mbio/enzymes.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def get_enzyme(name: str) -> Enzyme:
    """Return one enzyme by its name, its commercial name, or an isoschizomer's name.

    Raises
    ------
    KeyError
        If no shipped enzyme answers to `name`, or if `name` is an isoschizomer of more than
        one of them, which would leave the supplier properties ambiguous.

    Examples
    --------
    >>> get_enzyme("BsaI-HFv2").site
    'GGTCTC'
    """
    found = _shipped()[1].get(name.casefold())
    if not found:
        raise KeyError(f"no shipped enzyme is called {name!r}")
    enzyme, *shared = found
    if shared:
        names = ", ".join(sorted(record.name for record in found))
        raise KeyError(f"{name!r} is an isoschizomer of more than one shipped enzyme: {names}")
    return enzyme

liulab_mbio.sites

Find restriction sites in a record, put one there, and take one out of a coding sequence.

Two rules decide what counts as a site, and both matter to whoever asks whether an enzyme is free to use:

  • A site is reported where the enzyme may cut. Every position of the match needs a base the recognition site admits, so an IUPAC code in the template is read as the bases it stands for and a site it could spell is reported. CutSite.certain is False for such a hit. Erring towards reporting is the safe direction here: an enzyme called free of sites is one a design goes on to rely on.
  • A palindromic site is one site, not two. Its recognition site reads the same on either strand, so only the forward match is reported.

Coordinates are the model's, 0-based and half-open, and a site across the origin of a circular record ends past the record's length.

CutSite dataclass

One recognition site found in a record, and where its enzyme cuts there.

Parameters:

Name Type Description Default
enzyme Enzyme

The enzyme that reads this site.

required
start int

0-based index of the first base of the match on the TOP strand, whichever strand the site lies on.

required
strand Strand

Strand.FORWARD when the top strand spells the recognition site, Strand.REVERSE when the bottom strand does. A palindromic site is always forward.

required
top_cut int

Where the two strands are severed, in the record's own coordinates and in that order whatever the strand. Reduced modulo the length on a circular record; on a linear one either may fall outside the record, which is what overhang being None says.

required
bottom_cut int

Where the two strands are severed, in the record's own coordinates and in that order whatever the strand. Reduced modulo the length on a circular record; on a linear one either may fall outside the record, which is what overhang being None says.

required
overhang str | None

The top-strand bases the two cuts leave single-stranded, 5' to 3'; "" for a blunt cutter and None when a cut falls off the end of a linear record.

required
certain bool

False when an IUPAC code in the template, rather than a definite base, is what allowed the match.

True
Source code in src/liulab_mbio/sites.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
@dataclass(frozen=True, slots=True)
class CutSite:
    """One recognition site found in a record, and where its enzyme cuts there.

    Parameters
    ----------
    enzyme
        The enzyme that reads this site.
    start
        0-based index of the first base of the match on the TOP strand, whichever strand the
        site lies on.
    strand
        `Strand.FORWARD` when the top strand spells the recognition site, `Strand.REVERSE` when
        the bottom strand does. A palindromic site is always forward.
    top_cut, bottom_cut
        Where the two strands are severed, in the record's own coordinates and in that order
        whatever the strand. Reduced modulo the length on a circular record; on a linear one
        either may fall outside the record, which is what `overhang` being ``None`` says.
    overhang
        The top-strand bases the two cuts leave single-stranded, 5' to 3'; ``""`` for a blunt
        cutter and ``None`` when a cut falls off the end of a linear record.
    certain
        ``False`` when an IUPAC code in the template, rather than a definite base, is what
        allowed the match.
    """

    enzyme: Enzyme
    start: int
    strand: Strand
    _: KW_ONLY
    top_cut: int
    bottom_cut: int
    overhang: str | None
    certain: bool = True

    @property
    def end(self) -> int:
        """Where the match ends, passing the length when it runs across the origin."""
        return self.start + len(self.enzyme.site)

    @property
    def span(self) -> Segment:
        """The matched bases, as a segment of the top strand."""
        return Segment(self.start, self.end)

    @property
    def cuts(self) -> bool:
        """Whether both cuts land in the record, so the enzyme can actually cut here."""
        return self.overhang is not None

cuts property

cuts: bool

Whether both cuts land in the record, so the enzyme can actually cut here.

end property

end: int

Where the match ends, passing the length when it runs across the origin.

span property

span: Segment

The matched bases, as a segment of the top strand.

Domestication dataclass

One synonymous codon change, and the site it took away.

Parameters:

Name Type Description Default
site CutSite

The site that was there before the change.

required
feature Feature

The coding sequence the codon belongs to, as it read before the change.

required
position int

0-based index on the TOP strand where the codon's three bases begin. A codon of a reverse-strand coding sequence reads back from position + 2.

required
codon_index int

Which codon of the coding sequence this is, counting from zero.

required
old_codon str

The codon before and after, read 5' to 3' along the coding sequence.

required
new_codon str

The codon before and after, read 5' to 3' along the coding sequence.

required
amino_acid str

The one-letter amino acid both codons spell.

required
Source code in src/liulab_mbio/sites.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
@dataclass(frozen=True, slots=True)
class Domestication:
    """One synonymous codon change, and the site it took away.

    Parameters
    ----------
    site
        The site that was there before the change.
    feature
        The coding sequence the codon belongs to, as it read before the change.
    position
        0-based index on the TOP strand where the codon's three bases begin. A codon of a
        reverse-strand coding sequence reads back from ``position + 2``.
    codon_index
        Which codon of the coding sequence this is, counting from zero.
    old_codon, new_codon
        The codon before and after, read 5' to 3' along the coding sequence.
    amino_acid
        The one-letter amino acid both codons spell.
    """

    site: CutSite
    feature: Feature
    position: int
    codon_index: int
    old_codon: str
    new_codon: str
    amino_acid: str

DomesticationReport dataclass

What domestication changed, and what it left for someone to decide.

Attributes:

Name Type Description
changes tuple[Domestication, ...]

Each site taken away, with the codon change that did it.

outside_cds tuple[CutSite, ...]

Sites lying in no coding sequence. Removing one of these changes what the record spells, so it is reported and the choice is left to the caller.

unchanged tuple[CutSite, ...]

Sites in a coding sequence that no synonymous change could take away.

Source code in src/liulab_mbio/sites.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
@dataclass(frozen=True, slots=True)
class DomesticationReport:
    """What domestication changed, and what it left for someone to decide.

    Attributes
    ----------
    changes
        Each site taken away, with the codon change that did it.
    outside_cds
        Sites lying in no coding sequence. Removing one of these changes what the record
        spells, so it is reported and the choice is left to the caller.
    unchanged
        Sites in a coding sequence that no synonymous change could take away.
    """

    changes: tuple[Domestication, ...] = ()
    outside_cds: tuple[CutSite, ...] = ()
    unchanged: tuple[CutSite, ...] = ()

Fragment dataclass

One piece a digest leaves, bounded by the cuts in the top strand.

Parameters:

Name Type Description Default
start int

The two top-strand cuts that bound it, 0-based and half-open. A fragment across the origin of a circular record keeps start < end and ends past the record's length.

required
end int

The two top-strand cuts that bound it, 0-based and half-open. A fragment across the origin of a circular record keeps start < end and ends past the record's length.

required
left_overhang str

The single-stranded bases at each end, written as the TOP strand reads them 5' to 3', and "" for a blunt end. Written that way whether the overhang is 5' or 3', and whether it sits on the top strand of this fragment or the bottom, so two ends anneal exactly when the two strings are equal.

required
right_overhang str

The single-stranded bases at each end, written as the TOP strand reads them 5' to 3', and "" for a blunt end. Written that way whether the overhang is 5' or 3', and whether it sits on the top strand of this fragment or the bottom, so two ends anneal exactly when the two strings are equal.

required
Source code in src/liulab_mbio/sites.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@dataclass(frozen=True, slots=True)
class Fragment:
    """One piece a digest leaves, bounded by the cuts in the top strand.

    Parameters
    ----------
    start, end
        The two top-strand cuts that bound it, 0-based and half-open. A fragment across the
        origin of a circular record keeps ``start < end`` and ends past the record's length.
    left_overhang, right_overhang
        The single-stranded bases at each end, written as the TOP strand reads them 5' to 3',
        and ``""`` for a blunt end. Written that way whether the overhang is 5' or 3', and
        whether it sits on the top strand of this fragment or the bottom, so two ends anneal
        exactly when the two strings are equal.
    """

    start: int
    end: int
    left_overhang: str
    right_overhang: str

    @property
    def length(self) -> int:
        """How many bases of top strand it carries, which is what a gel measures."""
        return self.end - self.start

length property

length: int

How many bases of top strand it carries, which is what a gel measures.

digest

digest(
    record: SequenceRecord,
    enzymes: EnzymeLike | Iterable[EnzymeLike],
) -> tuple[Fragment, ...]

Cut record with one or more enzymes and return the fragments, in top-strand order.

A site whose cut falls off the end of a linear record is read but not cut, so it splits nothing. An uncut linear record is one fragment, being a molecule already; an uncut circular record is none, nothing having been cut.

Examples:

>>> [piece.length for piece in digest(SequenceRecord("AAAAGGTCTCGTTTTCCCC"), "BsaI")]
[11, 8]
Source code in src/liulab_mbio/sites.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def digest(
    record: SequenceRecord, enzymes: EnzymeLike | Iterable[EnzymeLike]
) -> tuple[Fragment, ...]:
    """Cut `record` with one or more enzymes and return the fragments, in top-strand order.

    A site whose cut falls off the end of a linear record is read but not cut, so it splits
    nothing. An uncut linear record is one fragment, being a molecule already; an uncut
    circular record is none, nothing having been cut.

    Examples
    --------
    >>> [piece.length for piece in digest(SequenceRecord("AAAAGGTCTCGTTTTCCCC"), "BsaI")]
    [11, 8]
    """
    length = len(record)
    #: Top-strand cut -> the overhang it leaves. Two enzymes cutting one position share it.
    boundaries: dict[int, str] = {}
    for site in find_sites(record, enzymes):
        if site.cuts and (record.topology == "circular" or 0 < site.top_cut < length):
            boundaries.setdefault(site.top_cut, site.overhang or "")
    cuts = sorted(boundaries)
    if record.topology == "circular":
        if not cuts:
            return ()
        ends = [*cuts[1:], cuts[0] + length]
    else:
        cuts, ends = [0, *cuts], [*cuts, length]
    return tuple(
        Fragment(start, end, boundaries.get(start, ""), boundaries.get(end % length, ""))
        for start, end in zip(cuts, ends, strict=True)
    )

domesticate

domesticate(
    record: SequenceRecord,
    enzymes: EnzymeLike | Iterable[EnzymeLike],
    *,
    usage: CodonUsage | None = None,
    avoid: Iterable[EnzymeLike] = (),
) -> tuple[SequenceRecord, DomesticationReport]

Take away every site of enzymes that a synonymous codon change can reach.

A site inside a coding sequence goes by changing one codon for another spelling the same amino acid, keeping the reading frame and the protein. The replacement is the one the host uses most often, among those that take the site away and spell no new site for enzymes or avoid. A site lying in no coding sequence is reported and not edited: removing it would change what the record spells, which is the caller's decision to make.

Parameters:

Name Type Description Default
record SequenceRecord

The record to domesticate.

required
enzymes EnzymeLike | Iterable[EnzymeLike]

The enzyme or enzymes whose sites should go.

required
usage CodonUsage | None

The host's codon usage. The shipped E. coli K-12 table by default.

None
avoid Iterable[EnzymeLike]

Further enzymes whose sites no change may create.

()

Returns:

Type Description
tuple[SequenceRecord, DomesticationReport]

The edited record, and what changed and what did not.

Source code in src/liulab_mbio/sites.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def domesticate(
    record: SequenceRecord,
    enzymes: EnzymeLike | Iterable[EnzymeLike],
    *,
    usage: CodonUsage | None = None,
    avoid: Iterable[EnzymeLike] = (),
) -> tuple[SequenceRecord, DomesticationReport]:
    """Take away every site of `enzymes` that a synonymous codon change can reach.

    A site inside a coding sequence goes by changing one codon for another spelling the same
    amino acid, keeping the reading frame and the protein. The replacement is the one the host
    uses most often, among those that take the site away and spell no new site for `enzymes` or
    `avoid`. A site lying in no coding sequence is reported and **not** edited: removing it
    would change what the record spells, which is the caller's decision to make.

    Parameters
    ----------
    record
        The record to domesticate.
    enzymes
        The enzyme or enzymes whose sites should go.
    usage
        The host's codon usage. The shipped *E. coli* K-12 table by default.
    avoid
        Further enzymes whose sites no change may create.

    Returns
    -------
    tuple[SequenceRecord, DomesticationReport]
        The edited record, and what changed and what did not.
    """
    table = usage if usage is not None else codon_usage()
    targets = _resolve(enzymes)
    active = targets + _resolve(avoid)
    changes: list[Domestication] = []
    outside: list[CutSite] = []
    unchanged: list[CutSite] = []
    handled: set[tuple[str, int, int]] = set()
    while True:
        pending = [
            site
            for site in find_sites(record, targets)
            if (site.enzyme.name, site.start, int(site.strand)) not in handled
        ]
        if not pending:
            return record, DomesticationReport(tuple(changes), tuple(outside), tuple(unchanged))
        site = pending[0]
        handled.add((site.enzyme.name, site.start, int(site.strand)))
        feature = _coding_feature(record, site)
        if feature is None:
            outside.append(site)
        elif (swap := _synonymous(record, site, feature, table, active)) is None:
            unchanged.append(site)
        else:
            record, change = swap
            changes.append(change)

find_sites

find_sites(
    record: SequenceRecord,
    enzymes: EnzymeLike | Iterable[EnzymeLike],
) -> tuple[CutSite, ...]

Return every site one or more enzymes read in record, in top-strand order.

Both strands are searched, and a circular record is searched across its origin.

Parameters:

Name Type Description Default
record SequenceRecord

The record to search.

required
enzymes EnzymeLike | Iterable[EnzymeLike]

One enzyme or several, each an Enzyme or a name get_enzyme answers to.

required

Raises:

Type Description
KeyError

If a name is not one the package ships.

Examples:

>>> record = SequenceRecord("AAAAGGTCTCGTTTTCCCC")
>>> [(site.start, site.overhang) for site in find_sites(record, "BsaI")]
[(4, 'TTTT')]
Source code in src/liulab_mbio/sites.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def find_sites(
    record: SequenceRecord, enzymes: EnzymeLike | Iterable[EnzymeLike]
) -> tuple[CutSite, ...]:
    """Return every site one or more enzymes read in `record`, in top-strand order.

    Both strands are searched, and a circular record is searched across its origin.

    Parameters
    ----------
    record
        The record to search.
    enzymes
        One enzyme or several, each an `Enzyme` or a name `get_enzyme` answers to.

    Raises
    ------
    KeyError
        If a name is not one the package ships.

    Examples
    --------
    >>> record = SequenceRecord("AAAAGGTCTCGTTTTCCCC")
    >>> [(site.start, site.overhang) for site in find_sites(record, "BsaI")]
    [(4, 'TTTT')]
    """
    found: list[CutSite] = []
    for enzyme in _resolve(enzymes):
        reverse = reverse_complement(enzyme.site)
        needles = [(Strand.FORWARD, enzyme.site)]
        if reverse != enzyme.site:
            needles.append((Strand.REVERSE, reverse))
        for strand, needle in needles:
            found.extend(
                _hit(record, enzyme, start, strand, needle) for start in _starts(record, needle)
            )
    return tuple(sorted(found, key=lambda site: (site.start, site.enzyme.name, -int(site.strand))))

free_enzymes

free_enzymes(
    records: Iterable[SequenceRecord],
    enzymes: Iterable[EnzymeLike] | None = None,
) -> tuple[Enzyme, ...]

Return the enzymes with no site in any of records, in the order they were given.

These are the enzymes a Golden Gate design can use without domesticating anything.

Source code in src/liulab_mbio/sites.py
243
244
245
246
247
248
249
250
251
252
def free_enzymes(
    records: Iterable[SequenceRecord], enzymes: Iterable[EnzymeLike] | None = None
) -> tuple[Enzyme, ...]:
    """Return the enzymes with no site in any of `records`, in the order they were given.

    These are the enzymes a Golden Gate design can use without domesticating anything.
    """
    chosen = shipped() if enzymes is None else _resolve(enzymes)
    counts = site_counts(records, chosen)
    return tuple(enzyme for enzyme in chosen if counts[enzyme.name] == 0)

has_site

has_site(
    record: SequenceRecord,
    enzymes: EnzymeLike | Iterable[EnzymeLike],
) -> bool

Whether record still holds a site any of these enzymes reads.

Source code in src/liulab_mbio/sites.py
222
223
224
def has_site(record: SequenceRecord, enzymes: EnzymeLike | Iterable[EnzymeLike]) -> bool:
    """Whether `record` still holds a site any of these enzymes reads."""
    return bool(find_sites(record, enzymes))

insert_site

insert_site(
    record: SequenceRecord,
    enzyme: EnzymeLike,
    position: int,
    *,
    strand: Strand = FORWARD,
) -> tuple[SequenceRecord, EditReport]

Put one enzyme's recognition site into record before position.

Everything after the site shifts, and the report says which features the insertion fell inside. A reverse-strand site is written as the reverse complement, so the enzyme reaches back towards lower coordinates to cut.

Raises:

Type Description
ValueError

If the recognition site holds an IUPAC code, there being no one sequence to write.

Examples:

>>> edited, _ = insert_site(SequenceRecord("AAAACCCC"), "BsaI", 4)
>>> edited.sequence
'AAAAGGTCTCCCCC'
Source code in src/liulab_mbio/sites.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def insert_site(
    record: SequenceRecord,
    enzyme: EnzymeLike,
    position: int,
    *,
    strand: Strand = Strand.FORWARD,
) -> tuple[SequenceRecord, EditReport]:
    """Put one enzyme's recognition site into `record` before `position`.

    Everything after the site shifts, and the report says which features the insertion fell
    inside. A reverse-strand site is written as the reverse complement, so the enzyme reaches
    back towards lower coordinates to cut.

    Raises
    ------
    ValueError
        If the recognition site holds an IUPAC code, there being no one sequence to write.

    Examples
    --------
    >>> edited, _ = insert_site(SequenceRecord("AAAACCCC"), "BsaI", 4)
    >>> edited.sequence
    'AAAAGGTCTCCCCC'
    """
    one = _resolve(enzyme)[0]
    bases = _definite(one)
    return insert(record, position, reverse_complement(bases) if strand < 0 else bases)

primer_tail

primer_tail(
    enzyme: EnzymeLike,
    overhang: str = "",
    *,
    spacer: str | None = None,
    spacer_length: int = SPACER_LENGTH,
    avoid: Iterable[EnzymeLike] = (),
) -> str

Build the 5' tail of a cloning primer, 5' to 3', for the caller to put its own 3' end on.

The tail is a spacer, the recognition site, the bases the enzyme reaches over, and then overhang — so that cutting the amplicon leaves exactly overhang single-stranded.

Parameters:

Name Type Description Default
enzyme EnzymeLike

The enzyme the tail is cut by.

required
overhang str

The overhang the cut should leave, as long as the enzyme leaves. Empty, and only empty, for an enzyme that cuts inside its own site.

''
spacer str | None

The bases 5' of the site. Chosen when not given, and checked when it is.

None
spacer_length int

How many bases to choose when spacer is not given. NEB recommends six.

SPACER_LENGTH
avoid Iterable[EnzymeLike]

Enzymes besides this one whose sites the tail must not spell.

()

Raises:

Type Description
ValueError

If overhang is not one this enzyme leaves, if spacer spells a further site or puts a Dcm site beside one this enzyme is impaired by, or if no bases could be found that avoid both.

Examples:

>>> primer_tail("BsaI", "AATG")
'AAACACGGTCTCAAATG'
Source code in src/liulab_mbio/sites.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
def primer_tail(
    enzyme: EnzymeLike,
    overhang: str = "",
    *,
    spacer: str | None = None,
    spacer_length: int = SPACER_LENGTH,
    avoid: Iterable[EnzymeLike] = (),
) -> str:
    """Build the 5' tail of a cloning primer, 5' to 3', for the caller to put its own 3' end on.

    The tail is a spacer, the recognition site, the bases the enzyme reaches over, and
    then `overhang` — so that cutting the amplicon leaves exactly `overhang` single-stranded.

    Parameters
    ----------
    enzyme
        The enzyme the tail is cut by.
    overhang
        The overhang the cut should leave, as long as the enzyme leaves. Empty, and only
        empty, for an enzyme that cuts inside its own site.
    spacer
        The bases 5' of the site. Chosen when not given, and checked when it is.
    spacer_length
        How many bases to choose when `spacer` is not given. NEB recommends six.
    avoid
        Enzymes besides this one whose sites the tail must not spell.

    Raises
    ------
    ValueError
        If `overhang` is not one this enzyme leaves, if `spacer` spells a further site or puts a
        Dcm site beside one this enzyme is impaired by, or if no bases could be found that
        avoid both.

    Examples
    --------
    >>> primer_tail("BsaI", "AATG")
    'AAACACGGTCTCAAATG'
    """
    one = _resolve(enzyme)[0]
    overhang = overhang.upper()
    _check_overhang(one, overhang)
    active = (one, *_resolve(avoid))
    site = _definite(one)
    filler = _search(
        max(0, one.top_cut - len(site)), lambda bases: site + bases + overhang, active, one
    )
    core = site + filler + overhang
    if spacer is None:
        return _search(spacer_length, lambda bases: bases + core, active, one) + core
    spacer = spacer.upper()
    if (reason := _problem(spacer + core, active, one)) is not None:
        raise ValueError(f"spacer {spacer!r} cannot be used: {reason}")
    return spacer + core

site_counts

site_counts(
    records: Iterable[SequenceRecord],
    enzymes: Iterable[EnzymeLike] | None = None,
) -> dict[str, int]

Count the sites each enzyme reads across several records, keyed by enzyme name.

Every enzyme asked about gets an entry, so a count of zero is stated rather than missing. enzymes defaults to every enzyme the package ships.

Source code in src/liulab_mbio/sites.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def site_counts(
    records: Iterable[SequenceRecord], enzymes: Iterable[EnzymeLike] | None = None
) -> dict[str, int]:
    """Count the sites each enzyme reads across several records, keyed by enzyme name.

    Every enzyme asked about gets an entry, so a count of zero is stated rather than missing.
    `enzymes` defaults to every enzyme the package ships.
    """
    chosen = shipped() if enzymes is None else _resolve(enzymes)
    counts = {enzyme.name: 0 for enzyme in chosen}
    for record in records:
        for site in find_sites(record, chosen):
            counts[site.enzyme.name] += 1
    return counts

liulab_mbio.codons

How often a host spells each codon, and the genetic code that groups them.

A table here counts every codon over every complete coding sequence of one genome, so it is a measurement of that genome rather than a copy of a published compilation. scripts/build_codon_usage.py rebuilds it and docs/research/codon-usage.md says where the sequences came from.

A whole-genome table is the background the genome itself uses. It is not a highly expressed reference set, which is what a codon adaptation index wants and is a different object.

CodonUsage dataclass

How often one genome spells each of the 64 codons.

Parameters:

Name Type Description Default
name str

The short name this table is asked for by, such as "e-coli-k12".

required
organism str

The organism as its genome record names it.

required
taxid int

The NCBI taxonomy identifier, and the sequence record counted.

required
accession int

The NCBI taxonomy identifier, and the sequence record counted.

required
counts Mapping[str, int]

Codon to the number of times the coding sequences spell it. All 64 are present.

required
cds_count int

How many coding sequences were counted, and how many codons they held.

required
codon_count int

How many coding sequences were counted, and how many codons they held.

required
note str

What the table is and is not, for a reader choosing between tables.

''
Source code in src/liulab_mbio/codons.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@dataclass(frozen=True, slots=True)
class CodonUsage:
    """How often one genome spells each of the 64 codons.

    Parameters
    ----------
    name
        The short name this table is asked for by, such as ``"e-coli-k12"``.
    organism
        The organism as its genome record names it.
    taxid, accession
        The NCBI taxonomy identifier, and the sequence record counted.
    counts
        Codon to the number of times the coding sequences spell it. All 64 are present.
    cds_count, codon_count
        How many coding sequences were counted, and how many codons they held.
    note
        What the table is and is not, for a reader choosing between tables.
    """

    name: str
    organism: str
    _: KW_ONLY
    taxid: int
    accession: str
    counts: Mapping[str, int] = field(hash=False)
    cds_count: int
    codon_count: int
    note: str = ""

    def amino_acid(self, codon: str) -> str:
        """Return the amino acid this codon spells, or ``"*"`` for a stop.

        Raises
        ------
        KeyError
            If `codon` is not one of the 64.
        """
        return _code()[_checked(codon)]

    def fraction(self, codon: str) -> float:
        """Return this codon's share of the codons spelling the same amino acid, 0 to 1."""
        family = _families()[self.amino_acid(codon)]
        return self.counts[_checked(codon)] / sum(self.counts[one] for one in family)

    def per_thousand(self, codon: str) -> float:
        """How often this codon occurs in a thousand codons of the genome."""
        return 1000 * self.counts[_checked(codon)] / self.codon_count

    def synonymous(self, codon: str) -> tuple[str, ...]:
        """Every codon for the same amino acid, the one this genome uses most often first.

        Examples
        --------
        >>> codon_usage().synonymous("GAC")
        ('GAT', 'GAC')
        """
        family = _families()[self.amino_acid(codon)]
        return tuple(sorted(family, key=lambda one: (-self.counts[one], one)))

amino_acid

amino_acid(codon: str) -> str

Return the amino acid this codon spells, or "*" for a stop.

Raises:

Type Description
KeyError

If codon is not one of the 64.

Source code in src/liulab_mbio/codons.py
52
53
54
55
56
57
58
59
60
def amino_acid(self, codon: str) -> str:
    """Return the amino acid this codon spells, or ``"*"`` for a stop.

    Raises
    ------
    KeyError
        If `codon` is not one of the 64.
    """
    return _code()[_checked(codon)]

fraction

fraction(codon: str) -> float

Return this codon's share of the codons spelling the same amino acid, 0 to 1.

Source code in src/liulab_mbio/codons.py
62
63
64
65
def fraction(self, codon: str) -> float:
    """Return this codon's share of the codons spelling the same amino acid, 0 to 1."""
    family = _families()[self.amino_acid(codon)]
    return self.counts[_checked(codon)] / sum(self.counts[one] for one in family)

per_thousand

per_thousand(codon: str) -> float

How often this codon occurs in a thousand codons of the genome.

Source code in src/liulab_mbio/codons.py
67
68
69
def per_thousand(self, codon: str) -> float:
    """How often this codon occurs in a thousand codons of the genome."""
    return 1000 * self.counts[_checked(codon)] / self.codon_count

synonymous

synonymous(codon: str) -> tuple[str, ...]

Every codon for the same amino acid, the one this genome uses most often first.

Examples:

>>> codon_usage().synonymous("GAC")
('GAT', 'GAC')
Source code in src/liulab_mbio/codons.py
71
72
73
74
75
76
77
78
79
80
def synonymous(self, codon: str) -> tuple[str, ...]:
    """Every codon for the same amino acid, the one this genome uses most often first.

    Examples
    --------
    >>> codon_usage().synonymous("GAC")
    ('GAT', 'GAC')
    """
    family = _families()[self.amino_acid(codon)]
    return tuple(sorted(family, key=lambda one: (-self.counts[one], one)))

codon_tables

codon_tables() -> tuple[str, ...]

Return the name of every codon usage table the package ships.

Source code in src/liulab_mbio/codons.py
83
84
85
def codon_tables() -> tuple[str, ...]:
    """Return the name of every codon usage table the package ships."""
    return tuple(_shipped())

codon_usage

codon_usage(name: str = DEFAULT_TABLE) -> CodonUsage

Return one host's codon usage.

Raises:

Type Description
KeyError

If no shipped table is called name.

Examples:

>>> codon_usage().organism
'Escherichia coli str. K-12 substr. MG1655'
Source code in src/liulab_mbio/codons.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def codon_usage(name: str = DEFAULT_TABLE) -> CodonUsage:
    """Return one host's codon usage.

    Raises
    ------
    KeyError
        If no shipped table is called `name`.

    Examples
    --------
    >>> codon_usage().organism
    'Escherichia coli str. K-12 substr. MG1655'
    """
    shipped = _shipped()
    if name not in shipped:
        raise KeyError(f"no shipped codon usage table is called {name!r}")
    return shipped[name]

Primers

Every public name in the modules below imports from liulab_mbio.primers too: from liulab_mbio.primers import design_pair works as well as the longer path.

liulab_mbio.primers

Design PCR primers and judge them.

Every public name in its modules imports from here as well:

  • polymerase: a polymerase, the Tm in its buffer, its annealing and extension rules, and NEB's PCR for it.
  • thresholds: the bands a check is judged by, and the words it is printed in.
  • placement: where a primer anneals on a template, where else it primes, and amplicons.
  • evaluation: judging a primer, or a pair on a template, and what a primer's length decides.
  • design: choosing the annealing region of a primer or a pair.

liulab_mbio.primers.polymerase

A DNA polymerase: the buffer its Tm is computed in, its cycling rules, and NEB's PCR for it.

Tm is the SantaLucia (1998) nearest-neighbour model computed by primer3-py, salt-corrected as NEB's Tm Calculator does it: Owczarzy (2004) at the monovalent equivalent NEB assigns the buffer, or Schildkraut (1965) for Phusion. Each Polymerase carries that buffer, the rule turning a pair's Tms into an annealing temperature (Ta), and its extension rate:

  • Q5 (the default), each primer 500 nM: Ta is the lower Tm + 1 °C, at most 72 °C, and NEB asks for at least 55 °C. Extension 72 °C, 20 s/kb.
  • PHUSION, 500 nM: Ta is 0.93 * the lower Tm + 7.5 °C, at most 72 °C. Extension 72 °C, 15 s/kb.
  • TAQ and ONETAQ, 200 nM: Ta is the lower Tm - 5 °C, at most 68 °C, and NEB asks for at least 45 °C. Extension 68 °C, 60 s/kb.

Each also carries its PcrProfile: the buffer, the enzyme and the cycling NEB's protocol sets. Sources, and the values these reproduce: docs/research/primer-design-and-pcr.md.

PcrProfile dataclass

What NEB's protocol puts in one polymerase's PCR, and the program around it.

Parameters:

Name Type Description Default
buffer_name str

The reaction buffer as supplied, so its volume is the reaction over buffer_fold.

required
buffer_fold str

The reaction buffer as supplied, so its volume is the reaction over buffer_fold.

required
units_per_ul float

Polymerase in the reaction.

required
stock_units_ul float

Polymerase in the tube it is pipetted from.

required
initial_denaturation_c float

The step before the cycles.

required
initial_denaturation_seconds float

The step before the cycles.

required
denaturation_c float

Inside each cycle; the annealing temperature is the primer pair's.

required
denaturation_seconds float

Inside each cycle; the annealing temperature is the primer pair's.

required
annealing_seconds float

Inside each cycle; the annealing temperature is the primer pair's.

required
final_extension_seconds int

At the polymerase's own extension temperature.

required
two_step_celsius float

The lowest annealing temperature that gets a two-step program, annealing and extension combined. Taq's rule is "above 65 °C", which at a tenth of a degree is 65.1.

required
cycles int

The rest of NEB's table.

30
dntp_um_each int

The rest of NEB's table.

30
hold_c int

The rest of NEB's table.

30
Source code in src/liulab_mbio/primers/polymerase.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@dataclass(frozen=True, slots=True)
class PcrProfile:
    """What NEB's protocol puts in one polymerase's PCR, and the program around it.

    Parameters
    ----------
    buffer_name, buffer_fold
        The reaction buffer as supplied, so its volume is the reaction over `buffer_fold`.
    units_per_ul
        Polymerase in the reaction.
    stock_units_ul
        Polymerase in the tube it is pipetted from.
    initial_denaturation_c, initial_denaturation_seconds
        The step before the cycles.
    denaturation_c, denaturation_seconds, annealing_seconds
        Inside each cycle; the annealing temperature is the primer pair's.
    final_extension_seconds
        At the polymerase's own extension temperature.
    two_step_celsius
        The lowest annealing temperature that gets a two-step program, annealing and extension
        combined. Taq's rule is "above 65 °C", which at a tenth of a degree is 65.1.
    cycles, dntp_um_each, hold_c
        The rest of NEB's table.
    """

    buffer_name: str
    _: KW_ONLY
    buffer_fold: float
    units_per_ul: float
    stock_units_ul: float
    initial_denaturation_c: float
    denaturation_c: float
    denaturation_seconds: int
    annealing_seconds: int
    final_extension_seconds: int
    two_step_celsius: float
    cycles: int = 30
    initial_denaturation_seconds: int = 30
    dntp_um_each: float = 200.0
    hold_c: float = 4.0

Polymerase dataclass

A DNA polymerase, the buffer its Tm is computed in, and its cycling rules.

Parameters:

Name Type Description Default
name str

As sold, such as "Q5".

required
monovalent_mm float

The monovalent cation concentration NEB's calculator assigns this buffer, mM. It stands for the whole buffer, so Mg²⁺ and dNTPs are left out of the Tm: primer3's Owczarzy (2008) magnesium term divides by an integer and vanishes.

required
salt_correction str

primer3's salt_corrections_method.

required
primer_nm float

Each primer in the reaction, nanomolar.

required
tm_dna_conc_nm float

What primer3 is given, which is four times primer_nm under the Owczarzy correction: primer3 always divides by four, where NEB divides only for Phusion.

required
annealing_slope float

Ta is annealing_slope times the lower Tm of the pair, plus annealing_offset, °C.

required
annealing_offset float

Ta is annealing_slope times the lower Tm of the pair, plus annealing_offset, °C.

required
annealing_max float

Ta is capped at annealing_max; NEB warns below annealing_min.

required
annealing_min float

Ta is capped at annealing_max; NEB warns below annealing_min.

required
extension_temperature float

°C.

required
extension_seconds_per_kb int

Extension time per kilobase of amplicon.

required
pcr PcrProfile

Its buffer, enzyme dose and cycling, as NEB's protocol for it sets them.

required
Source code in src/liulab_mbio/primers/polymerase.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@dataclass(frozen=True, slots=True)
class Polymerase:
    """A DNA polymerase, the buffer its Tm is computed in, and its cycling rules.

    Parameters
    ----------
    name
        As sold, such as ``"Q5"``.
    monovalent_mm
        The monovalent cation concentration NEB's calculator assigns this buffer, mM. It
        stands for the whole buffer, so Mg²⁺ and dNTPs are left out of the Tm: primer3's
        Owczarzy (2008) magnesium term divides by an integer and vanishes.
    salt_correction
        primer3's `salt_corrections_method`.
    primer_nm
        Each primer in the reaction, nanomolar.
    tm_dna_conc_nm
        What primer3 is given, which is four times `primer_nm` under the Owczarzy correction:
        primer3 always divides by four, where NEB divides only for Phusion.
    annealing_slope, annealing_offset
        Ta is `annealing_slope` times the lower Tm of the pair, plus `annealing_offset`, °C.
    annealing_max, annealing_min
        Ta is capped at `annealing_max`; NEB warns below `annealing_min`.
    extension_temperature
        °C.
    extension_seconds_per_kb
        Extension time per kilobase of amplicon.
    pcr
        Its buffer, enzyme dose and cycling, as NEB's protocol for it sets them.
    """

    name: str
    _: KW_ONLY
    monovalent_mm: float
    salt_correction: str
    primer_nm: float
    tm_dna_conc_nm: float
    annealing_slope: float
    annealing_offset: float
    annealing_max: float
    annealing_min: float
    extension_temperature: float
    extension_seconds_per_kb: int
    pcr: PcrProfile

    def annealing_temperature(self, tm: float, other_tm: float) -> float:
        """Return the annealing temperature for a pair with these Tms, °C to a tenth."""
        lower = min(tm, other_tm)
        return round(
            min(self.annealing_slope * lower + self.annealing_offset, self.annealing_max), 1
        )

    def extension_seconds(self, amplicon_length: int) -> int:
        """Return the extension time for an amplicon, rounded up to whole kilobases."""
        return max(1, math.ceil(amplicon_length / 1000)) * self.extension_seconds_per_kb

annealing_temperature

annealing_temperature(tm: float, other_tm: float) -> float

Return the annealing temperature for a pair with these Tms, °C to a tenth.

Source code in src/liulab_mbio/primers/polymerase.py
111
112
113
114
115
116
def annealing_temperature(self, tm: float, other_tm: float) -> float:
    """Return the annealing temperature for a pair with these Tms, °C to a tenth."""
    lower = min(tm, other_tm)
    return round(
        min(self.annealing_slope * lower + self.annealing_offset, self.annealing_max), 1
    )

extension_seconds

extension_seconds(amplicon_length: int) -> int

Return the extension time for an amplicon, rounded up to whole kilobases.

Source code in src/liulab_mbio/primers/polymerase.py
118
119
120
def extension_seconds(self, amplicon_length: int) -> int:
    """Return the extension time for an amplicon, rounded up to whole kilobases."""
    return max(1, math.ceil(amplicon_length / 1000)) * self.extension_seconds_per_kb

melting_temperature

melting_temperature(
    sequence: str, polymerase: Polymerase = Q5
) -> float

Return the Tm of a sequence in a polymerase's buffer, °C.

The value NEB's Tm Calculator gives for that polymerase.

Examples:

>>> round(melting_temperature("GTAAAACGACGGCCAGT"))
62
Source code in src/liulab_mbio/primers/polymerase.py
254
255
256
257
258
259
260
261
262
263
264
265
266
def melting_temperature(sequence: str, polymerase: Polymerase = Q5) -> float:
    """Return the Tm of a sequence in a polymerase's buffer, °C.

    The value NEB's Tm Calculator gives for that polymerase.

    Examples
    --------
    >>> round(melting_temperature("GTAAAACGACGGCCAGT"))
    62
    """
    import primer3

    return primer3.calc_tm(sequence.upper(), tm_method="santalucia", **_conditions(polymerase))

liulab_mbio.primers.thresholds

The bands every primer check is judged by, and the words a check is printed in.

Band dataclass

The values a check passes on, and the wider band it only warns on.

Parameters:

Name Type Description Default
low float

A value between them, inclusive, passes.

required
high float

A value between them, inclusive, passes.

required
warn_low float

A value between them warns; outside them the check fails. Infinite by default, so a value outside the passing band only warns.

-inf
warn_high float

A value between them warns; outside them the check fails. Infinite by default, so a value outside the passing band only warns.

-inf
proposed bool

Whether the passing band is the research note's own proposal rather than a published rule. A page prints it as proposed.

False
Source code in src/liulab_mbio/primers/thresholds.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@dataclass(frozen=True, slots=True)
class Band:
    """The values a check passes on, and the wider band it only warns on.

    Parameters
    ----------
    low, high
        A value between them, inclusive, passes.
    warn_low, warn_high
        A value between them warns; outside them the check fails. Infinite by default, so a
        value outside the passing band only warns.
    proposed
        Whether the passing band is the research note's own proposal rather than a published
        rule. A page prints it as proposed.
    """

    low: float
    high: float
    warn_low: float = -math.inf
    warn_high: float = math.inf
    proposed: bool = False

    def grade(self, value: float) -> Status:
        """Return the status of a value."""
        if self.low <= value <= self.high:
            return "pass"
        if self.warn_low <= value <= self.warn_high:
            return "warn"
        return "fail"

grade

grade(value: float) -> Status

Return the status of a value.

Source code in src/liulab_mbio/primers/thresholds.py
34
35
36
37
38
39
40
def grade(self, value: float) -> Status:
    """Return the status of a value."""
    if self.low <= value <= self.high:
        return "pass"
    if self.warn_low <= value <= self.warn_high:
        return "warn"
    return "fail"

Reading dataclass

One check in the few words a page prints.

Parameters:

Name Type Description Default
label str

What a reader calls the check, such as "GC".

required
value str

What it measured, with its unit, such as "39%".

required
limit str

The band it was held to, such as "band 40-60", empty where nothing judged it.

''
Source code in src/liulab_mbio/primers/thresholds.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
@dataclass(frozen=True, slots=True)
class Reading:
    """One check in the few words a page prints.

    Parameters
    ----------
    label
        What a reader calls the check, such as ``"GC"``.
    value
        What it measured, with its unit, such as ``"39%"``.
    limit
        The band it was held to, such as ``"band 40-60"``, empty where nothing judged it.
    """

    label: str
    value: str
    limit: str = ""

    @property
    def detail(self) -> str:
        """The value and the band it was held to."""
        return f"{self.value} ({self.limit})" if self.limit else self.value

detail property

detail: str

The value and the band it was held to.

Thresholds dataclass

Every threshold the checks are judged by, with its source.

Sources are in docs/research/primer-design-and-pcr.md; where it found no published rule, the note's own proposal is marked below.

Parameters:

Name Type Description Default
length Band

Annealing region, nt. primer3's minimum of 18 and IDT's 18-30, warning out to the longest primer primer3's Tm formula takes. The warning band is the note's proposal.

Band(18, 30, 15, 35)
gc_percent Band

Of the annealing region. NEB asks for 40-60%; primer3 allows 20-80.

Band(40.0, 60.0, 20.0, 80.0)
gc_clamp Band

Gs and Cs in the last five 3' bases. primer3 allows five; one to three is the note's proposal, from NEB's "avoid GC-rich 3' ends".

Band(1, 3, 0, 5, proposed=True)
tm Band

Of the annealing region, °C, on this polymerase's scale. IDT asks for 60-64 °C; the warning band is the note's proposal.

Band(60.0, 64.0, 55.0, 70.0)
tm_difference Band

Between a pair's annealing regions, °C. NEB's calculator warns above five.

Band(0.0, 5.0)
mononucleotide_run Band

Longest run of one base, and of G alone, anywhere in the primer. primer3 allows a run of five; IDT warns at four Gs.

Band(0, 4, 0, 5)
guanine_run Band

Longest run of one base, and of G alone, anywhere in the primer. primer3 allows a run of five; IDT warns at four Gs.

Band(0, 4, 0, 5)
dinucleotide_repeat Band

Most repeats of one dinucleotide anywhere in the primer. Four is the note's proposal; no source gives a limit.

Band(0, 3, proposed=True)
hairpin Band

Melting temperature of the structure at primer3's default conditions, °C: primer3's PRIMER_MAX_HAIRPIN_TH and PRIMER_MAX_SELF_ANY_TH, both 47 °C.

Band(-inf, 47.0)
dimer Band

Melting temperature of the structure at primer3's default conditions, °C: primer3's PRIMER_MAX_HAIRPIN_TH and PRIMER_MAX_SELF_ANY_TH, both 47 °C.

Band(-inf, 47.0)
dimer_3prime Band

As dimer, for a structure holding the 3' end, which the polymerase can extend: primer3's PRIMER_MAX_SELF_END_TH, failing rather than warning.

Band(-inf, 47.0, -inf, 47.0)
binding_sites Band

Places on a template where the annealing region matches, and amplicons the pair can make. A primer wants exactly one of each.

Band(1, 1, 1, 1)
products Band

Places on a template where the annealing region matches, and amplicons the pair can make. A primer wants exactly one of each.

Band(1, 1, 1, 1)
off_target Band

Other places where it can prime, which warn.

Band(0, 0)
binding_min_length int

Bases at the 3' end that must match for a primer carrying no binding site to be placed on a template. The note's proposal.

15
off_target_mismatches int

What is worth scoring at all: at most five mismatches, and at most one in the last five 3' bases. Primer-BLAST's defaults (Ye et al. 2012, BMC Bioinformatics 13:134).

5
off_target_3prime_window int

What is worth scoring at all: at most five mismatches, and at most one in the last five 3' bases. Primer-BLAST's defaults (Ye et al. 2012, BMC Bioinformatics 13:134).

5
off_target_3prime_mismatches int

What is worth scoring at all: at most five mismatches, and at most one in the last five 3' bases. Primer-BLAST's defaults (Ye et al. 2012, BMC Bioinformatics 13:134).

5
off_target_margin float

How far under a perfect match's Tm a place can still prime, °C. primer3 sets its mispriming threshold 10 °C below its own minimum Tm.

10.0
Source code in src/liulab_mbio/primers/thresholds.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@dataclass(frozen=True, slots=True)
class Thresholds:
    """Every threshold the checks are judged by, with its source.

    Sources are in ``docs/research/primer-design-and-pcr.md``; where it found no published
    rule, the note's own proposal is marked below.

    Parameters
    ----------
    length
        Annealing region, nt. primer3's minimum of 18 and IDT's 18-30, warning out to the
        longest primer primer3's Tm formula takes. The warning band is the note's proposal.
    gc_percent
        Of the annealing region. NEB asks for 40-60%; primer3 allows 20-80.
    gc_clamp
        Gs and Cs in the last five 3' bases. primer3 allows five; one to three is the note's
        proposal, from NEB's "avoid GC-rich 3' ends".
    tm
        Of the annealing region, °C, on this polymerase's scale. IDT asks for 60-64 °C; the
        warning band is the note's proposal.
    tm_difference
        Between a pair's annealing regions, °C. NEB's calculator warns above five.
    mononucleotide_run, guanine_run
        Longest run of one base, and of G alone, anywhere in the primer. primer3 allows a run
        of five; IDT warns at four Gs.
    dinucleotide_repeat
        Most repeats of one dinucleotide anywhere in the primer. Four is the note's proposal;
        no source gives a limit.
    hairpin, dimer
        Melting temperature of the structure at primer3's default conditions, °C: primer3's
        `PRIMER_MAX_HAIRPIN_TH` and `PRIMER_MAX_SELF_ANY_TH`, both 47 °C.
    dimer_3prime
        As `dimer`, for a structure holding the 3' end, which the polymerase can extend:
        primer3's `PRIMER_MAX_SELF_END_TH`, failing rather than warning.
    binding_sites, products
        Places on a template where the annealing region matches, and amplicons the pair can
        make. A primer wants exactly one of each.
    off_target
        Other places where it can prime, which warn.
    binding_min_length
        Bases at the 3' end that must match for a primer carrying no binding site to be placed
        on a template. The note's proposal.
    off_target_mismatches, off_target_3prime_window, off_target_3prime_mismatches
        What is worth scoring at all: at most five mismatches, and at most one in the last five
        3' bases. Primer-BLAST's defaults (Ye et al. 2012, *BMC Bioinformatics* 13:134).
    off_target_margin
        How far under a perfect match's Tm a place can still prime, °C. primer3 sets its
        mispriming threshold 10 °C below its own minimum Tm.
    """

    length: Band = Band(18, 30, 15, 35)
    gc_percent: Band = Band(40.0, 60.0, 20.0, 80.0)
    gc_clamp: Band = Band(1, 3, 0, 5, proposed=True)
    tm: Band = Band(60.0, 64.0, 55.0, 70.0)
    tm_difference: Band = Band(0.0, 5.0)
    mononucleotide_run: Band = Band(0, 4, 0, 5)
    guanine_run: Band = Band(0, 3)
    dinucleotide_repeat: Band = Band(0, 3, proposed=True)
    hairpin: Band = Band(-math.inf, 47.0)
    dimer: Band = Band(-math.inf, 47.0)
    dimer_3prime: Band = Band(-math.inf, 47.0, -math.inf, 47.0)
    binding_sites: Band = Band(1, 1, 1, 1)
    products: Band = Band(1, 1, 1, 1)
    off_target: Band = Band(0, 0)
    binding_min_length: int = 15
    off_target_mismatches: int = 5
    off_target_3prime_window: int = 5
    off_target_3prime_mismatches: int = 1
    off_target_margin: float = 10.0

reading

reading(
    check: Check, thresholds: Thresholds = THRESHOLDS
) -> Reading

Return check in the words a page prints: its label, its value and its band.

A check no sourced threshold judges carries no band, so it says only what it measured.

Examples:

>>> reading(Check("gc_percent", "warn", 39.1)).detail
'39% (band 40-60)'
Source code in src/liulab_mbio/primers/thresholds.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def reading(check: Check, thresholds: Thresholds = THRESHOLDS) -> Reading:
    """Return `check` in the words a page prints: its label, its value and its band.

    A check no sourced threshold judges carries no band, so it says only what it measured.

    Examples
    --------
    >>> reading(Check("gc_percent", "warn", 39.1)).detail
    '39% (band 40-60)'
    """
    wording = _WORDING.get(check.name, _Wording(check.name.replace("_", " ")))
    band = getattr(thresholds, wording.band or check.name, None)
    return Reading(
        wording.label,
        f"{check.value:.{wording.decimals}f}{wording.unit}",
        _band_text(band) if isinstance(band, Band) else "",
    )

liulab_mbio.primers.placement

Where a primer anneals on a template, where else it can prime, and what a pair amplifies.

A site or an amplicon crosses the origin of a circular template, ending past its length.

PrimingSite dataclass

Where a primer's 3' end can anneal on a template, and how strongly.

Parameters:

Name Type Description Default
site BindingSite

What the annealing region covers, running past the end of a circular template when it crosses the origin.

required
tm float

Of the primer annealed there, °C at primer3's default conditions.

required
mismatches int

Bases of the annealing region that do not pair.

required
Source code in src/liulab_mbio/primers/placement.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@dataclass(frozen=True, slots=True)
class PrimingSite:
    """Where a primer's 3' end can anneal on a template, and how strongly.

    Parameters
    ----------
    site
        What the annealing region covers, running past the end of a circular template when it
        crosses the origin.
    tm
        Of the primer annealed there, °C at primer3's default conditions.
    mismatches
        Bases of the annealing region that do not pair.
    """

    site: BindingSite
    tm: float
    mismatches: int

amplicon_sizes

amplicon_sizes(
    forward: Primer,
    reverse: Primer,
    template: SequenceRecord,
    *,
    thresholds: Thresholds = THRESHOLDS,
) -> tuple[int, ...]

Return the size of every amplicon a pair can make on a template, smallest first.

Tails count, as they do in PairReport.amplicon_length. A primer carrying binding sites is taken to bind where they say, so asking about a template other than the one it was placed on means clearing them first.

Source code in src/liulab_mbio/primers/placement.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def amplicon_sizes(
    forward: Primer,
    reverse: Primer,
    template: SequenceRecord,
    *,
    thresholds: Thresholds = THRESHOLDS,
) -> tuple[int, ...]:
    """Return the size of every amplicon a pair can make on a template, smallest first.

    Tails count, as they do in `PairReport.amplicon_length`. A primer carrying binding sites is
    taken to bind where they say, so asking about a template other than the one it was placed on
    means clearing them first.
    """
    placed: list[tuple[BindingSite, int]] = []
    for primer in (forward, reverse):
        sites = primer.binding_sites or find_binding_sites(
            primer.sequence, template, thresholds=thresholds
        )
        annealing = max((site.end - site.start for site in sites), default=len(primer.sequence))
        placed.extend((site, len(primer.sequence) - annealing) for site in sites)
    products = []
    for site, tail in placed:
        if site.strand is not Strand.FORWARD:
            continue
        for other, other_tail in placed:
            if other.strand is not Strand.REVERSE:
                continue
            span = _span(site, other, template)
            if span is not None:
                products.append(span + tail + other_tail)
    return tuple(sorted(products))

find_binding_sites

find_binding_sites(
    sequence: str,
    template: SequenceRecord,
    *,
    thresholds: Thresholds = THRESHOLDS,
) -> tuple[BindingSite, ...]

Return every place a primer's 3' end matches a template exactly, on either strand.

The match runs from the 3' end back towards the 5' end, so a tail hangs off it; it must reach Thresholds.binding_min_length. A site crosses the origin of a circular template.

Examples:

>>> template = SequenceRecord("CGGCGTAATCATGGTCATAGCTGTTTCC")
>>> sites = find_binding_sites("TTGGTCTCAGGCGTAATCATGGTCATAGC", template)
>>> [(site.start, site.end, site.strand.name) for site in sites]
[(1, 21, 'FORWARD')]
Source code in src/liulab_mbio/primers/placement.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def find_binding_sites(
    sequence: str, template: SequenceRecord, *, thresholds: Thresholds = THRESHOLDS
) -> tuple[BindingSite, ...]:
    """Return every place a primer's 3' end matches a template exactly, on either strand.

    The match runs from the 3' end back towards the 5' end, so a tail hangs off it; it must
    reach `Thresholds.binding_min_length`. A site crosses the origin of a circular template.

    Examples
    --------
    >>> template = SequenceRecord("CGGCGTAATCATGGTCATAGCTGTTTCC")
    >>> sites = find_binding_sites("TTGGTCTCAGGCGTAATCATGGTCATAGC", template)
    >>> [(site.start, site.end, site.strand.name) for site in sites]
    [(1, 21, 'FORWARD')]
    """
    dna = sequence.upper()
    length = len(template)
    reverse = reverse_complement(dna)
    sites = []
    for index in range(length):
        matched = _matched(template, index, dna[::-1], -1)
        if matched >= thresholds.binding_min_length:
            start = (index - matched + 1) % length
            sites.append(BindingSite(start, start + matched, Strand.FORWARD))
        matched = _matched(template, index, reverse, 1)
        if matched >= thresholds.binding_min_length:
            sites.append(BindingSite(index, index + matched, Strand.REVERSE))
    return tuple(sorted(sites, key=lambda site: (site.start, site.strand)))

find_priming_sites

find_priming_sites(
    sequence: str,
    template: SequenceRecord,
    *,
    thresholds: Thresholds = THRESHOLDS,
) -> tuple[PrimingSite, ...]

Return every place an annealing region can prime a template, on either strand.

A place counts when few enough of its bases mismatch, fewest of all at the 3' end, and when the primer annealed there melts within Thresholds.off_target_margin of a perfect match. Sites cross the origin of a circular template.

Source code in src/liulab_mbio/primers/placement.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def find_priming_sites(
    sequence: str, template: SequenceRecord, *, thresholds: Thresholds = THRESHOLDS
) -> tuple[PrimingSite, ...]:
    """Return every place an annealing region can prime a template, on either strand.

    A place counts when few enough of its bases mismatch, fewest of all at the 3' end, and
    when the primer annealed there melts within `Thresholds.off_target_margin` of a perfect
    match. Sites cross the origin of a circular template.
    """
    import primer3

    dna = sequence.upper()
    size = len(dna)
    length = len(template)
    if size > length:
        return ()
    circular = template.topology == "circular"
    top = template.sequence + (template.sequence[: size - 1] if circular else "")
    reverse = reverse_complement(dna)
    window = thresholds.off_target_3prime_window
    floor = primer3.calc_end_stability(dna, reverse).tm - thresholds.off_target_margin
    found = []
    for start in range(length if circular else length - size + 1):
        here = top[start : start + size]
        for strand, probe, anchor, annealed in (
            (Strand.FORWARD, dna, _mismatches(dna[-window:], here[-window:]), None),
            (Strand.REVERSE, reverse, _mismatches(reverse[:window], here[:window]), here),
        ):
            if anchor > thresholds.off_target_3prime_mismatches:
                continue
            mismatches = _mismatches(probe, here)
            if mismatches > thresholds.off_target_mismatches:
                continue
            tm = primer3.calc_end_stability(dna, annealed or reverse_complement(here)).tm
            if tm >= floor:
                found.append(PrimingSite(BindingSite(start, start + size, strand), tm, mismatches))
    return tuple(found)

liulab_mbio.primers.evaluation

Judge one primer, or a pair on a template, against the thresholds.

Hairpins and dimers are structure Tms at primer3's own default conditions, which is where its 47 °C threshold comes from. Sources, and the values these reproduce: docs/research/primer-design-and-pcr.md.

PairReport dataclass

Every check on a primer pair, and the numbers its PCR needs.

Parameters:

Name Type Description Default
forward PrimerReport

What each primer scored on its own.

required
reverse PrimerReport

What each primer scored on its own.

required
checks tuple[Check, ...]

What only a pair can be judged on.

required
annealing_temperature float

°C, by the polymerase's rule over the two annealing regions.

required
amplicon_length int | None

Bases between the primers' 5' ends, tails included, or None unless they make exactly one product.

required
extension_seconds int | None

For that amplicon, or None.

required
Source code in src/liulab_mbio/primers/evaluation.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
@dataclass(frozen=True, slots=True)
class PairReport:
    """Every check on a primer pair, and the numbers its PCR needs.

    Parameters
    ----------
    forward, reverse
        What each primer scored on its own.
    checks
        What only a pair can be judged on.
    annealing_temperature
        °C, by the polymerase's rule over the two annealing regions.
    amplicon_length
        Bases between the primers' 5' ends, tails included, or ``None`` unless they make
        exactly one product.
    extension_seconds
        For that amplicon, or ``None``.
    """

    forward: PrimerReport
    reverse: PrimerReport
    checks: tuple[Check, ...]
    annealing_temperature: float
    amplicon_length: int | None
    extension_seconds: int | None

    @property
    def status(self) -> Status:
        """Return the worst status of either primer or of any pair check that was judged."""
        return worst(
            (self.forward.status, self.reverse.status, *(one.status for one in self.checks))
        )

    def __getitem__(self, name: str) -> Check:
        """Return the pair check of that name.

        Raises
        ------
        KeyError
            If no check has it.
        """
        return _named(self.checks, name)

status property

status: Status

Return the worst status of either primer or of any pair check that was judged.

__getitem__

__getitem__(name: str) -> Check

Return the pair check of that name.

Raises:

Type Description
KeyError

If no check has it.

Source code in src/liulab_mbio/primers/evaluation.py
171
172
173
174
175
176
177
178
179
def __getitem__(self, name: str) -> Check:
    """Return the pair check of that name.

    Raises
    ------
    KeyError
        If no check has it.
    """
    return _named(self.checks, name)

PrimerReport dataclass

Every check on one primer.

Parameters:

Name Type Description Default
primer Primer

The primer judged.

required
checks tuple[Check, ...]

In the order they were run.

required
Source code in src/liulab_mbio/primers/evaluation.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@dataclass(frozen=True, slots=True)
class PrimerReport:
    """Every check on one primer.

    Parameters
    ----------
    primer
        The primer judged.
    checks
        In the order they were run.
    """

    primer: Primer
    checks: tuple[Check, ...]

    @property
    def status(self) -> Status:
        """Return the worst status of any check that was judged."""
        return worst(check.status for check in self.checks)

    def __getitem__(self, name: str) -> Check:
        """Return the check of that name.

        Raises
        ------
        KeyError
            If no check has it.
        """
        return _named(self.checks, name)

status property

status: Status

Return the worst status of any check that was judged.

__getitem__

__getitem__(name: str) -> Check

Return the check of that name.

Raises:

Type Description
KeyError

If no check has it.

Source code in src/liulab_mbio/primers/evaluation.py
53
54
55
56
57
58
59
60
61
def __getitem__(self, name: str) -> Check:
    """Return the check of that name.

    Raises
    ------
    KeyError
        If no check has it.
    """
    return _named(self.checks, name)

annealing_checks

annealing_checks(
    annealing: str,
    *,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> tuple[Check, ...]

Judge what an annealing region's length decides: its length, GC, GC clamp and Tm.

These are the checks a design chooses a length by, so it keeps inside the bands it is judged by wherever some length can.

Examples:

>>> [check.status for check in annealing_checks("GTAAAACGACGGCCAGT")]
['warn', 'pass', 'pass', 'pass']
Source code in src/liulab_mbio/primers/evaluation.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def annealing_checks(
    annealing: str, *, polymerase: Polymerase = Q5, thresholds: Thresholds = THRESHOLDS
) -> tuple[Check, ...]:
    """Judge what an annealing region's length decides: its length, GC, GC clamp and Tm.

    These are the checks a design chooses a length by, so it keeps inside the bands it is
    judged by wherever some length can.

    Examples
    --------
    >>> [check.status for check in annealing_checks("GTAAAACGACGGCCAGT")]
    ['warn', 'pass', 'pass', 'pass']
    """
    gc = 100.0 * sum(annealing.count(base) for base in "GC") / len(annealing)
    return (
        _graded("length", len(annealing), thresholds.length),
        _graded("gc_percent", gc, thresholds.gc_percent),
        _graded("gc_clamp", sum(annealing[-5:].count(base) for base in "GC"), thresholds.gc_clamp),
        _graded("tm", melting_temperature(annealing, polymerase), thresholds.tm),
    )

evaluate_pair

evaluate_pair(
    forward: Primer,
    reverse: Primer,
    template: SequenceRecord,
    *,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> PairReport

Judge a primer pair on a template, with the amplicon it makes.

Each primer is judged as on its own, and the pair adds the gap between their Tms, their heterodimers, and every product their binding sites can make. An amplicon runs from one primer's 5' end to the other's, tails counted, across the origin where it must.

Examples:

>>> from liulab_mbio.primers.design import design_pair
>>> template = SequenceRecord("GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT")
>>> pair = design_pair(template, 0, len(template))
>>> report = evaluate_pair(pair[0], pair[1], template)
>>> report.amplicon_length, report["products"].status
(48, 'pass')
Source code in src/liulab_mbio/primers/evaluation.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def evaluate_pair(
    forward: Primer,
    reverse: Primer,
    template: SequenceRecord,
    *,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> PairReport:
    """Judge a primer pair on a template, with the amplicon it makes.

    Each primer is judged as on its own, and the pair adds the gap between their Tms, their
    heterodimers, and every product their binding sites can make. An amplicon runs from one
    primer's 5' end to the other's, tails counted, across the origin where it must.

    Examples
    --------
    >>> from liulab_mbio.primers.design import design_pair
    >>> template = SequenceRecord("GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT")
    >>> pair = design_pair(template, 0, len(template))
    >>> report = evaluate_pair(pair[0], pair[1], template)
    >>> report.amplicon_length, report["products"].status
    (48, 'pass')
    """
    import primer3

    reports = tuple(
        evaluate_primer(primer, template, polymerase=polymerase, thresholds=thresholds)
        for primer in (forward, reverse)
    )
    tms = tuple(report["tm"].value for report in reports)
    first, second = (_thermo_sequence(primer.sequence) for primer in (forward, reverse))
    note = first[1] or second[1]
    anchored = max(
        primer3.calc_end_stability(first[0], second[0]).tm,
        primer3.calc_end_stability(second[0], first[0]).tm,
    )
    products = amplicon_sizes(forward, reverse, template, thresholds=thresholds)
    length = products[0] if len(products) == 1 else None
    checks = (
        _graded("tm_difference", abs(tms[0] - tms[1]), thresholds.tm_difference),
        _graded(
            "heterodimer", primer3.calc_heterodimer(first[0], second[0]).tm, thresholds.dimer, note
        ),
        _graded("heterodimer_3prime", anchored, thresholds.dimer_3prime, note),
        _graded(
            "products",
            len(products),
            thresholds.products,
            ", ".join(f"{size} bp" for size in products),
        ),
        Check("amplicon_size", None, length or 0),
    )
    return PairReport(
        reports[0],
        reports[1],
        checks,
        polymerase.annealing_temperature(*tms),
        length,
        None if length is None else polymerase.extension_seconds(length),
    )

evaluate_primer

evaluate_primer(
    primer: Primer,
    template: SequenceRecord | None = None,
    *,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> PrimerReport

Judge one primer, and where it anneals when a template is given.

The annealing region is the 3' part its binding site covers; a primer carrying none is placed on the template by find_binding_sites, and judged whole without one. Length, GC and Tm are of the annealing region, runs and structures of the whole primer. A primer longer than primer3 will align is judged on its 3'-terminal bases. The full-primer Tm and the 3'-end stability are reported without a verdict.

Examples:

>>> report = evaluate_primer(Primer("M13 fwd", "GTAAAACGACGGCCAGT"))
>>> report["gc_clamp"].value, report.status
(3, 'warn')
Source code in src/liulab_mbio/primers/evaluation.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def evaluate_primer(
    primer: Primer,
    template: SequenceRecord | None = None,
    *,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> PrimerReport:
    """Judge one primer, and where it anneals when a template is given.

    The annealing region is the 3' part its binding site covers; a primer carrying none is
    placed on the template by `find_binding_sites`, and judged whole without one. Length, GC
    and Tm are of the annealing region, runs and structures of the whole primer. A primer
    longer than primer3 will align is judged on its 3'-terminal bases. The full-primer Tm and
    the 3'-end stability are reported without a verdict.

    Examples
    --------
    >>> report = evaluate_primer(Primer("M13 fwd", "GTAAAACGACGGCCAGT"))
    >>> report["gc_clamp"].value, report.status
    (3, 'warn')
    """
    import primer3

    sites = primer.binding_sites
    if not sites and template is not None:
        sites = find_binding_sites(primer.sequence, template, thresholds=thresholds)
    annealing = _annealing_region(primer, sites)
    thermo, note = _thermo_sequence(primer.sequence)
    checks = (
        *annealing_checks(annealing, polymerase=polymerase, thresholds=thresholds),
        Check("tm_full", None, melting_temperature(primer.sequence, polymerase)),
        Check("end_stability", None, _end_stability(annealing)),
        _run_check(primer.sequence, thresholds),
        _graded(
            "dinucleotide_repeat",
            _longest_dinucleotide_repeat(primer.sequence),
            thresholds.dinucleotide_repeat,
        ),
        _graded("hairpin", primer3.calc_hairpin(thermo).tm, thresholds.hairpin, note),
        _graded("self_dimer", primer3.calc_homodimer(thermo).tm, thresholds.dimer, note),
        _graded(
            "self_dimer_3prime",
            primer3.calc_end_stability(thermo, thermo).tm,
            thresholds.dimer_3prime,
            note,
        ),
    )
    if template is not None:
        checks += _template_checks(annealing, sites, template, thresholds)
    return PrimerReport(primer, checks)

liulab_mbio.primers.design

Design a primer, or a pair, by choosing the annealing region at a position on a template.

design_pair

design_pair(
    template: SequenceRecord,
    start: int,
    end: int,
    *,
    forward_tail: str = "",
    reverse_tail: str = "",
    forward_name: str = "",
    reverse_name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> tuple[Primer, Primer]

Design a pair amplifying start to end, with their Tms as near each other as they go.

end passes the length of a circular template when the amplicon crosses the origin. The two lengths are chosen together: the pair grading best, then its two primers grading best together, then inside the length band, then the Tms sitting closest to each other and to target_tm.

Raises:

Type Description
ValueError

If no annealing region fits the template at either end.

Source code in src/liulab_mbio/primers/design.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def design_pair(
    template: SequenceRecord,
    start: int,
    end: int,
    *,
    forward_tail: str = "",
    reverse_tail: str = "",
    forward_name: str = "",
    reverse_name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> tuple[Primer, Primer]:
    """Design a pair amplifying `start` to `end`, with their Tms as near each other as they go.

    `end` passes the length of a circular template when the amplicon crosses the origin. The
    two lengths are chosen together: the pair grading best, then its two primers grading best
    together, then inside the length band, then the Tms sitting closest to each other and to
    `target_tm`.

    Raises
    ------
    ValueError
        If no annealing region fits the template at either end.
    """
    forwards = _annealing_options(template, start, Strand.FORWARD, polymerase, thresholds)
    reverses = _annealing_options(template, end, Strand.REVERSE, polymerase, thresholds)
    if not forwards or not reverses:
        raise ValueError(f"no annealing region fits this template at {start} or at {end}")
    forward, reverse = min(
        ((one, other) for one in forwards for other in reverses),
        key=lambda pair: _pair_score(pair[0], pair[1], target_tm, thresholds),
    )
    return (
        Primer(forward_name, forward_tail + forward.sequence, binding_sites=(forward.site,)),
        Primer(reverse_name, reverse_tail + reverse.sequence, binding_sites=(reverse.site,)),
    )

design_primer

design_primer(
    template: SequenceRecord,
    position: int,
    strand: Strand,
    *,
    tail: str = "",
    name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> Primer

Design a primer annealing at a position on a template.

A forward primer's annealing region starts at position and reads towards higher coordinates; a reverse primer's ends there and reads back. Either crosses the origin of a circular template. Each length Thresholds.length does not fail is graded by annealing_checks; the chosen one grades best, then lies inside the band a length passes on, then lands closest to target_tm. tail joins its 5' end and stays out of the binding site.

Raises:

Type Description
ValueError

If no annealing region fits the template there.

Examples:

>>> template = SequenceRecord("GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT")
>>> design_primer(template, 0, Strand.FORWARD, name="MCS fwd").sequence
'GGCGTAATCATGGTCATAGC'
Source code in src/liulab_mbio/primers/design.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def design_primer(
    template: SequenceRecord,
    position: int,
    strand: Strand,
    *,
    tail: str = "",
    name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
) -> Primer:
    """Design a primer annealing at a position on a template.

    A forward primer's annealing region starts at `position` and reads towards higher
    coordinates; a reverse primer's ends there and reads back. Either crosses the origin of a
    circular template. Each length `Thresholds.length` does not fail is graded by
    `annealing_checks`; the chosen one grades best, then lies inside the band a length passes
    on, then lands closest to `target_tm`. `tail` joins its 5' end and stays out of the binding
    site.

    Raises
    ------
    ValueError
        If no annealing region fits the template there.

    Examples
    --------
    >>> template = SequenceRecord("GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT")
    >>> design_primer(template, 0, Strand.FORWARD, name="MCS fwd").sequence
    'GGCGTAATCATGGTCATAGC'
    """
    options = _annealing_options(template, position, strand, polymerase, thresholds)
    if not options:
        raise ValueError(f"no annealing region fits this template at {position}")
    best = min(options, key=lambda option: _option_score(option, target_tm))
    return Primer(name, tail + best.sequence, binding_sites=(best.site,))

Protocols

The model a bench protocol is written in, and the renderer that turns one into a single self-contained HTML page. Check is how a page shows a verdict, with no value; Oligo is one row of the order sheet, carrying its own verdict and the checks that fired where something judged it; OVERVIEW_CHARS is the character budget for a header card, and a longer value is refused rather than truncated.

liulab_mbio.protocol

Bench protocols: a small model, loaded from JSON and rendered to one self-contained HTML file.

JSON keys are the field names of the classes below, lists stand for tuples, and only the fields without a default are required::

{"title": str, "summary": str, "overview": {label: short value},
 "highlights": [sentence], "checks": [{"name", "status", "detail"}],
 "materials": [{"name", "supplier", "catalog", "storage", "amount", "note"}],
 "oligos": [{"name", "sequence", "purpose", "tm_c", "stock", "note", "status",
     "checks": [{"name", "status", "detail"}]}],
 "equipment": [str],
 "steps": [{"title", "instructions": [str], "cautions": [str], "notes": [str],
     "tables": [{"title", "reactions", "overage",
         "components": [{"name", "volume_ul", "stock", "final", "master_mix"}]}],
     "programs": [{"title", "lid_temperature_c",
         "stages": [{"cycles", "incubations": [{"label", "temperature_c", "seconds"}]}]}],
     "timers": [{"label", "seconds"}],
     "gels": [{"title", "ladder": {"name", "bands_bp"}, "lanes": [{"label", "bands_bp"}]}],
     "expected": [str], "troubleshooting": [{"problem", "solution"}]}],
 "references": [{"text", "url"}]}

An incubation's "seconds": null holds indefinitely. A check's "status" is "pass", "warn" or "fail"; an oligo's may also be absent, which says nothing judged that row. An "overview" value is a card: a few words, never a sentence.

liulab_mbio.protocol.model

The protocol model, and loading it from JSON.

Every class refuses, with ValueError, a value no bench could follow: an empty title, a non-positive volume, time, cycle count or band size, or a link that is not http(s).

Check dataclass

One pass, warn or fail verdict on the work, shown in the header as a badge.

Parameters:

Name Type Description Default
name str

What was judged, such as "junctions".

required
status Status

One of STATUSES.

required
detail str

What a reader needs besides the verdict, shown only where the verdict is not a pass.

''
Source code in src/liulab_mbio/protocol/model.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@dataclass(frozen=True, slots=True)
class Check:
    """One pass, warn or fail verdict on the work, shown in the header as a badge.

    Parameters
    ----------
    name
        What was judged, such as ``"junctions"``.
    status
        One of `STATUSES`.
    detail
        What a reader needs besides the verdict, shown only where the verdict is not a pass.
    """

    name: str
    status: Status
    detail: str = ""

    def __post_init__(self) -> None:
        """Refuse a verdict that is not one of the three."""
        _require(
            self.status in STATUSES,
            f"check {self.name!r}: status is one of {', '.join(STATUSES)}, got {self.status!r}",
        )

__post_init__

__post_init__() -> None

Refuse a verdict that is not one of the three.

Source code in src/liulab_mbio/protocol/model.py
74
75
76
77
78
79
def __post_init__(self) -> None:
    """Refuse a verdict that is not one of the three."""
    _require(
        self.status in STATUSES,
        f"check {self.name!r}: status is one of {', '.join(STATUSES)}, got {self.status!r}",
    )

Component dataclass

One line of a reaction table.

Parameters:

Name Type Description Default
name str

What to pipette.

required
volume_ul float

Microlitres per reaction.

required
stock str

Free-text concentrations, such as "10x" and "1x".

''
final str

Free-text concentrations, such as "10x" and "1x".

''
master_mix bool

False for a component added to each tube separately, such as template.

True
Source code in src/liulab_mbio/protocol/model.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
@dataclass(frozen=True, slots=True)
class Component:
    """One line of a reaction table.

    Parameters
    ----------
    name
        What to pipette.
    volume_ul
        Microlitres per reaction.
    stock, final
        Free-text concentrations, such as ``"10x"`` and ``"1x"``.
    master_mix
        ``False`` for a component added to each tube separately, such as template.
    """

    name: str
    volume_ul: float
    _: KW_ONLY
    stock: str = ""
    final: str = ""
    master_mix: bool = True

    def __post_init__(self) -> None:
        """Refuse a volume that is not positive."""
        _require(self.volume_ul > 0, f"component {self.name!r}: volume_ul must be positive")

__post_init__

__post_init__() -> None

Refuse a volume that is not positive.

Source code in src/liulab_mbio/protocol/model.py
157
158
159
def __post_init__(self) -> None:
    """Refuse a volume that is not positive."""
    _require(self.volume_ul > 0, f"component {self.name!r}: volume_ul must be positive")

Gel dataclass

A simulated agarose gel: a ladder lane followed by sample lanes.

Source code in src/liulab_mbio/protocol/model.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
@dataclass(frozen=True, slots=True)
class Gel:
    """A simulated agarose gel: a ladder lane followed by sample lanes."""

    ladder: Ladder
    lanes: tuple[Lane, ...]
    _: KW_ONLY
    title: str = ""

    def migration(self, bp: float) -> float:
        """Return how far a band runs: 0 for the largest band on this gel, 1 for the smallest.

        Linear in the logarithm of size, the usual approximation for agarose.
        """
        sizes = [*self.ladder.bands_bp, *(bp for lane in self.lanes for bp in lane.bands_bp)]
        top, bottom = math.log10(max(sizes)), math.log10(min(sizes))
        if top == bottom:
            return 0.5
        return (top - math.log10(bp)) / (top - bottom)

migration

migration(bp: float) -> float

Return how far a band runs: 0 for the largest band on this gel, 1 for the smallest.

Linear in the logarithm of size, the usual approximation for agarose.

Source code in src/liulab_mbio/protocol/model.py
315
316
317
318
319
320
321
322
323
324
def migration(self, bp: float) -> float:
    """Return how far a band runs: 0 for the largest band on this gel, 1 for the smallest.

    Linear in the logarithm of size, the usual approximation for agarose.
    """
    sizes = [*self.ladder.bands_bp, *(bp for lane in self.lanes for bp in lane.bands_bp)]
    top, bottom = math.log10(max(sizes)), math.log10(min(sizes))
    if top == bottom:
        return 0.5
    return (top - math.log10(bp)) / (top - bottom)

Incubation dataclass

One temperature held for a time.

Parameters:

Name Type Description Default
label str

Such as "Annealing".

required
temperature_c float

Degrees Celsius.

required
seconds float | None

None holds until the reader stops it.

required
Source code in src/liulab_mbio/protocol/model.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
@dataclass(frozen=True, slots=True)
class Incubation:
    """One temperature held for a time.

    Parameters
    ----------
    label
        Such as ``"Annealing"``.
    temperature_c
        Degrees Celsius.
    seconds
        ``None`` holds until the reader stops it.
    """

    label: str
    temperature_c: float
    seconds: float | None

    def __post_init__(self) -> None:
        """Refuse a time that is not positive."""
        _require(
            self.seconds is None or self.seconds > 0,
            f"incubation {self.label!r}: seconds must be positive or null",
        )

__post_init__

__post_init__() -> None

Refuse a time that is not positive.

Source code in src/liulab_mbio/protocol/model.py
224
225
226
227
228
229
def __post_init__(self) -> None:
    """Refuse a time that is not positive."""
    _require(
        self.seconds is None or self.seconds > 0,
        f"incubation {self.label!r}: seconds must be positive or null",
    )

Ladder dataclass

A named DNA size marker and its band sizes in base pairs.

Source code in src/liulab_mbio/protocol/model.py
281
282
283
284
285
286
287
288
289
290
291
@dataclass(frozen=True, slots=True)
class Ladder:
    """A named DNA size marker and its band sizes in base pairs."""

    name: str
    bands_bp: tuple[int, ...]

    def __post_init__(self) -> None:
        """Refuse a ladder with no band or a non-positive band size."""
        _require(bool(self.bands_bp), f"ladder {self.name!r} has no band")
        _check_bands(self.bands_bp, f"ladder {self.name!r}")

__post_init__

__post_init__() -> None

Refuse a ladder with no band or a non-positive band size.

Source code in src/liulab_mbio/protocol/model.py
288
289
290
291
def __post_init__(self) -> None:
    """Refuse a ladder with no band or a non-positive band size."""
    _require(bool(self.bands_bp), f"ladder {self.name!r} has no band")
    _check_bands(self.bands_bp, f"ladder {self.name!r}")

Lane dataclass

One sample lane of a simulated gel; no band sizes draws an empty lane.

Source code in src/liulab_mbio/protocol/model.py
294
295
296
297
298
299
300
301
302
303
@dataclass(frozen=True, slots=True)
class Lane:
    """One sample lane of a simulated gel; no band sizes draws an empty lane."""

    label: str
    bands_bp: tuple[int, ...] = ()

    def __post_init__(self) -> None:
        """Refuse a non-positive band size."""
        _check_bands(self.bands_bp, f"lane {self.label!r}")

__post_init__

__post_init__() -> None

Refuse a non-positive band size.

Source code in src/liulab_mbio/protocol/model.py
301
302
303
def __post_init__(self) -> None:
    """Refuse a non-positive band size."""
    _check_bands(self.bands_bp, f"lane {self.label!r}")

Material dataclass

A reagent, kit or consumable the protocol needs. An oligo is an Oligo.

Parameters:

Name Type Description Default
name str

As it is labelled on the tube or shelf.

required
supplier str

Who sells it and the number to order it by. Left empty where they are not known, and never guessed: a catalogue number is ordered as written.

''
catalog str

Who sells it and the number to order it by. Left empty where they are not known, and never guessed: a catalogue number is ordered as written.

''
storage str

Such as "-20 °C".

''
amount str

What one run takes, such as "1 µL per reaction".

''
note str

Anything else the bench needs, such as a stock concentration.

''
Source code in src/liulab_mbio/protocol/model.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@dataclass(frozen=True, slots=True)
class Material:
    """A reagent, kit or consumable the protocol needs. An oligo is an `Oligo`.

    Parameters
    ----------
    name
        As it is labelled on the tube or shelf.
    supplier, catalog
        Who sells it and the number to order it by. Left empty where they are not known, and
        never guessed: a catalogue number is ordered as written.
    storage
        Such as ``"-20 °C"``.
    amount
        What one run takes, such as ``"1 µL per reaction"``.
    note
        Anything else the bench needs, such as a stock concentration.
    """

    name: str
    _: KW_ONLY
    supplier: str = ""
    catalog: str = ""
    storage: str = ""
    amount: str = ""
    note: str = ""

Oligo dataclass

One synthetic DNA to order: a row of the order sheet, kept apart from the reagents.

Parameters:

Name Type Description Default
name str

What to order it under, and what its tube is labelled.

required
sequence str

5' to 3'. Shown with a copy button, and its length is counted from it.

required
purpose str

What it is for, such as the title of the step that uses it.

''
tm_c float | None

Of the part that anneals, °C, or None where none was computed.

None
stock str

The working dilution, such as "10 µM".

''
note str

Anything else, such as a modification or a purification.

''
status Status | None

The row's own verdict, one of STATUSES, or None where nothing judged it. A row with no verdict says so rather than reading as a pass.

None
checks tuple[Check, ...]

Why the verdict is not a pass: the checks that fired, each in a few words. The sheet shows the verdict in the row and keeps these behind a toggle, so it stays a sheet.

()
Source code in src/liulab_mbio/protocol/model.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@dataclass(frozen=True, slots=True)
class Oligo:
    """One synthetic DNA to order: a row of the order sheet, kept apart from the reagents.

    Parameters
    ----------
    name
        What to order it under, and what its tube is labelled.
    sequence
        5' to 3'. Shown with a copy button, and its length is counted from it.
    purpose
        What it is for, such as the title of the step that uses it.
    tm_c
        Of the part that anneals, °C, or ``None`` where none was computed.
    stock
        The working dilution, such as ``"10 µM"``.
    note
        Anything else, such as a modification or a purification.
    status
        The row's own verdict, one of `STATUSES`, or ``None`` where nothing judged it. A row
        with no verdict says so rather than reading as a pass.
    checks
        Why the verdict is not a pass: the checks that fired, each in a few words. The sheet
        shows the verdict in the row and keeps these behind a toggle, so it stays a sheet.
    """

    name: str
    sequence: str
    _: KW_ONLY
    purpose: str = ""
    tm_c: float | None = None
    stock: str = ""
    note: str = ""
    status: Status | None = None
    checks: tuple[Check, ...] = ()

    def __post_init__(self) -> None:
        """Refuse an oligo with no sequence, or a verdict better than its own checks."""
        _require(bool(self.sequence.strip()), f"oligo {self.name!r} has no sequence")
        _require(
            self.status is None or self.status in STATUSES,
            f"oligo {self.name!r}: status is one of {', '.join(STATUSES)}, got {self.status!r}",
        )
        for check in self.checks:
            _require(
                self.status is not None
                and STATUSES.index(self.status) >= STATUSES.index(check.status),
                f"oligo {self.name!r}: {check.name} is {check.status!r} and the row says "
                f"{self.status!r}",
            )

__post_init__

__post_init__() -> None

Refuse an oligo with no sequence, or a verdict better than its own checks.

Source code in src/liulab_mbio/protocol/model.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def __post_init__(self) -> None:
    """Refuse an oligo with no sequence, or a verdict better than its own checks."""
    _require(bool(self.sequence.strip()), f"oligo {self.name!r} has no sequence")
    _require(
        self.status is None or self.status in STATUSES,
        f"oligo {self.name!r}: status is one of {', '.join(STATUSES)}, got {self.status!r}",
    )
    for check in self.checks:
        _require(
            self.status is not None
            and STATUSES.index(self.status) >= STATUSES.index(check.status),
            f"oligo {self.name!r}: {check.name} is {check.status!r} and the row says "
            f"{self.status!r}",
        )

Protocol dataclass

A bench protocol.

Parameters:

Name Type Description Default
title str

The page heading.

required
summary str

One paragraph: what the protocol does.

''
overview Mapping[str, str]

Short facts, label to value, shown as a grid of cards. A handful of words each, at most OVERVIEW_CHARS characters, so the row scans left to right and stays one height.

dict()
highlights tuple[str, ...]

What a fact means, a sentence each, shown as prose under the cards. A statement a reader has to read rather than scan goes here and not in overview.

()
checks tuple[Check, ...]

Verdicts on the work, shown as a strip of badges, so a warning is seen and not read.

()
materials tuple[Material, ...]

The reagents, the oligos to order, and the hardware. Three lists and not one, because an order sheet and a reagent list want different columns.

()
oligos tuple[Material, ...]

The reagents, the oligos to order, and the hardware. Three lists and not one, because an order sheet and a reagent list want different columns.

()
equipment tuple[Material, ...]

The reagents, the oligos to order, and the hardware. Three lists and not one, because an order sheet and a reagent list want different columns.

()
steps tuple[Step, ...]

In the order they are shown.

()
references tuple[Step, ...]

In the order they are shown.

()
Source code in src/liulab_mbio/protocol/model.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
@dataclass(frozen=True, slots=True)
class Protocol:
    """A bench protocol.

    Parameters
    ----------
    title
        The page heading.
    summary
        One paragraph: what the protocol does.
    overview
        Short facts, label to value, shown as a grid of cards. A handful of words each, at most
        `OVERVIEW_CHARS` characters, so the row scans left to right and stays one height.
    highlights
        What a fact means, a sentence each, shown as prose under the cards. A statement a reader
        has to read rather than scan goes here and not in `overview`.
    checks
        Verdicts on the work, shown as a strip of badges, so a warning is seen and not read.
    materials, oligos, equipment
        The reagents, the oligos to order, and the hardware. Three lists and not one, because an
        order sheet and a reagent list want different columns.
    steps, references
        In the order they are shown.
    """

    title: str
    _: KW_ONLY
    summary: str = ""
    overview: Mapping[str, str] = field(default_factory=dict, hash=False)
    highlights: tuple[str, ...] = ()
    checks: tuple[Check, ...] = ()
    materials: tuple[Material, ...] = ()
    oligos: tuple[Oligo, ...] = ()
    equipment: tuple[str, ...] = ()
    steps: tuple[Step, ...] = ()
    references: tuple[Reference, ...] = ()

    def __post_init__(self) -> None:
        """Refuse an empty title, or an overview value too long to be a card."""
        _require(bool(self.title.strip()), "a protocol needs a title")
        for label, value in self.overview.items():
            _require(
                len(value) <= OVERVIEW_CHARS,
                f"overview {label!r} is {len(value)} characters, over {OVERVIEW_CHARS}: a card "
                "holds a few words, so put a sentence in highlights instead",
            )

    @classmethod
    def from_dict(cls, data: Mapping[str, Any]) -> "Protocol":
        """Build a protocol from parsed JSON; keys are the field names, lists become tuples.

        Raises
        ------
        ValueError
            On an unknown or missing key, naming where it is, or a value the model refuses.
        """
        return _PROTOCOL(data, "protocol")

__post_init__

__post_init__() -> None

Refuse an empty title, or an overview value too long to be a card.

Source code in src/liulab_mbio/protocol/model.py
437
438
439
440
441
442
443
444
445
def __post_init__(self) -> None:
    """Refuse an empty title, or an overview value too long to be a card."""
    _require(bool(self.title.strip()), "a protocol needs a title")
    for label, value in self.overview.items():
        _require(
            len(value) <= OVERVIEW_CHARS,
            f"overview {label!r} is {len(value)} characters, over {OVERVIEW_CHARS}: a card "
            "holds a few words, so put a sentence in highlights instead",
        )

from_dict classmethod

from_dict(data: Mapping[str, Any]) -> Protocol

Build a protocol from parsed JSON; keys are the field names, lists become tuples.

Raises:

Type Description
ValueError

On an unknown or missing key, naming where it is, or a value the model refuses.

Source code in src/liulab_mbio/protocol/model.py
447
448
449
450
451
452
453
454
455
456
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "Protocol":
    """Build a protocol from parsed JSON; keys are the field names, lists become tuples.

    Raises
    ------
    ValueError
        On an unknown or missing key, naming where it is, or a value the model refuses.
    """
    return _PROTOCOL(data, "protocol")

ReactionTable dataclass

Per-reaction volumes, scaled to a master mix for several reactions.

Parameters:

Name Type Description Default
components tuple[Component, ...]

In pipetting order; at least one.

required
title str

Such as "PCR master mix".

''
reactions int

The reaction count shown first; the reader can change it.

1
overage float

Extra master mix as a fraction, so 0.1 makes enough for 10% more reactions.

0.1
Source code in src/liulab_mbio/protocol/model.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
@dataclass(frozen=True, slots=True)
class ReactionTable:
    """Per-reaction volumes, scaled to a master mix for several reactions.

    Parameters
    ----------
    components
        In pipetting order; at least one.
    title
        Such as ``"PCR master mix"``.
    reactions
        The reaction count shown first; the reader can change it.
    overage
        Extra master mix as a fraction, so ``0.1`` makes enough for 10% more reactions.
    """

    components: tuple[Component, ...]
    _: KW_ONLY
    title: str = ""
    reactions: int = 1
    overage: float = 0.1

    def __post_init__(self) -> None:
        """Refuse an empty table, fewer than one reaction, or a negative overage."""
        _require(bool(self.components), f"reaction table {self.title!r} has no component")
        _require(self.reactions >= 1, "reactions must be at least 1")
        _require(self.overage >= 0, "overage must not be negative")

    def mix_volumes(self, reactions: int) -> tuple[float | None, ...]:
        """Return each component's master-mix volume in µL, to 0.01 µL.

        ``None`` marks a component added to each tube instead.

        Examples
        --------
        >>> ReactionTable((Component("Buffer", 2.5),), overage=0.1).mix_volumes(8)
        (22.0,)
        """
        scale = reactions * (1 + self.overage)
        return tuple(
            round(c.volume_ul * scale, 2) if c.master_mix else None for c in self.components
        )

__post_init__

__post_init__() -> None

Refuse an empty table, fewer than one reaction, or a negative overage.

Source code in src/liulab_mbio/protocol/model.py
184
185
186
187
188
def __post_init__(self) -> None:
    """Refuse an empty table, fewer than one reaction, or a negative overage."""
    _require(bool(self.components), f"reaction table {self.title!r} has no component")
    _require(self.reactions >= 1, "reactions must be at least 1")
    _require(self.overage >= 0, "overage must not be negative")

mix_volumes

mix_volumes(reactions: int) -> tuple[float | None, ...]

Return each component's master-mix volume in µL, to 0.01 µL.

None marks a component added to each tube instead.

Examples:

>>> ReactionTable((Component("Buffer", 2.5),), overage=0.1).mix_volumes(8)
(22.0,)
Source code in src/liulab_mbio/protocol/model.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def mix_volumes(self, reactions: int) -> tuple[float | None, ...]:
    """Return each component's master-mix volume in µL, to 0.01 µL.

    ``None`` marks a component added to each tube instead.

    Examples
    --------
    >>> ReactionTable((Component("Buffer", 2.5),), overage=0.1).mix_volumes(8)
    (22.0,)
    """
    scale = reactions * (1 + self.overage)
    return tuple(
        round(c.volume_ul * scale, 2) if c.master_mix else None for c in self.components
    )

Reference dataclass

A citation, with an optional http(s) link.

Source code in src/liulab_mbio/protocol/model.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
@dataclass(frozen=True, slots=True)
class Reference:
    """A citation, with an optional http(s) link."""

    text: str
    _: KW_ONLY
    url: str = ""

    def __post_init__(self) -> None:
        """Refuse a link that is not http or https."""
        _require(
            not self.url or self.url.startswith(("http://", "https://")),
            f"reference url must be http(s), got {self.url!r}",
        )

__post_init__

__post_init__() -> None

Refuse a link that is not http or https.

Source code in src/liulab_mbio/protocol/model.py
355
356
357
358
359
360
def __post_init__(self) -> None:
    """Refuse a link that is not http or https."""
    _require(
        not self.url or self.url.startswith(("http://", "https://")),
        f"reference url must be http(s), got {self.url!r}",
    )

Stage dataclass

Incubations run in order, repeated cycles times.

Source code in src/liulab_mbio/protocol/model.py
232
233
234
235
236
237
238
239
240
241
242
243
@dataclass(frozen=True, slots=True)
class Stage:
    """Incubations run in order, repeated `cycles` times."""

    incubations: tuple[Incubation, ...]
    _: KW_ONLY
    cycles: int = 1

    def __post_init__(self) -> None:
        """Refuse an empty stage or fewer than one cycle."""
        _require(bool(self.incubations), "a stage needs at least one incubation")
        _require(self.cycles >= 1, "cycles must be at least 1")

__post_init__

__post_init__() -> None

Refuse an empty stage or fewer than one cycle.

Source code in src/liulab_mbio/protocol/model.py
240
241
242
243
def __post_init__(self) -> None:
    """Refuse an empty stage or fewer than one cycle."""
    _require(bool(self.incubations), "a stage needs at least one incubation")
    _require(self.cycles >= 1, "cycles must be at least 1")

Step dataclass

One numbered step of a protocol.

Parameters:

Name Type Description Default
title str

What the step achieves, such as "Run the thermocycler".

required
instructions tuple[str, ...]

Ordered actions, one sentence each.

()
cautions tuple[str, ...]

Shown before and after the instructions.

()
notes tuple[str, ...]

Shown before and after the instructions.

()
tables tuple[ReactionTable, ...]

Reaction tables, thermocycler programs and countdowns the step uses.

()
programs tuple[ReactionTable, ...]

Reaction tables, thermocycler programs and countdowns the step uses.

()
timers tuple[ReactionTable, ...]

Reaction tables, thermocycler programs and countdowns the step uses.

()
gels tuple[Gel, ...]

What a successful step looks like.

()
expected tuple[Gel, ...]

What a successful step looks like.

()
troubleshooting tuple[Troubleshooting, ...]

Problems the reader may see here.

()
Source code in src/liulab_mbio/protocol/model.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
@dataclass(frozen=True, slots=True)
class Step:
    """One numbered step of a protocol.

    Parameters
    ----------
    title
        What the step achieves, such as ``"Run the thermocycler"``.
    instructions
        Ordered actions, one sentence each.
    cautions, notes
        Shown before and after the instructions.
    tables, programs, timers
        Reaction tables, thermocycler programs and countdowns the step uses.
    gels, expected
        What a successful step looks like.
    troubleshooting
        Problems the reader may see here.
    """

    title: str
    _: KW_ONLY
    instructions: tuple[str, ...] = ()
    cautions: tuple[str, ...] = ()
    notes: tuple[str, ...] = ()
    tables: tuple[ReactionTable, ...] = ()
    programs: tuple[ThermocyclerProgram, ...] = ()
    timers: tuple[Timer, ...] = ()
    gels: tuple[Gel, ...] = ()
    expected: tuple[str, ...] = ()
    troubleshooting: tuple[Troubleshooting, ...] = ()

    def __post_init__(self) -> None:
        """Refuse an empty title."""
        _require(bool(self.title.strip()), "a step needs a title")

__post_init__

__post_init__() -> None

Refuse an empty title.

Source code in src/liulab_mbio/protocol/model.py
395
396
397
def __post_init__(self) -> None:
    """Refuse an empty title."""
    _require(bool(self.title.strip()), "a step needs a title")

ThermocyclerProgram dataclass

Stages run in order.

Parameters:

Name Type Description Default
stages tuple[Stage, ...]

In run order; at least one.

required
title str

The name to save the program under.

''
lid_temperature_c float | None

Heated lid, or None to leave it unstated.

None
Source code in src/liulab_mbio/protocol/model.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
@dataclass(frozen=True, slots=True)
class ThermocyclerProgram:
    """Stages run in order.

    Parameters
    ----------
    stages
        In run order; at least one.
    title
        The name to save the program under.
    lid_temperature_c
        Heated lid, or ``None`` to leave it unstated.
    """

    stages: tuple[Stage, ...]
    _: KW_ONLY
    title: str = ""
    lid_temperature_c: float | None = None

    def __post_init__(self) -> None:
        """Refuse a program with no stage."""
        _require(bool(self.stages), f"program {self.title!r} has no stage")

    @property
    def duration_seconds(self) -> float:
        """Run time at the block temperatures, leaving out ramps and indefinite holds."""
        return sum(
            stage.cycles * sum(i.seconds or 0 for i in stage.incubations) for stage in self.stages
        )

duration_seconds property

duration_seconds: float

Run time at the block temperatures, leaving out ramps and indefinite holds.

__post_init__

__post_init__() -> None

Refuse a program with no stage.

Source code in src/liulab_mbio/protocol/model.py
265
266
267
def __post_init__(self) -> None:
    """Refuse a program with no stage."""
    _require(bool(self.stages), f"program {self.title!r} has no stage")

Timer dataclass

A countdown the reader can start from the step.

Source code in src/liulab_mbio/protocol/model.py
327
328
329
330
331
332
333
334
335
336
@dataclass(frozen=True, slots=True)
class Timer:
    """A countdown the reader can start from the step."""

    label: str
    seconds: float

    def __post_init__(self) -> None:
        """Refuse a time that is not positive."""
        _require(self.seconds > 0, f"timer {self.label!r}: seconds must be positive")

__post_init__

__post_init__() -> None

Refuse a time that is not positive.

Source code in src/liulab_mbio/protocol/model.py
334
335
336
def __post_init__(self) -> None:
    """Refuse a time that is not positive."""
    _require(self.seconds > 0, f"timer {self.label!r}: seconds must be positive")

Troubleshooting dataclass

A problem the reader may see at a step, and what to do about it.

Source code in src/liulab_mbio/protocol/model.py
339
340
341
342
343
344
@dataclass(frozen=True, slots=True)
class Troubleshooting:
    """A problem the reader may see at a step, and what to do about it."""

    problem: str
    solution: str

read_protocol

read_protocol(path: str | PathLike[str]) -> Protocol

Read a protocol from a JSON file; see Protocol.from_dict.

Source code in src/liulab_mbio/protocol/model.py
459
460
461
def read_protocol(path: str | os.PathLike[str]) -> Protocol:
    """Read a protocol from a JSON file; see `Protocol.from_dict`."""
    return Protocol.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))

liulab_mbio.protocol.render

Render a protocol to one self-contained HTML page.

render_html

render_html(protocol: Protocol) -> str

Return protocol as one HTML page with its styles and script inline.

The page loads nothing over the network, remembers check marks and reaction counts in the browser's local storage when it can, and prints without its controls.

Source code in src/liulab_mbio/protocol/render.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def render_html(protocol: Protocol) -> str:
    """Return `protocol` as one HTML page with its styles and script inline.

    The page loads nothing over the network, remembers check marks and reaction counts in the
    browser's local storage when it can, and prints without its controls.
    """
    key = hashlib.sha256(repr(protocol).encode()).hexdigest()[:16]
    body = "".join(
        [
            _header(protocol),
            _materials(protocol.materials, protocol.equipment),
            _oligos(protocol.oligos),
            *(_step(n, step) for n, step in enumerate(protocol.steps, 1)),
            _references(protocol.references),
        ]
    )
    return (
        '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
        '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
        f"<title>{escape(protocol.title)}</title>\n<style>\n{_asset('protocol.css')}</style>\n"
        f'</head>\n<body data-protocol="{key}">\n<main class="page">\n{body}</main>\n'
        f"<script>\n{_asset('protocol.js')}</script>\n</body>\n</html>\n"
    )

write_html

write_html(
    protocol: Protocol, path: str | PathLike[str]
) -> Path

Write render_html(protocol) to path as UTF-8 and return the path.

Source code in src/liulab_mbio/protocol/render.py
50
51
52
53
54
def write_html(protocol: Protocol, path: str | os.PathLike[str]) -> Path:
    """Write `render_html(protocol)` to `path` as UTF-8 and return the path."""
    out = Path(path)
    out.write_text(render_html(protocol), encoding="utf-8")
    return out

Bench

The numbers any cloning pipeline shares: DNA amounts, PCR and colony PCR, gels, the checks that confirm a clone, heat inactivation, the phenotype a clone should show, the primer order sheet, and the protocol steps any pipeline reuses. Every public name in the modules below imports from liulab_mbio.bench too. A module that cites a source keeps its own REFERENCES, and liulab_mbio.bench.REFERENCES gathers them all. steps is the one exception: a protocol cites its DPNI_REFERENCE and PLATE_REFERENCE only when it runs the step they belong to.

liulab_mbio.bench

The bench any cloning pipeline shares: reactions, programs, validation and protocol steps.

Every public name in its modules imports from here as well. A module that cites a source keeps it as its own REFERENCES; REFERENCES here gathers them.

  • amounts: the weight of DNA, picomoles from nanograms, and what to pipette.
  • pcr: the PCR and colony PCR reactions and programs.
  • gels: the ladder and agarose percentage a range of bands takes.
  • validation: the colony PCR that reads an assembly's junctions, and the Sanger reads.
  • inactivation: an enzyme's heat inactivation.
  • phenotype: what a clone is expected to show, read off the product's own features.
  • oligos: the primer order sheet.
  • steps: the protocol steps any pipeline reuses, built from plain facts. Its two references are cited only by a protocol that runs the step they belong to.

Nothing here imports liulab_mbio.goldengate.

liulab_mbio.bench.amounts

How much DNA a reaction takes: its weight, picomoles from nanograms, and what to pipette.

The conversion is NEBioCalculator's, through docs/research/primer-design-and-pcr.md.

Amount dataclass

How much of one DNA a reaction takes, in the three units a bench needs.

Parameters:

Name Type Description Default
name str

The DNA's.

required
length_bp str

The DNA's.

required
pmol float

What the protocol asks for.

required
nanograms float

The same amount weighed.

required
volume_ul float

What to pipette at the DNA's concentration, or DNA_VOLUME_UL without one.

required

Raises:

Type Description
ValueError

If any of the three is not positive.

Source code in src/liulab_mbio/bench/amounts.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@dataclass(frozen=True, slots=True)
class Amount:
    """How much of one DNA a reaction takes, in the three units a bench needs.

    Parameters
    ----------
    name, length_bp
        The DNA's.
    pmol
        What the protocol asks for.
    nanograms
        The same amount weighed.
    volume_ul
        What to pipette at the DNA's concentration, or `DNA_VOLUME_UL` without one.

    Raises
    ------
    ValueError
        If any of the three is not positive.
    """

    name: str
    length_bp: int
    _: KW_ONLY
    pmol: float
    nanograms: float
    volume_ul: float

    def __post_init__(self) -> None:
        """Refuse an amount no one can pipette."""
        for field, value in (
            ("pmol", self.pmol),
            ("nanograms", self.nanograms),
            ("volume_ul", self.volume_ul),
        ):
            if value <= 0:
                raise ValueError(f"amount {self.name!r}: {field} must be positive")

__post_init__

__post_init__() -> None

Refuse an amount no one can pipette.

Source code in src/liulab_mbio/bench/amounts.py
68
69
70
71
72
73
74
75
76
def __post_init__(self) -> None:
    """Refuse an amount no one can pipette."""
    for field, value in (
        ("pmol", self.pmol),
        ("nanograms", self.nanograms),
        ("volume_ul", self.volume_ul),
    ):
        if value <= 0:
            raise ValueError(f"amount {self.name!r}: {field} must be positive")

dna_amount

dna_amount(
    name: str,
    length_bp: int,
    *,
    pmol: float,
    concentration_ng_ul: float | None = None,
) -> Amount

Return how much of one DNA a reaction takes, weighed and as a volume to pipette.

Parameters:

Name Type Description Default
name str

What its tube is labelled.

required
length_bp int

Base pairs.

required
pmol float

What the reaction asks for.

required
concentration_ng_ul float | None

Of that tube, or None when it is not measured yet.

None

Raises:

Type Description
ValueError

If the length, the concentration or the picomoles are not positive.

Examples:

>>> dna_amount("pUC19", 2686, pmol=0.05).nanograms
82.72
Source code in src/liulab_mbio/bench/amounts.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def dna_amount(
    name: str, length_bp: int, *, pmol: float, concentration_ng_ul: float | None = None
) -> Amount:
    """Return how much of one DNA a reaction takes, weighed and as a volume to pipette.

    Parameters
    ----------
    name
        What its tube is labelled.
    length_bp
        Base pairs.
    pmol
        What the reaction asks for.
    concentration_ng_ul
        Of that tube, or ``None`` when it is not measured yet.

    Raises
    ------
    ValueError
        If the length, the concentration or the picomoles are not positive.

    Examples
    --------
    >>> dna_amount("pUC19", 2686, pmol=0.05).nanograms
    82.72
    """
    if length_bp <= 0:
        raise ValueError(f"amount {name!r}: length_bp must be positive")
    if concentration_ng_ul is not None and concentration_ng_ul <= 0:
        raise ValueError(f"amount {name!r}: concentration_ng_ul must be positive")
    nanograms = to_nanograms(pmol, length_bp)
    volume = DNA_VOLUME_UL if concentration_ng_ul is None else nanograms / concentration_ng_ul
    return Amount(
        name, length_bp, pmol=pmol, nanograms=round(nanograms, 2), volume_ul=round(volume, 2)
    )

molecular_weight

molecular_weight(length_bp: int) -> float

Return the weight of a double-stranded DNA molecule, g/mol.

Source code in src/liulab_mbio/bench/amounts.py
19
20
21
def molecular_weight(length_bp: int) -> float:
    """Return the weight of a double-stranded DNA molecule, g/mol."""
    return _DUPLEX_ENDS + length_bp * _BASE_PAIR

to_nanograms

to_nanograms(pmol: float, length_bp: int) -> float

Return what those picomoles of double-stranded DNA of this length weigh, ng.

Source code in src/liulab_mbio/bench/amounts.py
35
36
37
def to_nanograms(pmol: float, length_bp: int) -> float:
    """Return what those picomoles of double-stranded DNA of this length weigh, ng."""
    return pmol * molecular_weight(length_bp) / 1000.0

to_pmol

to_pmol(nanograms: float, length_bp: int) -> float

Return how many picomoles a mass of double-stranded DNA of this length is.

Examples:

>>> round(to_pmol(1000, 2686), 3)
0.604
Source code in src/liulab_mbio/bench/amounts.py
24
25
26
27
28
29
30
31
32
def to_pmol(nanograms: float, length_bp: int) -> float:
    """Return how many picomoles a mass of double-stranded DNA of this length is.

    Examples
    --------
    >>> round(to_pmol(1000, 2686), 3)
    0.604
    """
    return 1000.0 * nanograms / molecular_weight(length_bp)

liulab_mbio.bench.pcr

PCR and colony PCR at the bench: the reaction and the thermocycler program for each.

Every number is NEB's, through docs/research/primer-design-and-pcr.md; each polymerase carries its own profile. Functions return liulab_mbio.protocol values, so a protocol prints them unchanged.

colony_pcr_master_mix_component

colony_pcr_master_mix_component(
    volume_ul: float = COLONY_PCR_VOLUME_UL,
) -> Component

Return the 2X master mix component of a colony PCR of volume_ul.

Source code in src/liulab_mbio/bench/pcr.py
116
117
118
def colony_pcr_master_mix_component(volume_ul: float = COLONY_PCR_VOLUME_UL) -> Component:
    """Return the 2X master mix component of a colony PCR of `volume_ul`."""
    return Component(COLONY_PCR_MASTER_MIX, round(volume_ul / 2, 2), stock="2X", final="1X")

colony_pcr_program

colony_pcr_program(
    polymerase: Polymerase = ONETAQ,
    *,
    annealing_temperature: float,
    amplicon_length: int,
    cycles: int | None = None,
) -> ThermocyclerProgram

Return the colony PCR program, which opens the cells before it denatures anything.

Source code in src/liulab_mbio/bench/pcr.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def colony_pcr_program(
    polymerase: Polymerase = ONETAQ,
    *,
    annealing_temperature: float,
    amplicon_length: int,
    cycles: int | None = None,
) -> ThermocyclerProgram:
    """Return the colony PCR program, which opens the cells before it denatures anything."""
    profile = polymerase.pcr
    lysis = Incubation("Lysis", profile.initial_denaturation_c, COLONY_LYSIS_SECONDS)
    return _program(
        polymerase,
        lysis,
        annealing_temperature=annealing_temperature,
        amplicon_length=amplicon_length,
        cycles=profile.cycles if cycles is None else cycles,
        hold_c=COLONY_HOLD_CELSIUS,
        title="Colony PCR",
    )

colony_pcr_reaction

colony_pcr_reaction(
    polymerase: Polymerase = ONETAQ,
    *,
    volume_ul: float = COLONY_PCR_VOLUME_UL,
    reactions: int = 1,
) -> ReactionTable

Return NEB's colony PCR reaction, which is a 2X master mix and the two primers.

The colony itself is no line of the table: it is picked with a toothpick and stirred into the tube until the solution clouds.

Raises:

Type Description
ValueError

If the primers and master mix do not fit volume_ul.

Source code in src/liulab_mbio/bench/pcr.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def colony_pcr_reaction(
    polymerase: Polymerase = ONETAQ,
    *,
    volume_ul: float = COLONY_PCR_VOLUME_UL,
    reactions: int = 1,
) -> ReactionTable:
    """Return NEB's colony PCR reaction, which is a 2X master mix and the two primers.

    The colony itself is no line of the table: it is picked with a toothpick and stirred into
    the tube until the solution clouds.

    Raises
    ------
    ValueError
        If the primers and master mix do not fit `volume_ul`.
    """
    components = [
        colony_pcr_master_mix_component(volume_ul),
        *(
            Component(
                f"{end} primer",
                round(volume_ul * polymerase.primer_nm / 1000 / PRIMER_STOCK_UM, 2),
                stock=f"{PRIMER_STOCK_UM:g} µM",
                final=f"{polymerase.primer_nm:g} nM",
            )
            for end in ("Forward", "Reverse")
        ),
    ]
    return _filled(components, volume_ul, title="Colony PCR", reactions=reactions)

pcr_program

pcr_program(
    polymerase: Polymerase = Q5,
    *,
    annealing_temperature: float,
    amplicon_length: int,
    cycles: int | None = None,
    title: str = "PCR",
) -> ThermocyclerProgram

Return the program for this polymerase, annealing temperature and amplicon.

Annealing and extension are combined into one step at the extension temperature once the annealing temperature reaches the polymerase's PcrProfile.two_step_celsius. cycles, when given, replaces the profile's own count.

Source code in src/liulab_mbio/bench/pcr.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def pcr_program(
    polymerase: Polymerase = Q5,
    *,
    annealing_temperature: float,
    amplicon_length: int,
    cycles: int | None = None,
    title: str = "PCR",
) -> ThermocyclerProgram:
    """Return the program for this polymerase, annealing temperature and amplicon.

    Annealing and extension are combined into one step at the extension temperature once the
    annealing temperature reaches the polymerase's `PcrProfile.two_step_celsius`. `cycles`, when
    given, replaces the profile's own count.
    """
    profile = polymerase.pcr
    initial = Incubation(
        "Initial denaturation",
        profile.initial_denaturation_c,
        profile.initial_denaturation_seconds,
    )
    return _program(
        polymerase,
        initial,
        annealing_temperature=annealing_temperature,
        amplicon_length=amplicon_length,
        cycles=profile.cycles if cycles is None else cycles,
        hold_c=profile.hold_c,
        title=title,
    )

pcr_reaction

pcr_reaction(
    polymerase: Polymerase = Q5,
    *,
    volume_ul: float = 50.0,
    reactions: int = 1,
    template_volume_ul: float = DNA_VOLUME_UL,
    title: str = "PCR",
) -> ReactionTable

Return the PCR NEB's protocol sets up for this polymerase, in its own order.

Volumes follow from the concentrations NEB's table gives: the buffer, the dNTP mix at DNTP_STOCK_MM, each primer at PRIMER_STOCK_UM, and the polymerase from its own tube. Template is added per tube.

Raises:

Type Description
ValueError

If the components do not fit volume_ul.

Source code in src/liulab_mbio/bench/pcr.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def pcr_reaction(
    polymerase: Polymerase = Q5,
    *,
    volume_ul: float = 50.0,
    reactions: int = 1,
    template_volume_ul: float = DNA_VOLUME_UL,
    title: str = "PCR",
) -> ReactionTable:
    """Return the PCR NEB's protocol sets up for this polymerase, in its own order.

    Volumes follow from the concentrations NEB's table gives: the buffer, the dNTP mix at
    `DNTP_STOCK_MM`, each primer at `PRIMER_STOCK_UM`, and the polymerase from its own tube.
    Template is added per tube.

    Raises
    ------
    ValueError
        If the components do not fit `volume_ul`.
    """
    profile = polymerase.pcr
    components = [
        Component(
            profile.buffer_name,
            round(volume_ul / profile.buffer_fold, 2),
            stock=f"{profile.buffer_fold:g}X",
            final="1X",
        ),
        Component(
            "dNTP mix",
            round(volume_ul * profile.dntp_um_each / (DNTP_STOCK_MM * 1000), 2),
            stock=f"{DNTP_STOCK_MM:g} mM each",
            final=f"{profile.dntp_um_each:g} µM each",
        ),
        *(
            Component(
                f"{end} primer",
                round(volume_ul * polymerase.primer_nm / 1000 / PRIMER_STOCK_UM, 2),
                stock=f"{PRIMER_STOCK_UM:g} µM",
                final=f"{polymerase.primer_nm:g} nM",
            )
            for end in ("Forward", "Reverse")
        ),
        Component("Template DNA", template_volume_ul, master_mix=False),
        Component(
            f"{polymerase.name} DNA Polymerase",
            round(volume_ul * profile.units_per_ul / profile.stock_units_ul, 2),
            stock=f"{profile.stock_units_ul:g} U/µL",
            final=f"{volume_ul * profile.units_per_ul:g} units",
        ),
    ]
    return _filled(components, volume_ul, title=title, reactions=reactions)

liulab_mbio.bench.gels

The ladder and the agarose percentage a range of bands takes.

Both are NEB's, through docs/research/primer-design-and-pcr.md.

agarose_percent

agarose_percent(bands_bp: tuple[int, ...]) -> float

Return the agarose percentage these bands resolve on.

Raises:

Type Description
ValueError

If no band is given.

Source code in src/liulab_mbio/bench/gels.py
53
54
55
56
57
58
59
60
61
def agarose_percent(bands_bp: tuple[int, ...]) -> float:
    """Return the agarose percentage these bands resolve on.

    Raises
    ------
    ValueError
        If no band is given.
    """
    return 2.0 if _largest(bands_bp) < _LADDER_LIMIT else 1.0

choose_ladder

choose_ladder(bands_bp: tuple[int, ...]) -> Ladder

Return the ladder covering these bands.

Raises:

Type Description
ValueError

If no band is given.

Source code in src/liulab_mbio/bench/gels.py
42
43
44
45
46
47
48
49
50
def choose_ladder(bands_bp: tuple[int, ...]) -> Ladder:
    """Return the ladder covering these bands.

    Raises
    ------
    ValueError
        If no band is given.
    """
    return LADDER_100_BP if _largest(bands_bp) < _LADDER_LIMIT else LADDER_1_KB_PLUS

liulab_mbio.bench.validation

What confirms a clone: a colony PCR reading its junctions, and Sanger reads across them.

The distances are the ones docs/research/primer-design-and-pcr.md sets out for colony PCR bands and for sequencing primers.

Clone dataclass

One plasmid a colony may carry, and the bands a colony PCR gives from it.

Source code in src/liulab_mbio/bench/validation.py
49
50
51
52
53
54
@dataclass(frozen=True, slots=True)
class Clone:
    """One plasmid a colony may carry, and the bands a colony PCR gives from it."""

    name: str
    bands_bp: tuple[int, ...]

ColonyCheck dataclass

A colony PCR reading across an assembly's junctions.

Parameters:

Name Type Description Default
primers tuple[Primer, ...]

The flanking pair first, then one junction primer per insert where they were asked for.

required
reports tuple[PrimerReport, ...]

What each primer scored on the assembled plasmid.

required
clones tuple[Clone, ...]

The candidates a colony can hold: the correct one, the empty vector, and one carrying each insert the other way round.

required
annealing_temperature float

By the polymerase's rule over the two lowest Tms of the set, °C.

required
extension_seconds int

For the largest band any candidate gives.

required
ladder Ladder

Chosen for that band range.

required
agarose_percent Ladder

Chosen for that band range.

required
Source code in src/liulab_mbio/bench/validation.py
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@dataclass(frozen=True, slots=True)
class ColonyCheck:
    """A colony PCR reading across an assembly's junctions.

    Parameters
    ----------
    primers
        The flanking pair first, then one junction primer per insert where they were asked for.
    reports
        What each primer scored on the assembled plasmid.
    clones
        The candidates a colony can hold: the correct one, the empty vector, and one carrying
        each insert the other way round.
    annealing_temperature
        By the polymerase's rule over the two lowest Tms of the set, °C.
    extension_seconds
        For the largest band any candidate gives.
    ladder, agarose_percent
        Chosen for that band range.
    """

    primers: tuple[Primer, ...]
    reports: tuple[PrimerReport, ...]
    clones: tuple[Clone, ...]
    annealing_temperature: float
    extension_seconds: int
    ladder: Ladder
    agarose_percent: float

    @property
    def gel(self) -> Gel:
        """The gel these clones should give, one lane each."""
        return Gel(
            self.ladder,
            tuple(Lane(clone.name, clone.bands_bp) for clone in self.clones),
            title="Colony PCR",
        )

    @property
    def tells_orientation(self) -> bool:
        """Whether the gel separates every reversed insert from the correct clone.

        Two vector primers flanking the inserts never do: they amplify them whichever way round
        they sit. A junction primer does, unless the two vector primers happen to lie the same
        distance from their own junctions.
        """
        correct = next(
            (clone.bands_bp for clone in self.clones if clone.name == CORRECT_CLONE), None
        )
        turned = [clone.bands_bp for clone in self.clones if clone.name.startswith(REVERSED_CLONE)]
        return bool(turned) and all(lane != correct for lane in turned)

gel property

gel: Gel

The gel these clones should give, one lane each.

tells_orientation property

tells_orientation: bool

Whether the gel separates every reversed insert from the correct clone.

Two vector primers flanking the inserts never do: they amplify them whichever way round they sit. A junction primer does, unless the two vector primers happen to lie the same distance from their own junctions.

SangerRead dataclass

A sequencing primer and the read it has to give.

Parameters:

Name Type Description Default
primer Primer

Reading towards the junction it sits outside.

required
distance_bp int

From its 3' end to that junction.

required
read_bp int

From its 3' end to the far junction, which is what the read must cover for the insert to be confirmed at both ends.

required
Source code in src/liulab_mbio/bench/validation.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
@dataclass(frozen=True, slots=True)
class SangerRead:
    """A sequencing primer and the read it has to give.

    Parameters
    ----------
    primer
        Reading towards the junction it sits outside.
    distance_bp
        From its 3' end to that junction.
    read_bp
        From its 3' end to the far junction, which is what the read must cover for the insert to
        be confirmed at both ends.
    """

    primer: Primer
    distance_bp: int
    read_bp: int

colony_pcr_check

colony_pcr_check(
    product: SequenceRecord,
    junctions: Sequence[int],
    *,
    vector: SequenceRecord,
    primers: tuple[Primer, ...] | None = None,
    insert_primer: bool = False,
    flank: int = COLONY_FLANK,
    junction_offset: int = JUNCTION_OFFSET,
    polymerase: Polymerase = ONETAQ,
    thresholds: Thresholds = THRESHOLDS_FOR["colony PCR"],
) -> ColonyCheck

Return what a colony PCR across these junctions should show.

An assembly of n inserts has n + 1 junctions and the inserts are the spans between them, so a product whose inserts cross the origin is rotated first. Without primers, a pair is designed in the vector flank bases outside the first and the last junction; insert_primer adds one primer per insert, annealing junction_offset bases into it. Those are what tell a reversed insert apart and what put a band of their own on each junction. Each candidate plasmid is amplified on its own, so the bands are simulated rather than derived.

Raises:

Type Description
ValueError

If the junctions are not two or more separate positions inside the product, if fewer than two primers are given, if an insert is too short for a junction primer, or if the primers amplify nothing at all.

Source code in src/liulab_mbio/bench/validation.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def colony_pcr_check(
    product: SequenceRecord,
    junctions: Sequence[int],
    *,
    vector: SequenceRecord,
    primers: tuple[Primer, ...] | None = None,
    insert_primer: bool = False,
    flank: int = COLONY_FLANK,
    junction_offset: int = JUNCTION_OFFSET,
    polymerase: Polymerase = ONETAQ,
    thresholds: Thresholds = THRESHOLDS_FOR["colony PCR"],
) -> ColonyCheck:
    """Return what a colony PCR across these junctions should show.

    An assembly of n inserts has n + 1 junctions and the inserts are the spans between them, so
    a product whose inserts cross the origin is rotated first. Without `primers`, a pair is
    designed in the vector `flank` bases outside the first and the last junction;
    `insert_primer` adds one primer per insert, annealing `junction_offset` bases into it. Those
    are what tell a reversed insert apart and what put a band of their own on each junction.
    Each candidate plasmid is amplified on its own, so the bands are simulated rather than
    derived.

    Raises
    ------
    ValueError
        If the junctions are not two or more separate positions inside the product, if fewer
        than two primers are given, if an insert is too short for a junction primer, or if the
        primers amplify nothing at all.
    """
    places = _junction_span(junctions, len(product))
    start, end = places[0], places[-1]
    inserts = tuple(pairwise(places))
    chosen = (
        list(primers)
        if primers is not None
        else list(_flanking_pair(product, start, end, flank, polymerase, thresholds))
    )
    if insert_primer:
        chosen.extend(
            _junction_primer(product, first, last, junction_offset, polymerase, thresholds, name)
            for name, (first, last) in zip(
                _numbered(_JUNCTION_PRIMER, inserts), inserts, strict=True
            )
        )
    if len(chosen) < 2:
        raise ValueError("a colony PCR needs at least two primers")
    placed = tuple(chosen)
    candidates = [(CORRECT_CLONE, product), (EMPTY_CLONE, vector)]
    candidates += [
        (name, _reversed_insert(product, first, last))
        for name, (first, last) in zip(_numbered(REVERSED_CLONE, inserts), inserts, strict=True)
    ]
    clones = tuple(Clone(name, _bands(placed, record, thresholds)) for name, record in candidates)
    sizes = tuple(sorted({bp for clone in clones for bp in clone.bands_bp}))
    if not sizes:
        raise ValueError("these primers amplify nothing on any of the candidate plasmids")
    reports = tuple(
        evaluate_primer(primer, product, polymerase=polymerase, thresholds=thresholds)
        for primer in placed
    )
    tms = sorted(report["tm"].value for report in reports)
    return ColonyCheck(
        placed,
        reports,
        clones,
        polymerase.annealing_temperature(tms[0], tms[1]),
        polymerase.extension_seconds(max(sizes)),
        choose_ladder(sizes),
        agarose_percent(sizes),
    )

sanger_primers

sanger_primers(
    product: SequenceRecord,
    junctions: Sequence[int],
    *,
    flank: int = SANGER_FLANK,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS_FOR["sequencing"],
) -> tuple[SangerRead, SangerRead]

Return a sequencing primer reading into the inserts from outside the first and last junction.

Every 3' end lands at least flank bases from its own junction, near enough for the read to be clean there, and SangerRead.read_bp is how far it must carry to reach the far junction. A provider whose reads are shorter needs a primer inside the inserts as well.

Raises:

Type Description
ValueError

If the junctions are not two or more separate positions inside the product, or no primer fits outside the first or the last.

Source code in src/liulab_mbio/bench/validation.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def sanger_primers(
    product: SequenceRecord,
    junctions: Sequence[int],
    *,
    flank: int = SANGER_FLANK,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS_FOR["sequencing"],
) -> tuple[SangerRead, SangerRead]:
    """Return a sequencing primer reading into the inserts from outside the first and last junction.

    Every 3' end lands at least `flank` bases from its own junction, near enough for the read to
    be clean there, and `SangerRead.read_bp` is how far it must carry to reach the far junction.
    A provider whose reads are shorter needs a primer inside the inserts as well.

    Raises
    ------
    ValueError
        If the junctions are not two or more separate positions inside the product, or no primer
        fits outside the first or the last.
    """
    places = _junction_span(junctions, len(product))
    start, end = places[0], places[-1]
    longest = _longest_annealing(thresholds)
    forward = design_primer(
        product,
        start - flank - longest,
        Strand.FORWARD,
        name="Sequencing forward",
        polymerase=polymerase,
        thresholds=thresholds,
    )
    reverse = design_primer(
        product,
        end + flank + longest,
        Strand.REVERSE,
        name="Sequencing reverse",
        polymerase=polymerase,
        thresholds=thresholds,
    )
    length = len(product)
    ahead = forward.binding_sites[0].end
    behind = reverse.binding_sites[0].start
    return (
        SangerRead(forward, (start - ahead) % length, (end - ahead) % length),
        SangerRead(reverse, (behind - end) % length, (behind - start) % length),
    )

liulab_mbio.bench.inactivation

An enzyme's heat inactivation, as its supplier gives it.

heat_inactivation

heat_inactivation(
    enzyme: Enzyme,
) -> ThermocyclerProgram | None

Return the supplier's heat inactivation for this enzyme, or None where it gives none.

This is not the 60 °C end soak a Golden Gate program ends with, which is a digest.

Source code in src/liulab_mbio/bench/inactivation.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
def heat_inactivation(enzyme: Enzyme) -> ThermocyclerProgram | None:
    """Return the supplier's heat inactivation for this enzyme, or ``None`` where it gives none.

    This is not the 60 °C end soak a Golden Gate program ends with, which is a digest.
    """
    celsius, minutes = enzyme.heat_inactivation_celsius, enzyme.heat_inactivation_minutes
    if celsius is None or minutes is None:
        return None
    return ThermocyclerProgram(
        (Stage((Incubation("Heat inactivation", float(celsius), minutes * 60),)),),
        title=f"{enzyme.name} heat inactivation",
    )

liulab_mbio.bench.phenotype

What a clone is expected to show, read off the product's own features.

What drives the inserts, whether anything should be translated, and how a plate reads: a protocol states these from the features rather than a person asserting them.

Phenotype dataclass

What the product says about itself, read off its own features.

Parameters:

Name Type Description Default
insert tuple[int, int]

The span the inserts occupy in the product, between the first junction and the last.

required
coding Feature | None

The longest coding sequence in that span, or None when it annotates none.

required
promoter Feature | None

The promoter nearest the insert on the promoter's own reading direction, or None.

required
gap_bp int

Bases between that promoter and the insert.

required
driven bool

Whether that promoter reads along the strand the insert is coded on.

required
ribosome_binding_site bool

Whether one is annotated between that promoter and the insert.

required
reporter Feature | None

The vector coding sequence the insertion interrupts, or None.

required
marker Feature | None

The vector's selection marker, or None when it annotates none this package knows.

required
Source code in src/liulab_mbio/bench/phenotype.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@dataclass(frozen=True, slots=True)
class Phenotype:
    """What the product says about itself, read off its own features.

    Parameters
    ----------
    insert
        The span the inserts occupy in the product, between the first junction and the last.
    coding
        The longest coding sequence in that span, or ``None`` when it annotates none.
    promoter
        The promoter nearest the insert on the promoter's own reading direction, or ``None``.
    gap_bp
        Bases between that promoter and the insert.
    driven
        Whether that promoter reads along the strand the insert is coded on.
    ribosome_binding_site
        Whether one is annotated between that promoter and the insert.
    reporter
        The vector coding sequence the insertion interrupts, or ``None``.
    marker
        The vector's selection marker, or ``None`` when it annotates none this package knows.
    """

    insert: tuple[int, int]
    coding: Feature | None
    promoter: Feature | None
    gap_bp: int
    driven: bool
    ribosome_binding_site: bool
    reporter: Feature | None
    marker: Feature | None

    @property
    def expressed(self) -> bool:
        """Whether the product should make the insert's protein."""
        return self.coding is not None and self.driven and self.ribosome_binding_site

    @property
    def blue_white(self) -> bool:
        """Whether X-gal and IPTG tell a correct clone from an empty vector.

        True when the insertion interrupts a lacZ fragment, which is then not there to
        complement the host's own.
        """
        return self.reporter is not None and self.reporter.name.lower().startswith("lacz")

    @property
    def antibiotic(self) -> str:
        """What to select transformants on, or an empty string when the marker is unknown."""
        if self.marker is None:
            return ""
        return SELECTION.get(self.marker.name.lower(), "")

antibiotic property

antibiotic: str

What to select transformants on, or an empty string when the marker is unknown.

blue_white property

blue_white: bool

Whether X-gal and IPTG tell a correct clone from an empty vector.

True when the insertion interrupts a lacZ fragment, which is then not there to complement the host's own.

expressed property

expressed: bool

Whether the product should make the insert's protein.

read_phenotype

read_phenotype(
    product: SequenceRecord,
    insert: tuple[int, int],
    *,
    vector: SequenceRecord,
    span: tuple[int, int],
) -> Phenotype

Read what the product says about itself off its own features.

Parameters:

Name Type Description Default
product SequenceRecord

The plasmid a clone carries.

required
insert tuple[int, int]

Where the inserts lie in it, from the first junction to the last.

required
vector SequenceRecord

The plasmid they went into.

required
span tuple[int, int]

The vector bases they replace.

required
Source code in src/liulab_mbio/bench/phenotype.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def read_phenotype(
    product: SequenceRecord,
    insert: tuple[int, int],
    *,
    vector: SequenceRecord,
    span: tuple[int, int],
) -> Phenotype:
    """Read what the product says about itself off its own features.

    Parameters
    ----------
    product
        The plasmid a clone carries.
    insert
        Where the inserts lie in it, from the first junction to the last.
    vector
        The plasmid they went into.
    span
        The vector bases they replace.
    """
    first, last = insert
    coding = _coding(product, first, last)
    promoter, gap = _promoter(product, first, last)
    return Phenotype(
        (first, last),
        coding,
        promoter,
        gap,
        promoter is not None and coding is not None and promoter.strand == coding.strand,
        _ribosome_binding_site(product, promoter, first, last),
        _interrupted(vector, span),
        _marker(vector),
    )

liulab_mbio.bench.oligos

The primer order sheet: every oligo a design asks for, as a table to order from.

primer_sheet writes it as a file, and oligo_row makes one row of a protocol's own sheet.

oligo_row

oligo_row(
    report: PrimerReport,
    *,
    purpose: str,
    thresholds: Thresholds,
) -> Oligo

Return one oligo as a row of a protocol's order sheet, carrying its verdict.

Parameters:

Name Type Description Default
report PrimerReport

What the oligo scored, the primer included.

required
purpose str

The title of the step that uses it.

required
thresholds Thresholds

What it was judged by, so a row prints the band beside each value that missed it.

required
Source code in src/liulab_mbio/bench/oligos.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def oligo_row(report: PrimerReport, *, purpose: str, thresholds: Thresholds) -> Oligo:
    """Return one oligo as a row of a protocol's order sheet, carrying its verdict.

    Parameters
    ----------
    report
        What the oligo scored, the primer included.
    purpose
        The title of the step that uses it.
    thresholds
        What it was judged by, so a row prints the band beside each value that missed it.
    """
    return Oligo(
        report.primer.name,
        report.primer.sequence,
        purpose=purpose,
        tm_c=round(report["tm"].value, 1),
        stock=f"{PRIMER_STOCK_UM:g} µM",
        status=report.status,
        checks=_fired(report, thresholds),
    )

primer_sheet

primer_sheet(reports: Sequence[PrimerReport]) -> str

Return these oligos as a tab-separated sheet, one row each, in the order given.

The columns are SHEET_COLUMNS: the name to order it under, the sequence 5' to 3', its length, and the Tm of the part that anneals.

Source code in src/liulab_mbio/bench/oligos.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def primer_sheet(reports: Sequence[PrimerReport]) -> str:
    """Return these oligos as a tab-separated sheet, one row each, in the order given.

    The columns are `SHEET_COLUMNS`: the name to order it under, the sequence 5' to 3', its
    length, and the Tm of the part that anneals.
    """
    rows = ["\t".join(SHEET_COLUMNS)]
    for report in reports:
        primer = report.primer
        rows.append(
            "\t".join(
                (
                    primer.name,
                    primer.sequence,
                    str(len(primer.sequence)),
                    f"{report['tm'].value:.1f}",
                )
            )
        )
    return "\n".join(rows) + "\n"

liulab_mbio.bench.steps

The protocol steps any cloning pipeline reuses, each built from plain facts.

A pipeline runs these in its own order around its own steps, and passes its own notes where it has something of its own to say; they follow the step's. Every sentence about the phenotype is read off liulab_mbio.bench.phenotype.

cleanup_step

cleanup_step(*, notes: Sequence[str] = ()) -> Step

Return the spin-column cleanup of every amplicon, carrying the caller's own notes.

Source code in src/liulab_mbio/bench/steps.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def cleanup_step(*, notes: Sequence[str] = ()) -> Step:
    """Return the spin-column cleanup of every amplicon, carrying the caller's own notes."""
    return Step(
        "Purify every amplicon",
        instructions=(
            "Run each reaction over a spin column and elute in the smallest volume the kit allows.",
        ),
        expected=("Clean DNA, free of polymerase, primers and dNTPs.",),
        notes=tuple(notes),
        troubleshooting=(
            Troubleshooting(
                "Low recovery",
                "Elute twice through the same column, or pool two reactions before purifying.",
            ),
        ),
    )

colony_pcr_step

colony_pcr_step(
    check: ColonyCheck,
    *,
    junctions: int,
    troubleshooting: Sequence[Troubleshooting] = (),
) -> Step

Return the colony PCR screen, saying which band means what.

Parameters:

Name Type Description Default
check ColonyCheck

The colony PCR, its expected clones included.

required
junctions int

How many junctions it reads.

required
troubleshooting Sequence[Troubleshooting]

The caller's own, after the step's.

()
Source code in src/liulab_mbio/bench/steps.py
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
def colony_pcr_step(
    check: ColonyCheck,
    *,
    junctions: int,
    troubleshooting: Sequence[Troubleshooting] = (),
) -> Step:
    """Return the colony PCR screen, saying which band means what.

    Parameters
    ----------
    check
        The colony PCR, its expected clones included.
    junctions
        How many junctions it reads.
    troubleshooting
        The caller's own, after the step's.
    """
    sizes = tuple(bp for clone in check.clones for bp in clone.bands_bp)
    expected = [
        f"Each of the {junctions} junctions is read: the flanking pair crosses them all, and "
        "each junction primer stops inside its own insert.",
        *(
            f"{clone.name}: {', '.join(f'{bp} bp' for bp in clone.bands_bp) or 'no band'}."
            for clone in check.clones
        ),
    ]
    expected.append(
        "A reversed insert is told from a correct one, because the two vector primers sit at "
        "different distances from their own junctions."
        if check.tells_orientation
        else "These primers cannot tell a reversed insert from a correct one."
    )
    return Step(
        COLONY_PCR_TITLE,
        instructions=(
            "Touch a well-separated colony with a sterile toothpick and stir it into the "
            "tube until the liquid clouds.",
            "Streak the same toothpick onto a numbered plate, so the clone survives the PCR.",
            f"Run the program below and load 5 µL on a {check.agarose_percent:g}% gel.",
        ),
        tables=(colony_pcr_reaction(),),
        programs=(
            colony_pcr_program(
                annealing_temperature=check.annealing_temperature,
                amplicon_length=max(sizes),
            ),
        ),
        gels=(check.gel,),
        expected=tuple(expected),
        notes=("The long first step at 94 °C lyses the cells; there is no purified template.",),
        troubleshooting=(
            Troubleshooting(
                "No band in any lane",
                "The colony was too much material: touch a smaller one, or dilute it.",
            ),
            *troubleshooting,
        ),
    )

dpni_step

dpni_step(
    pcrs: Sequence[str],
    templates: Sequence[tuple[str, int]],
    *,
    notes: Sequence[str] = (),
) -> Step

Return the DpnI digest that takes the plasmid template away, so it cannot transform.

Parameters:

Name Type Description Default
pcrs Sequence[str]

The PCRs to digest, by name.

required
templates Sequence[tuple[str, int]]

The plasmid each was amplified from, and the Dam sites it carries.

required
notes Sequence[str]

The caller's own, after the step's.

()
Source code in src/liulab_mbio/bench/steps.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def dpni_step(
    pcrs: Sequence[str],
    templates: Sequence[tuple[str, int]],
    *,
    notes: Sequence[str] = (),
) -> Step:
    """Return the DpnI digest that takes the plasmid template away, so it cannot transform.

    Parameters
    ----------
    pcrs
        The PCRs to digest, by name.
    templates
        The plasmid each was amplified from, and the Dam sites it carries.
    notes
        The caller's own, after the step's.
    """
    counted = ", ".join(f"{name} ({sites} Dam sites)" for name, sites in templates)
    return Step(
        "Digest the plasmid template with DpnI",
        instructions=(
            *(f"Add {DPNI_UNITS} units of DpnI to the {name} PCR and mix." for name in pcrs),
            f"Incubate at {DPNI_CELSIUS:g} °C for {DPNI_SECONDS // 60} minutes.",
        ),
        timers=(Timer("DpnI digest", DPNI_SECONDS),),
        expected=(
            "Nothing visible. The digest shows up later as fewer colonies carrying the "
            "template plasmid.",
        ),
        notes=(
            f"DpnI cuts GATC only where Dam has methylated it, so it cuts {counted} and "
            "leaves the PCR product, which carries no methylation.",
            *notes,
        ),
        troubleshooting=(
            Troubleshooting(
                "Many colonies on the no-insert control",
                "The template survived: digest longer, or use more DpnI.",
            ),
        ),
    )

gel_step

gel_step(amplicons: Sequence[tuple[str, int]]) -> Step

Return the gel that checks every PCR before anything is spent on them.

amplicons gives each amplicon's name and its length in base pairs, one lane each.

Source code in src/liulab_mbio/bench/steps.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def gel_step(amplicons: Sequence[tuple[str, int]]) -> Step:
    """Return the gel that checks every PCR before anything is spent on them.

    `amplicons` gives each amplicon's name and its length in base pairs, one lane each.
    """
    sizes = tuple(length_bp for _, length_bp in amplicons)
    percent = agarose_percent(sizes)
    return Step(
        "Check the PCRs on a gel",
        instructions=(
            f"Pour a {percent:g}% agarose gel.",
            "Load 5 µL of each reaction beside the ladder.",
            "Run until the dye front is two thirds down the gel.",
        ),
        gels=(
            Gel(
                choose_ladder(sizes),
                tuple(Lane(name, (length_bp,)) for name, length_bp in amplicons),
                title="PCR products",
            ),
        ),
        expected=tuple(f"{name}: one band at {length_bp} bp." for name, length_bp in amplicons),
        troubleshooting=(
            Troubleshooting(
                "A smear or an extra band",
                "Gel-purify the band of the right size; a wrong template in the assembly gives "
                "wrong clones.",
            ),
        ),
    )

listed

listed(items: Sequence[str]) -> str

Join names the way a sentence does, with and before the last.

Examples:

>>> listed(("GFP", "Linker", "Tag"))
'GFP, Linker and Tag'
Source code in src/liulab_mbio/bench/steps.py
65
66
67
68
69
70
71
72
73
74
75
def listed(items: Sequence[str]) -> str:
    """Join names the way a sentence does, with `and` before the last.

    Examples
    --------
    >>> listed(("GFP", "Linker", "Tag"))
    'GFP, Linker and Tag'
    """
    if len(items) < 3:
        return " and ".join(items)
    return f"{', '.join(items[:-1])} and {items[-1]}"

pcr_step

pcr_step(
    name: str,
    template: str,
    length_bp: int,
    *,
    polymerase: Polymerase,
    annealing_temperature: float,
    extension_seconds: int | None,
    cycles: int | None = None,
    notes: Sequence[str] = (),
) -> Step

Return the step that makes one amplicon by PCR.

Parameters:

Name Type Description Default
name str

What the amplicon is called, which titles the step, its reaction and its program.

required
template str

What its template is called.

required
length_bp int

The amplicon's length.

required
polymerase Polymerase

The PCR's, as the primer pair's report gives them.

required
annealing_temperature Polymerase

The PCR's, as the primer pair's report gives them.

required
extension_seconds Polymerase

The PCR's, as the primer pair's report gives them.

required
cycles int | None

Replaces the polymerase profile's own count.

None
notes Sequence[str]

The caller's own.

()
Source code in src/liulab_mbio/bench/steps.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def pcr_step(
    name: str,
    template: str,
    length_bp: int,
    *,
    polymerase: Polymerase,
    annealing_temperature: float,
    extension_seconds: int | None,
    cycles: int | None = None,
    notes: Sequence[str] = (),
) -> Step:
    """Return the step that makes one amplicon by PCR.

    Parameters
    ----------
    name
        What the amplicon is called, which titles the step, its reaction and its program.
    template
        What its template is called.
    length_bp
        The amplicon's length.
    polymerase, annealing_temperature, extension_seconds
        The PCR's, as the primer pair's report gives them.
    cycles
        Replaces the polymerase profile's own count.
    notes
        The caller's own.
    """
    return Step(
        pcr_title(name),
        instructions=(
            "Thaw the buffer, dNTPs and primers on ice, then vortex and spin them down.",
            f"Mix the master mix and put it in each tube, then add the {template} template.",
            f"Run the program below: {annealing_temperature:g} °C annealing and "
            f"{extension_seconds} s extension for a {length_bp} bp product.",
        ),
        cautions=("Keep the polymerase on ice.",),
        tables=(pcr_reaction(polymerase, title=f"{name} PCR"),),
        programs=(
            pcr_program(
                polymerase,
                annealing_temperature=annealing_temperature,
                amplicon_length=length_bp,
                cycles=cycles,
                title=f"{name} PCR",
            ),
        ),
        expected=(f"One band at {length_bp} bp.",),
        notes=tuple(notes),
        troubleshooting=(
            Troubleshooting(
                "No band",
                f"Drop the annealing temperature by 3 °C and check the {template} template is "
                "there.",
            ),
            Troubleshooting(
                "Several bands",
                "Raise the annealing temperature, or gel-purify the band of the right size.",
            ),
        ),
    )

pcr_title

pcr_title(name: str) -> str

Return the title of the step that amplifies name, which its oligos name as their purpose.

Source code in src/liulab_mbio/bench/steps.py
78
79
80
def pcr_title(name: str) -> str:
    """Return the title of the step that amplifies `name`, which its oligos name as their purpose."""
    return f"Amplify {name}"

phenotype_sentences

phenotype_sentences(
    phenotype: Phenotype, inserts: Sequence[str]
) -> tuple[str, ...]

Return what the product's own features say about the insert and about the plate.

inserts names the inserts for a product that annotates no coding sequence among them.

Source code in src/liulab_mbio/bench/steps.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
def phenotype_sentences(phenotype: Phenotype, inserts: Sequence[str]) -> tuple[str, ...]:
    """Return what the product's own features say about the insert and about the plate.

    `inserts` names the inserts for a product that annotates no coding sequence among them.
    """
    coding = _coding_name(phenotype, inserts)
    lines: list[str] = []
    if phenotype.promoter is not None:
        way = (
            f"reads on the same strand as {phenotype.promoter.name}, "
            f"{phenotype.gap_bp} bp downstream of it"
            if phenotype.driven
            else (
                f"reads on the opposite strand from {phenotype.promoter.name}, "
                f"{phenotype.gap_bp} bp away, so that promoter does not transcribe it"
            )
        )
        lines.append(f"{coding} {way}.")
    site = "is" if phenotype.ribosome_binding_site else "is no"
    lines.append(
        f"There {site} ribosome binding site annotated ahead of {coding}, so the clone "
        f"{'may make' if phenotype.expressed else 'is not expected to make'} its protein."
    )
    if phenotype.blue_white and phenotype.reporter is not None:
        lines.append(
            f"The insertion interrupts {phenotype.reporter.name}, so correct clones are white "
            "and empty vector is blue on X-gal and IPTG."
        )
    return tuple(lines)

quantify_step

quantify_step(amounts: Sequence[Amount]) -> Step

Return the step that measures what the next reaction is about to take.

Source code in src/liulab_mbio/bench/steps.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
def quantify_step(amounts: Sequence[Amount]) -> Step:
    """Return the step that measures what the next reaction is about to take."""
    wanted = tuple(
        f"{amount.name}: {amount.pmol:g} pmol is {amount.nanograms:g} ng, so "
        f"{amount.nanograms / DNA_VOLUME_UL:.0f} ng/µL or more fits in {DNA_VOLUME_UL:g} µL."
        for amount in amounts
    )
    return Step(
        "Measure every concentration",
        instructions=(
            "Measure each purified amplicon by A260 or with a fluorometer.",
            "Work out the volume that carries the picomoles the next table asks for.",
        ),
        expected=wanted,
        notes=(
            "Picomoles, not nanograms: the shorter fragment weighs less at the same molar "
            "ratio. Mass to moles here is NEBioCalculator's 36.04 + 615.94 per base pair, "
            "which is about 5% off the 650 Da per base pair of NEB's manuals.",
        ),
        troubleshooting=(
            Troubleshooting(
                "Too dilute to fit in the reaction",
                "Concentrate the amplicon, or scale the reaction up.",
            ),
        ),
    )

sequencing_step

sequencing_step(
    reads: Sequence[SangerRead],
    *,
    junctions: Sequence[str],
    inserts: Sequence[str],
) -> Step

Return the sequencing that confirms the junctions, which is the only thing that settles it.

Parameters:

Name Type Description Default
reads Sequence[SangerRead]

The sequencing primers and the reads they have to give.

required
junctions Sequence[str]

The bases each junction spells.

required
inserts Sequence[str]

What the inserts are called.

required
Source code in src/liulab_mbio/bench/steps.py
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def sequencing_step(
    reads: Sequence[SangerRead], *, junctions: Sequence[str], inserts: Sequence[str]
) -> Step:
    """Return the sequencing that confirms the junctions, which is the only thing that settles it.

    Parameters
    ----------
    reads
        The sequencing primers and the reads they have to give.
    junctions
        The bases each junction spells.
    inserts
        What the inserts are called.
    """
    lengths = tuple(
        f"{read.primer.name} anneals {read.distance_bp} bp from its own junction and has to "
        f"read {read.read_bp} bp to cover the far one."
        for read in reads
    )
    return Step(
        SEQUENCING_TITLE,
        instructions=(
            "Miniprep two or three colonies that read as correct.",
            "Send each with both sequencing primers.",
            "Check the read across every junction and the whole of each insert.",
        ),
        expected=(
            *lengths,
            f"The junctions read as {listed(junctions)}, and the parts match {listed(inserts)}.",
        ),
        notes=(
            "NEB asks for the assembly to be confirmed by sequencing across the junctions "
            "whatever the screen said.",
            "A provider whose read is shorter than the lengths above needs a further primer "
            "inside the inserts.",
        ),
        troubleshooting=(
            Troubleshooting(
                "The read starts too close to the junction",
                "Move the primer further out; the first bases after a primer are unreadable.",
            ),
        ),
    )

transform_step

transform_step(
    host: str,
    phenotype: Phenotype,
    *,
    inserts: Sequence[str],
    colonies: str,
) -> Step

Return the transformation and plating, with the colour the plate should show.

Parameters:

Name Type Description Default
host str

The competent strain.

required
phenotype Phenotype

What the product says about itself.

required
inserts Sequence[str]

What the inserts are called, for a product that annotates no coding sequence among them.

required
colonies str

How many colonies to expect, as a sentence: a count belongs to the pipeline's reaction.

required
Source code in src/liulab_mbio/bench/steps.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def transform_step(
    host: str, phenotype: Phenotype, *, inserts: Sequence[str], colonies: str
) -> Step:
    """Return the transformation and plating, with the colour the plate should show.

    Parameters
    ----------
    host
        The competent strain.
    phenotype
        What the product says about itself.
    inserts
        What the inserts are called, for a product that annotates no coding sequence among them.
    colonies
        How many colonies to expect, as a sentence: a count belongs to the pipeline's reaction.
    """
    expected = [colonies]
    if phenotype.blue_white and phenotype.reporter is not None:
        expected.append(
            f"Correct clones are white and empty vector is blue: the insertion interrupts "
            f"{phenotype.reporter.name}, which is then not there to complete the host's own."
        )
    notes = [
        f"The plate reads colour only with an alpha-complementing host, such as {host}. "
        "A host that cannot complement gives white colonies whatever the clone carries."
        if phenotype.blue_white
        else "Colour does not report this insertion; screen every colony by PCR.",
    ]
    if not phenotype.expressed:
        notes.append(_expression_note(phenotype, inserts))
    return Step(
        "Transform and plate",
        instructions=(
            f"Thaw {CELLS_UL:g} µL of {host} on ice for {THAW_SECONDS // 60} minutes.",
            f"Add {ASSEMBLY_UL:g} µL of the assembly and flick the tube four or five times.",
            f"Hold on ice for {ICE_SECONDS // 60} minutes.",
            f"Heat shock at {HEAT_SHOCK_CELSIUS:g} °C for {HEAT_SHOCK_SECONDS} seconds.",
            f"Return to ice for {RECOVER_SECONDS // 60} minutes.",
            f"Add {OUTGROWTH_UL:g} µL of outgrowth medium and shake at "
            f"{OUTGROWTH_CELSIUS:g} °C for {OUTGROWTH_SECONDS // 60} minutes at 250 rpm.",
            f"Spread {PLATE_UL:g} µL of a 1:{PLATE_DILUTION} dilution on a warmed plate and "
            "grow overnight at 37 °C.",
        ),
        cautions=("Competent cells die if they warm up; keep them on ice until the shock.",),
        timers=(
            Timer("On ice", ICE_SECONDS),
            Timer("Heat shock", HEAT_SHOCK_SECONDS),
            Timer("Outgrowth", OUTGROWTH_SECONDS),
        ),
        expected=tuple(expected),
        notes=tuple(notes),
        troubleshooting=(
            Troubleshooting(
                "No colonies",
                "Check the antibiotic and the cells' efficiency, and plate the rest of the "
                "outgrowth.",
            ),
            Troubleshooting(
                "A lawn",
                "Plate a smaller volume or a greater dilution next time.",
            ),
        ),
    )

Golden Gate

plan_assembly is the way in, and Plan.write puts the product, the primer sheet and the protocol in one directory. The inserts are varargs, so Plan.inserts is a tuple — plural, because one reaction joins as many inserts as the overhangs allow.

liulab_mbio.goldengate

Golden Gate cloning: enzyme and overhang choice, assembly, and the reaction that joins it.

plan_assembly is the way in: it takes a vector and any number of inserts and runs the whole design, and Plan.write puts the product, the primer sheet and the bench protocol in one directory. The submodules are the steps it is made of -- design, assembly, bench, oligos and steps -- plus ligase, which reads a ligase fidelity matrix the user holds. bench is the assembly reaction and its cycling, and steps the protocol's own steps and their order; what any pipeline shares is liulab_mbio.bench. design and ligase are not re-exported here: import them by module.

liulab_mbio.goldengate.plan

One Golden Gate experiment, planned from a vector and the inserts that go round it.

plan_assembly joins as many inserts as the overhangs allow, given in the order they go round the product, and runs the whole design: choose the enzyme, design the overhangs, simulate the PCRs and the ligation, work out the bench quantities, and design the colony PCR and sequencing that validate the clone. Plan.write puts the three things a bench needs in one directory -- the annotated product, a primer order sheet, and the interactive HTML protocol.

Every number the protocol prints is computed here or by the modules this one calls. What the protocol says about the phenotype -- what drives the inserts, whether anything should be translated, and how a plate reads -- is liulab_mbio.bench.phenotype, read off the product's own features.

Files dataclass

The three files a plan writes.

Parameters:

Name Type Description Default
product Path

The annotated product, as a SnapGene .dna file.

required
primers Path

Every designed oligo, as a tab-separated sheet to order from.

required
protocol Path

The interactive bench protocol, as one self-contained HTML page.

required
Source code in src/liulab_mbio/goldengate/plan.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
@dataclass(frozen=True, slots=True)
class Files:
    """The three files a plan writes.

    Parameters
    ----------
    product
        The annotated product, as a SnapGene ``.dna`` file.
    primers
        Every designed oligo, as a tab-separated sheet to order from.
    protocol
        The interactive bench protocol, as one self-contained HTML page.
    """

    product: Path
    primers: Path
    protocol: Path

Plan dataclass

One planned Golden Gate experiment.

Parameters:

Name Type Description Default
vector SequenceRecord

The records the plan was made from, the inserts in the order they go round the product and each on the strand that goes in.

required
inserts SequenceRecord

The records the plan was made from, the inserts in the order they go round the product and each on the strand that goes in.

required
span tuple[int, int]

The vector bases the assembly replaces, after any junction slide.

required
choice EnzymeChoice

The enzyme the plan uses, and what using it costs.

required
ranking tuple[EnzymeChoice, ...]

Every candidate enzyme, best first.

required
overhangs OverhangSet

The overhang every junction takes, with the fidelity of the whole set. An assembly of n inserts has n + 1 junctions.

required
linearised_vector Part

The part the vector is opened into.

required
insert_parts tuple[Part, ...]

The part each insert is amplified into, in insert order.

required
assembly Assembly

The simulated product, the parts and the junctions.

required
colony ColonyCheck

The colony PCR that reads every junction and tells a correct clone from an empty vector or from one carrying an insert the other way round.

required
reads tuple[SangerRead, SangerRead]

A sequencing primer reading in from outside the first junction and the last.

required
amounts tuple[Amount, ...]

What to put in the assembly reaction, vector first.

required
phenotype Phenotype

What the product says about itself.

required
designed_oligos tuple[DesignedOligo, ...]

Every designed oligo and what it is for, in order: each part's PCR, the colony PCR, the reads.

required
host str

The choices the protocol names.

required
polymerase str

The choices the protocol names.

required
thresholds Mapping[PrimerRole, Thresholds]

What those oligos were designed and judged by, for each role, so a page prints the band beside the value.

THRESHOLDS_FOR
Source code in src/liulab_mbio/goldengate/plan.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
@dataclass(frozen=True, slots=True)
class Plan:
    """One planned Golden Gate experiment.

    Parameters
    ----------
    vector, inserts
        The records the plan was made from, the inserts in the order they go round the product
        and each on the strand that goes in.
    span
        The vector bases the assembly replaces, after any junction slide.
    choice
        The enzyme the plan uses, and what using it costs.
    ranking
        Every candidate enzyme, best first.
    overhangs
        The overhang every junction takes, with the fidelity of the whole set. An assembly of n
        inserts has n + 1 junctions.
    linearised_vector
        The part the vector is opened into.
    insert_parts
        The part each insert is amplified into, in insert order.
    assembly
        The simulated product, the parts and the junctions.
    colony
        The colony PCR that reads every junction and tells a correct clone from an empty vector
        or from one carrying an insert the other way round.
    reads
        A sequencing primer reading in from outside the first junction and the last.
    amounts
        What to put in the assembly reaction, vector first.
    phenotype
        What the product says about itself.
    designed_oligos
        Every designed oligo and what it is for, in order: each part's PCR, the colony PCR, the
        reads.
    host, polymerase
        The choices the protocol names.
    thresholds
        What those oligos were designed and judged by, for each role, so a page prints the band
        beside the value.
    """

    vector: SequenceRecord
    inserts: tuple[SequenceRecord, ...]
    span: tuple[int, int]
    choice: EnzymeChoice
    ranking: tuple[EnzymeChoice, ...]
    overhangs: OverhangSet
    linearised_vector: Part
    insert_parts: tuple[Part, ...]
    assembly: Assembly
    colony: ColonyCheck
    reads: tuple[SangerRead, SangerRead]
    amounts: tuple[Amount, ...]
    phenotype: Phenotype
    designed_oligos: tuple[DesignedOligo, ...]
    host: str
    polymerase: Polymerase
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR

    @property
    def enzyme(self) -> Enzyme:
        """The Type IIS enzyme the assembly is cut with."""
        return self.assembly.enzyme

    @property
    def product(self) -> SequenceRecord:
        """The circular plasmid the assembly makes."""
        return self.assembly.product

    @property
    def parts(self) -> tuple[Part, ...]:
        """The parts that go into the reaction, the linearised vector first."""
        return (self.linearised_vector, *self.insert_parts)

    @property
    def oligos(self) -> tuple[Primer, ...]:
        """Every oligo the plan designs, in the order the sheet lists them."""
        return tuple(report.primer for report in self.reports)

    @property
    def reports(self) -> tuple[PrimerReport, ...]:
        """Every designed oligo's evaluation, in the order the sheet lists them."""
        return tuple(oligo.report for oligo in self.designed_oligos)

    @property
    def checks(self) -> tuple[Check, ...]:
        """The product's checks, with one more for the oligos."""
        return (
            *self.assembly.checks,
            Check(
                "primers",
                worst(report.status for report in self.reports),
                len(self.reports),
                _primer_detail(self.reports),
            ),
        )

    @property
    def status(self) -> Status:
        """The worst status of any check."""
        return worst(check.status for check in self.checks)

    def protocol(self) -> Protocol:
        """Return the bench protocol for this plan."""
        return protocol_for(
            vector=self.vector,
            span=self.span,
            overhangs=self.overhangs,
            linearised_vector=self.linearised_vector,
            insert_parts=self.insert_parts,
            assembly=self.assembly,
            colony=self.colony,
            reads=self.reads,
            amounts=self.amounts,
            phenotype=self.phenotype,
            oligos=self.designed_oligos,
            checks=self.checks,
            host=self.host,
            polymerase=self.polymerase,
            thresholds=self.thresholds,
        )

    def write(self, directory: str | os.PathLike[str]) -> Files:
        """Write the product, the primer sheet and the protocol into `directory`.

        The directory is made when it is not there. The three files are named by
        `PRODUCT_FILE`, `PRIMER_FILE` and `PROTOCOL_FILE`, and a second run over the same
        inputs writes the same bytes.
        """
        out = Path(directory)
        out.mkdir(parents=True, exist_ok=True)
        product = out / PRODUCT_FILE
        write_dna(self.product, product)
        sheet = out / PRIMER_FILE
        sheet.write_text(primer_sheet(self.reports), encoding="utf-8")
        return Files(product, sheet, write_html(self.protocol(), out / PROTOCOL_FILE))

checks property

checks: tuple[Check, ...]

The product's checks, with one more for the oligos.

enzyme property

enzyme: Enzyme

The Type IIS enzyme the assembly is cut with.

oligos property

oligos: tuple[Primer, ...]

Every oligo the plan designs, in the order the sheet lists them.

parts property

parts: tuple[Part, ...]

The parts that go into the reaction, the linearised vector first.

product property

product: SequenceRecord

The circular plasmid the assembly makes.

reports property

reports: tuple[PrimerReport, ...]

Every designed oligo's evaluation, in the order the sheet lists them.

status property

status: Status

The worst status of any check.

protocol

protocol() -> Protocol

Return the bench protocol for this plan.

Source code in src/liulab_mbio/goldengate/plan.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def protocol(self) -> Protocol:
    """Return the bench protocol for this plan."""
    return protocol_for(
        vector=self.vector,
        span=self.span,
        overhangs=self.overhangs,
        linearised_vector=self.linearised_vector,
        insert_parts=self.insert_parts,
        assembly=self.assembly,
        colony=self.colony,
        reads=self.reads,
        amounts=self.amounts,
        phenotype=self.phenotype,
        oligos=self.designed_oligos,
        checks=self.checks,
        host=self.host,
        polymerase=self.polymerase,
        thresholds=self.thresholds,
    )

write

write(directory: str | PathLike[str]) -> Files

Write the product, the primer sheet and the protocol into directory.

The directory is made when it is not there. The three files are named by PRODUCT_FILE, PRIMER_FILE and PROTOCOL_FILE, and a second run over the same inputs writes the same bytes.

Source code in src/liulab_mbio/goldengate/plan.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def write(self, directory: str | os.PathLike[str]) -> Files:
    """Write the product, the primer sheet and the protocol into `directory`.

    The directory is made when it is not there. The three files are named by
    `PRODUCT_FILE`, `PRIMER_FILE` and `PROTOCOL_FILE`, and a second run over the same
    inputs writes the same bytes.
    """
    out = Path(directory)
    out.mkdir(parents=True, exist_ok=True)
    product = out / PRODUCT_FILE
    write_dna(self.product, product)
    sheet = out / PRIMER_FILE
    sheet.write_text(primer_sheet(self.reports), encoding="utf-8")
    return Files(product, sheet, write_html(self.protocol(), out / PROTOCOL_FILE))

flipped

flipped(record: SequenceRecord) -> SequenceRecord

Return record read from the other strand, features and binding sites turned with it.

Raises:

Type Description
ValueError

If a span runs across the origin, which has no place on the other strand of a record this turns end for end.

Examples:

>>> flipped(SequenceRecord("AAAACCCG")).sequence
'CGGGTTTT'
Source code in src/liulab_mbio/goldengate/plan.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def flipped(record: SequenceRecord) -> SequenceRecord:
    """Return `record` read from the other strand, features and binding sites turned with it.

    Raises
    ------
    ValueError
        If a span runs across the origin, which has no place on the other strand of a record
        this turns end for end.

    Examples
    --------
    >>> flipped(SequenceRecord("AAAACCCG")).sequence
    'CGGGTTTT'
    """
    length = len(record)
    other = {Strand.FORWARD: Strand.REVERSE, Strand.REVERSE: Strand.FORWARD}
    spans = [
        (segment.start, segment.end) for feature in record.features for segment in feature.segments
    ]
    spans += [(site.start, site.end) for primer in record.primers for site in primer.binding_sites]
    if any(end > length for _, end in spans):
        raise ValueError("a record with a span across its origin cannot be turned end for end")
    features = tuple(
        dataclasses.replace(
            feature,
            segments=tuple(
                sorted(
                    (
                        Segment(
                            length - segment.end,
                            length - segment.start,
                            name=segment.name,
                            color=segment.color,
                        )
                        for segment in feature.segments
                    ),
                    key=lambda segment: (segment.start, segment.end),
                )
            ),
            strand=other.get(feature.strand, feature.strand),
        )
        for feature in record.features
    )
    primers = tuple(
        dataclasses.replace(
            primer,
            binding_sites=tuple(
                BindingSite(length - site.end, length - site.start, other[site.strand])
                for site in primer.binding_sites
            ),
        )
        for primer in record.primers
    )
    return dataclasses.replace(
        record,
        sequence=reverse_complement(record.sequence),
        features=features,
        primers=primers,
        extras={},
    )

plan_assembly

plan_assembly(
    vector: SequenceRecord | str | PathLike[str],
    *inserts: SequenceRecord | str | PathLike[str],
    site: Site = None,
    orientation: Orientation
    | Sequence[Orientation] = "forward",
    in_frame: bool | Sequence[bool] = False,
    enzyme: EnzymeLike | None = None,
    profile: LigaseProfile
    | str
    | PathLike[str]
    | None = None,
    prefer_profile: bool = False,
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    name: str = "",
    window: int = VECTOR_WINDOW,
    thresholds: Mapping[
        PrimerRole, Thresholds
    ] = THRESHOLDS_FOR,
) -> Plan

Plan one Golden Gate experiment putting inserts into vector.

One reaction joins as many inserts as the overhangs allow, given in the order they go round the product. The vector is opened by PCR across the span they replace, each insert is amplified with tails of its own, and every junction is scarless: it takes the bases the part already spells there. Only the vector junction may slide, by up to window bases, to get past an overhang rule, which moves where the vector is cut and not what the inserts spell.

Parameters:

Name Type Description Default
vector SequenceRecord | str | PathLike[str]

A record, or a path to a .dna, GenBank or FASTA file holding one.

required
inserts SequenceRecord | str | PathLike[str]

A record, or a path to a .dna, GenBank or FASTA file holding one.

required
site Site

Where the inserts go: a feature name, a (start, end) span of the vector, or None to use the vector's own MCS_FEATURE feature.

None
orientation Orientation | Sequence[Orientation]

"reverse" puts the other strand of an insert into the product. One value covers every insert; a sequence gives one for each.

'forward'
in_frame bool | Sequence[bool]

Hold an insert's junction on a codon boundary of the coding sequence it lies in. One value covers every insert; a sequence gives one for each.

False
enzyme EnzymeLike | None

The Type IIS enzyme to use. Chosen by choose_enzyme when not given, and refused either way if it reads a site in any part.

None
profile LigaseProfile | str | PathLike[str] | None

A ligase fidelity matrix the caller holds, as a path or an already read LigaseProfile. It scores the overhangs where no shipped matrix covers the enzyme. The package ships none: see liulab_mbio.goldengate.ligase.

None
prefer_profile bool

Use it even where a shipped matrix covers the enzyme.

False
polymerase Polymerase

For the PCRs. The colony PCR uses OneTaq, which is what NEB's protocol asks for.

Q5
host str

The strain the protocol names, and what to call the product.

DEFAULT_HOST
name str

The strain the protocol names, and what to call the product.

DEFAULT_HOST
window int

How far the vector junction may slide.

VECTOR_WINDOW
thresholds Mapping[PrimerRole, Thresholds]

For each role, what its oligos are designed and judged by in liulab_mbio.primers.

THRESHOLDS_FOR

Returns:

Type Description
Plan

The design, the simulated product and the validation.

Raises:

Type Description
ValueError

If no insert is given, if no insertion site is named and the vector annotates none, if the enzyme reads a site in a part, if no overhang passes every rule, if profile names a file that is not a count matrix, or if the parts do not assemble.

Source code in src/liulab_mbio/goldengate/plan.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
def plan_assembly(
    vector: SequenceRecord | str | os.PathLike[str],
    *inserts: SequenceRecord | str | os.PathLike[str],
    site: Site = None,
    orientation: Orientation | Sequence[Orientation] = "forward",
    in_frame: bool | Sequence[bool] = False,
    enzyme: EnzymeLike | None = None,
    profile: LigaseProfile | str | os.PathLike[str] | None = None,
    prefer_profile: bool = False,
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    name: str = "",
    window: int = VECTOR_WINDOW,
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR,
) -> Plan:
    """Plan one Golden Gate experiment putting `inserts` into `vector`.

    One reaction joins as many inserts as the overhangs allow, given in the order they go round
    the product. The vector is opened by PCR across the span they replace, each insert is
    amplified with tails of its own, and every junction is scarless: it takes the bases the part
    already spells there. Only the vector junction may slide, by up to `window` bases, to get
    past an overhang rule, which moves where the vector is cut and not what the inserts spell.

    Parameters
    ----------
    vector, inserts
        A record, or a path to a ``.dna``, GenBank or FASTA file holding one.
    site
        Where the inserts go: a feature name, a ``(start, end)`` span of the vector, or
        ``None`` to use the vector's own `MCS_FEATURE` feature.
    orientation
        ``"reverse"`` puts the other strand of an insert into the product. One value covers
        every insert; a sequence gives one for each.
    in_frame
        Hold an insert's junction on a codon boundary of the coding sequence it lies in. One
        value covers every insert; a sequence gives one for each.
    enzyme
        The Type IIS enzyme to use. Chosen by `choose_enzyme` when not given, and refused
        either way if it reads a site in any part.
    profile
        A ligase fidelity matrix the caller holds, as a path or an already read `LigaseProfile`.
        It scores the overhangs where no shipped matrix covers the enzyme. The package ships
        none: see `liulab_mbio.goldengate.ligase`.
    prefer_profile
        Use it even where a shipped matrix covers the enzyme.
    polymerase
        For the PCRs. The colony PCR uses OneTaq, which is what NEB's protocol asks for.
    host, name
        The strain the protocol names, and what to call the product.
    window
        How far the vector junction may slide.
    thresholds
        For each role, what its oligos are designed and judged by in `liulab_mbio.primers`.

    Returns
    -------
    Plan
        The design, the simulated product and the validation.

    Raises
    ------
    ValueError
        If no insert is given, if no insertion site is named and the vector annotates none, if
        the enzyme reads a site in a part, if no overhang passes every rule, if `profile` names
        a file that is not a count matrix, or if the parts do not assemble.
    """
    if not inserts:
        raise ValueError("an assembly needs a vector and at least one insert")
    one = _record(vector)
    ways = _orientations(orientation, len(inserts))
    frames = _frames(in_frame, len(inserts))
    going = [
        flipped(read) if way == "reverse" else read
        for read, way in ((_record(record), way) for record, way in zip(inserts, ways, strict=True))
    ]
    labels = [record.name or f"insert {number}" for number, record in enumerate(going, start=1)]
    start, end = _span(one, site)
    ranking = choose_enzyme([one, *going])
    choice = _chosen(ranking, enzyme, (one, *going))
    chosen = choice.enzyme
    designed = design_overhangs(
        (
            *(
                Junction(label, record=record, position=0, scarless=not frame, in_frame=frame)
                for label, record, frame in zip(labels, going, frames, strict=True)
            ),
            Junction(one.name or "vector", record=one, position=end, scarless=True, window=window),
        ),
        chosen,
        profile=_profile(profile),
        prefer_profile=prefer_profile,
    )
    overhangs = designed.overhangs
    span = (start, end + designed.choices[-1].offset)
    linearised_vector = open_vector(
        one,
        chosen,
        *span,
        overhangs=(overhangs[0], overhangs[-1]),
        name=f"{one.name} backbone".strip(),
        polymerase=polymerase,
        thresholds=thresholds["amplification"],
    )
    insert_parts = tuple(
        amplify(
            record,
            chosen,
            0,
            len(record),
            left_overhang=overhangs[number],
            right_overhang=overhangs[number + 1],
            name=label,
            polymerase=polymerase,
            thresholds=thresholds["amplification"],
        )
        for number, (label, record) in enumerate(zip(labels, going, strict=True))
    )
    parts = (linearised_vector, *insert_parts)
    built = assemble(parts, chosen, name=name or "-".join([one.name, *labels]).strip("-"))
    junctions = built.junction_positions
    first, last = junctions[0], junctions[-1]
    colony = colony_pcr_check(
        built.product,
        junctions,
        vector=one,
        primers=design_pair(
            built.product,
            first - COLONY_FLANK,
            last + REVERSE_FLANK,
            forward_name="Colony PCR forward",
            reverse_name="Colony PCR reverse",
            polymerase=ONETAQ,
            thresholds=thresholds["colony PCR"],
        ),
        insert_primer=True,
        polymerase=ONETAQ,
        thresholds=thresholds["colony PCR"],
    )
    reads = sanger_primers(built.product, junctions, thresholds=thresholds["sequencing"])
    return Plan(
        one,
        tuple(going),
        span,
        choice,
        ranking,
        designed,
        linearised_vector,
        insert_parts,
        built,
        colony,
        reads,
        assembly_amounts(
            (linearised_vector.name, linearised_vector.length),
            tuple((part.name, part.length) for part in insert_parts),
        ),
        read_phenotype(built.product, (first, last), vector=one, span=span),
        (
            *(
                DesignedOligo(report, "amplification", part)
                for part in parts
                for report in (part.report.forward, part.report.reverse)
            ),
            *(DesignedOligo(report, "colony PCR") for report in colony.reports),
            *(
                DesignedOligo(
                    evaluate_primer(
                        read.primer, built.product, thresholds=thresholds["sequencing"]
                    ),
                    "sequencing",
                )
                for read in reads
            ),
        ),
        host,
        polymerase,
        thresholds,
    )

liulab_mbio.goldengate.design

Choosing the Type IIS enzyme an assembly uses, and the overhangs its junctions are cut to.

Three decisions, in the order a design makes them:

  • Which enzyme. Rank the candidates by how many sites they read in the parts that end up in the product. An enzyme with no site needs nothing done to the parts, so domestication is proposed only when no candidate is free, and then as a report of what it would change.
  • Which overhangs. Each junction takes an overhang of the length the enzyme leaves. A palindrome would let a fragment ligate to itself, a repeat would let two junctions swap, and a near-duplicate is what mis-ligates; each refusal is reported with the rule that made it.
  • How well the set should ligate. Pryor et al. 2020 measured every overhang pair for five enzymes, and that data ships here: fidelity is the product over the junctions of correct ligations over all ligations. An enzyme they did not measure is scored against a ligase profile the caller holds (liulab_mbio.goldengate.ligase) where there is one and by the rules where there is not, and the report says which of the three scored it.

The fidelity data is src/liulab_mbio/data/ligation_fidelity.json, from the supplementary tables of Pryor, J.M., Potapov, V., Kucera, R.B., Bilotti, K., Cantor, E.J. and Lohman, G.J.S. (2020) PLoS One 15(9): e0238592, used under CC BY 4.0. docs/research/ligation-fidelity.md records the licence, the axis convention and the rules.

Choice dataclass

The overhang one junction took.

Parameters:

Name Type Description Default
junction Junction

The junction this is for.

required
overhang str

The overhang it takes, written on the top strand.

required
offset int

How far the junction moved from the position it was asked for.

0
rejected tuple[Rejection, ...]

Every candidate refused before this one, in the order they were tried.

()
Source code in src/liulab_mbio/goldengate/design.py
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
@dataclass(frozen=True, slots=True)
class Choice:
    """The overhang one junction took.

    Parameters
    ----------
    junction
        The junction this is for.
    overhang
        The overhang it takes, written on the top strand.
    offset
        How far the junction moved from the position it was asked for.
    rejected
        Every candidate refused before this one, in the order they were tried.
    """

    junction: Junction
    overhang: str
    offset: int = 0
    rejected: tuple[Rejection, ...] = ()

EnzymeChoice dataclass

One enzyme a design could use, and what using it would cost.

Parameters:

Name Type Description Default
enzyme Enzyme

The enzyme.

required
sites int

How many sites it reads across the parts.

required
measured bool

Whether shipped ligation data can score its overhangs.

False
changes tuple[Domestication, ...]

The synonymous codon changes that would take its sites away. Empty while another candidate is free, because domestication is proposed only when none is.

()
outside_cds tuple[CutSite, ...]

Sites lying in no coding sequence, which a human has to decide about.

()
unchanged tuple[CutSite, ...]

Sites in a coding sequence that no synonymous change could take away.

()
Source code in src/liulab_mbio/goldengate/design.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
@dataclass(frozen=True, slots=True)
class EnzymeChoice:
    """One enzyme a design could use, and what using it would cost.

    Parameters
    ----------
    enzyme
        The enzyme.
    sites
        How many sites it reads across the parts.
    measured
        Whether shipped ligation data can score its overhangs.
    changes
        The synonymous codon changes that would take its sites away. Empty while another
        candidate is free, because domestication is proposed only when none is.
    outside_cds
        Sites lying in no coding sequence, which a human has to decide about.
    unchanged
        Sites in a coding sequence that no synonymous change could take away.
    """

    enzyme: Enzyme
    sites: int
    _: KW_ONLY
    measured: bool = False
    changes: tuple[Domestication, ...] = ()
    outside_cds: tuple[CutSite, ...] = ()
    unchanged: tuple[CutSite, ...] = ()

    @property
    def free(self) -> bool:
        """Whether the parts hold no site of this enzyme already."""
        return self.sites == 0

    @property
    def clean(self) -> bool:
        """Whether the parts can be made free of it without anyone deciding anything."""
        return not (self.outside_cds or self.unchanged)

clean property

clean: bool

Whether the parts can be made free of it without anyone deciding anything.

free property

free: bool

Whether the parts hold no site of this enzyme already.

FidelityReport dataclass

How well a set of overhangs should ligate, and where the number came from.

Parameters:

Name Type Description Default
enzyme str

The enzyme whose data scored the set.

required
source str

The measurement, or a statement that the rules scored it instead.

required
measured bool

False when no published data covers this enzyme and the rules stood in.

required
value float

The probability that every junction ligates to its own partner.

required
ligations tuple[Ligation, ...]

One entry per overhang, empty when the rules scored the set.

()
weak tuple[str, ...]

Overhangs whose Watson-Crick pair was seen fewer than STRONG_LIGATION times per 100,000 ligation events.

()
mismatches tuple[tuple[str, str, float], ...]

Top overhang, bottom overhang and normalised count, for every cross pair seen at least MODEST_MISMATCH times per 100,000 ligation events, the worst first.

()
enzyme_specific bool

False when a ligase profile scored the set: a measurement of the ligase and the conditions, and not of this enzyme.

True
Source code in src/liulab_mbio/goldengate/design.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
@dataclass(frozen=True, slots=True)
class FidelityReport:
    """How well a set of overhangs should ligate, and where the number came from.

    Parameters
    ----------
    enzyme
        The enzyme whose data scored the set.
    source
        The measurement, or a statement that the rules scored it instead.
    measured
        ``False`` when no published data covers this enzyme and the rules stood in.
    value
        The probability that every junction ligates to its own partner.
    ligations
        One entry per overhang, empty when the rules scored the set.
    weak
        Overhangs whose Watson-Crick pair was seen fewer than `STRONG_LIGATION` times per
        100,000 ligation events.
    mismatches
        Top overhang, bottom overhang and normalised count, for every cross pair seen at least
        `MODEST_MISMATCH` times per 100,000 ligation events, the worst first.
    enzyme_specific
        ``False`` when a ligase profile scored the set: a measurement of the ligase and the
        conditions, and not of this enzyme.
    """

    enzyme: str
    source: str
    _: KW_ONLY
    measured: bool
    value: float
    ligations: tuple[Ligation, ...] = ()
    weak: tuple[str, ...] = ()
    mismatches: tuple[tuple[str, str, float], ...] = ()
    enzyme_specific: bool = True

    @property
    def label(self) -> str:
        """What kind of number this is, for a report printing it beside the value."""
        if not self.measured:
            return "rule-based estimate"
        if not self.enzyme_specific:
            return f"measured ligase profile, not specific to {self.enzyme}"
        return "measured"

label property

label: str

What kind of number this is, for a report printing it beside the value.

Junction dataclass

Where two parts meet in the product, and how free its overhang is.

Parameters:

Name Type Description Default
name str

What a report calls this junction.

required
record SequenceRecord | None

The part the junction is read from, for a scarless or in-frame junction.

None
position int

Where the two parts separate: the 0-based top-strand index the downstream part begins at.

0
scarless bool

Take the overhang from record, so the product gains no bases at this junction.

False
in_frame bool

A scarless junction inside a coding sequence that has to read through it: the junction sits on a codon boundary and moves only by whole codons.

False
window int

How many bases the junction may move by to get past a rule. A scarless junction that moves does not change what the product spells, only where the cut falls.

0
overhang str | None

Fixed by the caller. Still held to every rule, and the refusal says which one.

None

Raises:

Type Description
ValueError

If a fixed overhang is asked to be scarless too, or a scarless junction names no record, or the window is negative.

Source code in src/liulab_mbio/goldengate/design.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
@dataclass(frozen=True, slots=True)
class Junction:
    """Where two parts meet in the product, and how free its overhang is.

    Parameters
    ----------
    name
        What a report calls this junction.
    record
        The part the junction is read from, for a scarless or in-frame junction.
    position
        Where the two parts separate: the 0-based top-strand index the downstream part
        begins at.
    scarless
        Take the overhang from `record`, so the product gains no bases at this junction.
    in_frame
        A scarless junction inside a coding sequence that has to read through it: the junction
        sits on a codon boundary and moves only by whole codons.
    window
        How many bases the junction may move by to get past a rule. A scarless junction that
        moves does not change what the product spells, only where the cut falls.
    overhang
        Fixed by the caller. Still held to every rule, and the refusal says which one.

    Raises
    ------
    ValueError
        If a fixed overhang is asked to be scarless too, or a scarless junction names no
        record, or the window is negative.
    """

    name: str
    _: KW_ONLY
    record: SequenceRecord | None = None
    position: int = 0
    scarless: bool = False
    in_frame: bool = False
    window: int = 0
    overhang: str | None = None

    def __post_init__(self) -> None:
        """Refuse a junction whose fields ask for two different things."""
        if self.overhang is not None and (self.scarless or self.in_frame):
            raise ValueError(
                f"junction {self.name!r} both fixes an overhang and reads one from a record"
            )
        if (self.scarless or self.in_frame) and self.record is None:
            raise ValueError(f"junction {self.name!r} needs a record to read its overhang from")
        if self.window < 0:
            raise ValueError(f"junction {self.name!r} has a negative window")

    @property
    def fixed(self) -> bool:
        """Whether the caller already chose this junction's overhang."""
        return self.overhang is not None

fixed property

fixed: bool

Whether the caller already chose this junction's overhang.

__post_init__

__post_init__() -> None

Refuse a junction whose fields ask for two different things.

Source code in src/liulab_mbio/goldengate/design.py
292
293
294
295
296
297
298
299
300
301
def __post_init__(self) -> None:
    """Refuse a junction whose fields ask for two different things."""
    if self.overhang is not None and (self.scarless or self.in_frame):
        raise ValueError(
            f"junction {self.name!r} both fixes an overhang and reads one from a record"
        )
    if (self.scarless or self.in_frame) and self.record is None:
        raise ValueError(f"junction {self.name!r} needs a record to read its overhang from")
    if self.window < 0:
        raise ValueError(f"junction {self.name!r} has a negative window")

Ligation dataclass

What the data says about one overhang of a set.

Parameters:

Name Type Description Default
overhang str

The overhang, written on the top strand.

required
correct int

Ligations of its two ends to each other, which is what the junction is for.

required
total int

Ligations of its two ends to any end the reaction holds, correct and mismatched.

required
Source code in src/liulab_mbio/goldengate/design.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
@dataclass(frozen=True, slots=True)
class Ligation:
    """What the data says about one overhang of a set.

    Parameters
    ----------
    overhang
        The overhang, written on the top strand.
    correct
        Ligations of its two ends to each other, which is what the junction is for.
    total
        Ligations of its two ends to any end the reaction holds, correct and mismatched.
    """

    overhang: str
    correct: int
    total: int

    @property
    def value(self) -> float:
        """The share of this junction's ligations that were correct."""
        return self.correct / self.total if self.total else 0.0

value property

value: float

The share of this junction's ligations that were correct.

LigationMatrix dataclass

How often each overhang pair was seen ligating, measured with one enzyme.

Parameters:

Name Type Description Default
enzyme str

The enzyme, and the supplier's product the measurement used.

required
product str

The enzyme, and the supplier's product the measurement used.

required
table str

Which supplementary table of the paper this is.

required
overhang_length int

How many bases the overhangs on both axes have.

required
cycling_celsius tuple[int, int]

The two temperatures the measured reaction was cycled between.

required
observations int

Every ligation event counted.

required
counts Mapping[str, Mapping[str, int]]

Top-strand overhang to the bottom-strand overhangs it was seen ligating to. Both are written 5' to 3', so a row pairs with the column spelling its reverse complement.

required
citation str

The paper the data comes from.

''
Source code in src/liulab_mbio/goldengate/design.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@dataclass(frozen=True, slots=True)
class LigationMatrix:
    """How often each overhang pair was seen ligating, measured with one enzyme.

    Parameters
    ----------
    enzyme, product
        The enzyme, and the supplier's product the measurement used.
    table
        Which supplementary table of the paper this is.
    overhang_length
        How many bases the overhangs on both axes have.
    cycling_celsius
        The two temperatures the measured reaction was cycled between.
    observations
        Every ligation event counted.
    counts
        Top-strand overhang to the bottom-strand overhangs it was seen ligating to. Both are
        written 5' to 3', so a row pairs with the column spelling its reverse complement.
    citation
        The paper the data comes from.
    """

    enzyme: str
    product: str
    table: str
    _: KW_ONLY
    overhang_length: int
    cycling_celsius: tuple[int, int]
    observations: int
    counts: Mapping[str, Mapping[str, int]] = field(hash=False)
    citation: str = ""

    @property
    def overhangs(self) -> tuple[str, ...]:
        """Every overhang the measurement covers."""
        return tuple(self.counts)

    @property
    def source(self) -> str:
        """Where a report should say this number came from."""
        return f"{self.citation} {self.table}, measured with {self.product}"

    def count(self, top: str, bottom: str) -> int:
        """How often a top-strand overhang was seen ligating to a bottom-strand one."""
        return self.counts.get(top.upper(), {}).get(bottom.upper(), 0)

    def normalised(self, top: str, bottom: str) -> float:
        """Return the count per 100,000 ligation events, the scale NEB's thresholds use."""
        return 100_000 * self.count(top, bottom) / self.observations

overhangs property

overhangs: tuple[str, ...]

Every overhang the measurement covers.

source property

source: str

Where a report should say this number came from.

count

count(top: str, bottom: str) -> int

How often a top-strand overhang was seen ligating to a bottom-strand one.

Source code in src/liulab_mbio/goldengate/design.py
132
133
134
def count(self, top: str, bottom: str) -> int:
    """How often a top-strand overhang was seen ligating to a bottom-strand one."""
    return self.counts.get(top.upper(), {}).get(bottom.upper(), 0)

normalised

normalised(top: str, bottom: str) -> float

Return the count per 100,000 ligation events, the scale NEB's thresholds use.

Source code in src/liulab_mbio/goldengate/design.py
136
137
138
def normalised(self, top: str, bottom: str) -> float:
    """Return the count per 100,000 ligation events, the scale NEB's thresholds use."""
    return 100_000 * self.count(top, bottom) / self.observations

OverhangSet dataclass

The overhangs a design chose, one per junction.

Parameters:

Name Type Description Default
enzyme Enzyme

The enzyme that will cut them.

required
choices tuple[Choice, ...]

One per junction, in the order the junctions were given.

required
fidelity FidelityReport

How well the set should ligate.

required
Source code in src/liulab_mbio/goldengate/design.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
@dataclass(frozen=True, slots=True)
class OverhangSet:
    """The overhangs a design chose, one per junction.

    Parameters
    ----------
    enzyme
        The enzyme that will cut them.
    choices
        One per junction, in the order the junctions were given.
    fidelity
        How well the set should ligate.
    """

    enzyme: Enzyme
    choices: tuple[Choice, ...]
    fidelity: FidelityReport

    @property
    def overhangs(self) -> tuple[str, ...]:
        """The chosen overhangs, in the order the junctions were given."""
        return tuple(choice.overhang for choice in self.choices)

overhangs property

overhangs: tuple[str, ...]

The chosen overhangs, in the order the junctions were given.

Rejection dataclass

One candidate overhang a rule refused.

Parameters:

Name Type Description Default
overhang str

The candidate.

required
rule RejectionRule

Which rule refused it.

required
detail str

Why that rule refused this candidate.

required
Source code in src/liulab_mbio/goldengate/design.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
@dataclass(frozen=True, slots=True)
class Rejection:
    """One candidate overhang a rule refused.

    Parameters
    ----------
    overhang
        The candidate.
    rule
        Which rule refused it.
    detail
        Why that rule refused this candidate.
    """

    overhang: str
    rule: RejectionRule
    detail: str

choose_enzyme

choose_enzyme(
    parts: Iterable[SequenceRecord],
    *,
    enzymes: Iterable[EnzymeLike] = GOLDEN_GATE_ENZYMES,
    usage: CodonUsage | None = None,
    avoid: Iterable[EnzymeLike] = (),
) -> tuple[EnzymeChoice, ...]

Rank Type IIS enzymes for assembling these parts, best first.

The parts are the pieces that end up in the product, so a site anywhere in them is a site the enzyme would cut during assembly. An enzyme with no site is preferred; only when none is free is domestication proposed, and then every candidate carries what it would change.

A tie between free enzymes goes to the one whose overhangs shipped data can score, then to the one leaving the longer overhang. LAST_RESORT enzymes rank behind everything.

Parameters:

Name Type Description Default
parts Iterable[SequenceRecord]

The records going into the assembly.

required
enzymes Iterable[EnzymeLike]

The candidates. Every one must be Type IIS.

GOLDEN_GATE_ENZYMES
usage CodonUsage | None

The host's codon usage, for a proposed domestication.

None
avoid Iterable[EnzymeLike]

Further enzymes whose sites a domestication may not create.

()

Raises:

Type Description
ValueError

If a candidate cuts inside its own recognition site, leaving no overhang to design.

Source code in src/liulab_mbio/goldengate/design.py
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
def choose_enzyme(
    parts: Iterable[SequenceRecord],
    *,
    enzymes: Iterable[EnzymeLike] = GOLDEN_GATE_ENZYMES,
    usage: CodonUsage | None = None,
    avoid: Iterable[EnzymeLike] = (),
) -> tuple[EnzymeChoice, ...]:
    """Rank Type IIS enzymes for assembling these parts, best first.

    The parts are the pieces that end up in the product, so a site anywhere in them is a site
    the enzyme would cut during assembly. An enzyme with no site is preferred; only when none
    is free is domestication proposed, and then every candidate carries what it would change.

    A tie between free enzymes goes to the one whose overhangs shipped data can score, then to
    the one leaving the longer overhang. `LAST_RESORT` enzymes rank behind everything.

    Parameters
    ----------
    parts
        The records going into the assembly.
    enzymes
        The candidates. Every one must be Type IIS.
    usage
        The host's codon usage, for a proposed domestication.
    avoid
        Further enzymes whose sites a domestication may not create.

    Raises
    ------
    ValueError
        If a candidate cuts inside its own recognition site, leaving no overhang to design.
    """
    records = tuple(parts)
    candidates = _resolve(enzymes)
    for one in candidates:
        _type_iis(one)
    counts = site_counts(records, candidates)
    anyone_free = any(counts[one.name] == 0 for one in candidates)
    choices: list[EnzymeChoice] = []
    for one in candidates:
        changes, outside, unchanged = _proposal(records, one, usage, avoid, anyone_free)
        choices.append(
            EnzymeChoice(
                one,
                counts[one.name],
                measured=ligation_matrix(one) is not None,
                changes=changes,
                outside_cds=outside,
                unchanged=unchanged,
            )
        )
    return tuple(sorted(choices, key=_rank))

design_overhangs

design_overhangs(
    junctions: Iterable[Junction],
    enzyme: EnzymeLike,
    *,
    avoid: Iterable[EnzymeLike] = (),
    min_distance: int = MIN_DISTANCE,
    allow_uniform: bool = False,
    profile: LigaseProfile | None = None,
    prefer_profile: bool = False,
) -> OverhangSet

Choose one overhang per junction, and say what was refused on the way.

Junctions whose overhang is already fixed are settled first, then the ones read from a record, then the free ones, so the constrained junctions are never blocked by a free one. The result lists them in the order they were given.

Parameters:

Name Type Description Default
junctions Iterable[Junction]

The junctions to design for.

required
enzyme EnzymeLike

The enzyme that will cut them, which sets the overhang length.

required
avoid Iterable[EnzymeLike]

Enzymes besides this one whose sites a primer tail carrying the overhang must not spell.

()
min_distance int

How many bases two overhangs in the set must differ by.

MIN_DISTANCE
allow_uniform bool

Accept an overhang of one base kind. Refused by default: an all-GC junction truncates.

False
profile LigaseProfile | None

A ligase's own matrix, read by liulab_mbio.goldengate.ligase.read_profile. It ranks the free candidates and scores the set where no shipped matrix covers the enzyme.

None
prefer_profile bool

Use profile even where a shipped matrix covers the enzyme.

False

Raises:

Type Description
ValueError

If a junction has no candidate left, naming the rule that refused the last one, or if a profile covers overhangs of another length than the enzyme leaves.

Examples:

>>> record = SequenceRecord("AAAACCTGAGGGTTTT")
>>> junction = Junction("insert", record=record, position=4, scarless=True)
>>> design_overhangs([junction], "BsaI").overhangs
('CCTG',)
Source code in src/liulab_mbio/goldengate/design.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
def design_overhangs(
    junctions: Iterable[Junction],
    enzyme: EnzymeLike,
    *,
    avoid: Iterable[EnzymeLike] = (),
    min_distance: int = MIN_DISTANCE,
    allow_uniform: bool = False,
    profile: LigaseProfile | None = None,
    prefer_profile: bool = False,
) -> OverhangSet:
    """Choose one overhang per junction, and say what was refused on the way.

    Junctions whose overhang is already fixed are settled first, then the ones read from a
    record, then the free ones, so the constrained junctions are never blocked by a free one.
    The result lists them in the order they were given.

    Parameters
    ----------
    junctions
        The junctions to design for.
    enzyme
        The enzyme that will cut them, which sets the overhang length.
    avoid
        Enzymes besides this one whose sites a primer tail carrying the overhang must not spell.
    min_distance
        How many bases two overhangs in the set must differ by.
    allow_uniform
        Accept an overhang of one base kind. Refused by default: an all-GC junction truncates.
    profile
        A ligase's own matrix, read by `liulab_mbio.goldengate.ligase.read_profile`. It ranks
        the free candidates and scores the set where no shipped matrix covers the enzyme.
    prefer_profile
        Use `profile` even where a shipped matrix covers the enzyme.

    Raises
    ------
    ValueError
        If a junction has no candidate left, naming the rule that refused the last one, or if
        a profile covers overhangs of another length than the enzyme leaves.

    Examples
    --------
    >>> record = SequenceRecord("AAAACCTGAGGGTTTT")
    >>> junction = Junction("insert", record=record, position=4, scarless=True)
    >>> design_overhangs([junction], "BsaI").overhangs
    ('CCTG',)
    """
    one = _type_iis(_one(enzyme))
    others = _resolve(avoid)
    table, _ = _scoring(one, profile, prefer_profile)
    wanted = tuple(junctions)
    order = sorted(range(len(wanted)), key=lambda index: _freedom(wanted[index]))
    chosen: dict[int, Choice] = {}
    taken: list[str] = []
    for index in order:
        junction = wanted[index]
        rejected: list[Rejection] = []
        for offset, candidate in _candidates(junction, one, table):
            refusal = _refuse(candidate, one, taken, others, min_distance, allow_uniform)
            if refusal is None:
                chosen[index] = Choice(junction, candidate, offset, tuple(rejected))
                taken.append(candidate)
                break
            rejected.append(refusal)
        else:
            raise _stuck(junction, rejected)
    choices = tuple(chosen[index] for index in range(len(wanted)))
    scored = fidelity(
        [choice.overhang for choice in choices],
        one,
        profile=profile,
        prefer_profile=prefer_profile,
    )
    return OverhangSet(one, choices, scored)

fidelity

fidelity(
    overhangs: Iterable[str],
    enzyme: EnzymeLike,
    *,
    profile: LigaseProfile | None = None,
    prefer_profile: bool = False,
) -> FidelityReport

Score how well a set of overhangs should ligate to their own partners and nothing else.

With shipped data, this is Pryor 2020's definition: the product over the junctions of correct ligations divided by every ligation the junction was seen making with an end the reaction holds. A junction has two ends, one presenting the top-strand overhang and one presenting its reverse complement, and both are counted — which is what reproduces the fidelity the paper reports for its own worked examples.

A profile stands in where no shipped matrix covers the enzyme, and prefer_profile uses it even where one does. A profile is a measurement of the ligase and the conditions and not of the enzyme, so FidelityReport.enzyme_specific is then False and the source says so.

With neither, the rules score the set instead and FidelityReport.measured is False. Those numbers are a ranking, not a prediction: they compare one candidate set with another scored the same way and with nothing else.

Raises:

Type Description
ValueError

If an overhang is not one this enzyme leaves, or a profile covers overhangs of another length than the enzyme leaves.

Examples:

>>> round(fidelity(["AAAA"], "BsaI").value, 3)
1.0
Source code in src/liulab_mbio/goldengate/design.py
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
def fidelity(
    overhangs: Iterable[str],
    enzyme: EnzymeLike,
    *,
    profile: LigaseProfile | None = None,
    prefer_profile: bool = False,
) -> FidelityReport:
    """Score how well a set of overhangs should ligate to their own partners and nothing else.

    With shipped data, this is Pryor 2020's definition: the product over the junctions of
    correct ligations divided by every ligation the junction was seen making with an end the
    reaction holds. A junction has two ends, one presenting the top-strand overhang and one
    presenting its reverse complement, and both are counted — which is what reproduces the
    fidelity the paper reports for its own worked examples.

    A `profile` stands in where no shipped matrix covers the enzyme, and `prefer_profile` uses
    it even where one does. A profile is a measurement of the ligase and the conditions and not
    of the enzyme, so `FidelityReport.enzyme_specific` is then ``False`` and the source says so.

    With neither, the rules score the set instead and `FidelityReport.measured` is ``False``.
    Those numbers are a ranking, not a prediction: they compare one candidate set with another
    scored the same way and with nothing else.

    Raises
    ------
    ValueError
        If an overhang is not one this enzyme leaves, or a profile covers overhangs of another
        length than the enzyme leaves.

    Examples
    --------
    >>> round(fidelity(["AAAA"], "BsaI").value, 3)
    1.0
    """
    one = _type_iis(_one(enzyme))
    chosen = tuple(overhang.upper() for overhang in overhangs)
    for overhang in chosen:
        if len(overhang) != one.overhang_length or set(overhang) - set("ACGT"):
            raise ValueError(
                f"{one.name} leaves a {one.overhang_length}-base overhang, "
                f"and {overhang!r} is {len(overhang)}"
            )
    table, specific = _scoring(one, profile, prefer_profile)
    if table is None:
        return _by_rule(one, chosen)
    query = sorted({*chosen, *(reverse_complement(overhang) for overhang in chosen)})
    ligations: list[Ligation] = []
    weak: list[str] = []
    value = 1.0
    for overhang in chosen:
        partner = reverse_complement(overhang)
        ends = (overhang, partner)
        ligation = Ligation(
            overhang,
            sum(table.count(end, other) for end, other in (ends, ends[::-1])),
            sum(table.count(end, column) for end in ends for column in query),
        )
        ligations.append(ligation)
        value *= ligation.value
        if table.normalised(overhang, partner) < STRONG_LIGATION:
            weak.append(overhang)
    mismatches = sorted(
        (
            (row, column, table.normalised(row, column))
            for row in query
            for column in query
            if column != reverse_complement(row)
            and table.normalised(row, column) >= MODEST_MISMATCH
        ),
        key=lambda entry: (-entry[2], entry[0], entry[1]),
    )
    source = table.source
    if not specific:
        source = f"{source}, a ligase profile and not a measurement of {one.name}"
    return FidelityReport(
        one.name,
        source,
        measured=True,
        value=value,
        ligations=tuple(ligations),
        weak=tuple(weak),
        mismatches=tuple(mismatches),
        enzyme_specific=specific,
    )

ligation_matrix

ligation_matrix(
    enzyme: EnzymeLike,
) -> LigationMatrix | None

Return the shipped ligation data measured with this enzyme, or None where there is none.

Matched by name. The measurement is of one supplier's product cycled at one pair of temperatures, so an isoschizomer sold by somebody else does not inherit it.

Examples:

>>> ligation_matrix("BsaI").count("TTTT", "AAAA")
635
>>> ligation_matrix("PaqCI") is None
True
Source code in src/liulab_mbio/goldengate/design.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def ligation_matrix(enzyme: EnzymeLike) -> LigationMatrix | None:
    """Return the shipped ligation data measured with this enzyme, or ``None`` where there is none.

    Matched by name. The measurement is of one supplier's product cycled at one pair of
    temperatures, so an isoschizomer sold by somebody else does not inherit it.

    Examples
    --------
    >>> ligation_matrix("BsaI").count("TTTT", "AAAA")
    635
    >>> ligation_matrix("PaqCI") is None
    True
    """
    return _shipped().get(_one(enzyme).name)

liulab_mbio.goldengate.assembly

Simulate a Golden Gate assembly: amplify each part, cut it, and ligate the pieces.

Every part reaches the reaction as a PCR product whose primers carry a Type IIS tail, so that cutting the amplicon leaves the overhang the design asked for. The vector is one of those parts: open_vector points its primers outward from the span the assembly replaces, so the whole backbone amplifies and the template that templated it is removed with DpnI.

Two rules run through the module:

  • An overhang is written on the top strand, so two ends join when the strings are equal. That is liulab_mbio.sites' rule, and it makes a junction simply the bases standing at that point of the product.
  • A part's left_overhang replaces the first bases of its own span. A junction that keeps what the template already spells passes those bases back; one that changes them changes the product there, and nothing is added or lost either way.

Coordinates are the model's, 0-based and half-open, and a span across the origin of a circular record ends past the record's length.

Assembly dataclass

What one Golden Gate reaction makes.

Parameters:

Name Type Description Default
product SequenceRecord

The circular plasmid: every part's features carried to their new coordinates, the primers annotated where they anneal, and each junction drawn in JUNCTION_COLOR.

required
parts tuple[Part, ...]

The parts that went in, in the order they were given.

required
junctions tuple[Junction, ...]

Where they meet, in the product's own order.

required
enzyme Enzyme

The enzyme the reaction was cut with.

required
Source code in src/liulab_mbio/goldengate/assembly.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
@dataclass(frozen=True, slots=True)
class Assembly:
    """What one Golden Gate reaction makes.

    Parameters
    ----------
    product
        The circular plasmid: every part's features carried to their new coordinates, the
        primers annotated where they anneal, and each junction drawn in `JUNCTION_COLOR`.
    parts
        The parts that went in, in the order they were given.
    junctions
        Where they meet, in the product's own order.
    enzyme
        The enzyme the reaction was cut with.
    """

    product: SequenceRecord
    parts: tuple[Part, ...]
    junctions: tuple[Junction, ...]
    enzyme: Enzyme

    @property
    def junction_positions(self) -> tuple[int, ...]:
        """Where each junction begins, which is what a validation design reads across."""
        return tuple(one.start for one in self.junctions)

    @property
    def checks(self) -> tuple[Check, ...]:
        """Judge the product, as data a protocol can print.

        The sites left for the enzyme come first — one that remains would cut the product open
        again — then a check for each part counting the copies of it the product holds, then how
        many junctions spell the overhang they claim. A part is counted from its template and
        not from the digest, so the count answers for the ligation rather than repeating it.
        """
        left = len(find_sites(self.product, self.enzyme))
        checks = [
            Check(
                "sites",
                "pass" if left == 0 else "fail",
                left,
                f"{self.enzyme.name} no longer cuts the product"
                if left == 0
                else f"{self.enzyme.name} still cuts the product open",
            )
        ]
        for index, part in enumerate(self.parts):
            copies = _copies(self.product, part.bases)
            checks.append(
                Check(
                    part.name or f"part {index}",
                    "pass" if copies == 1 else "fail",
                    copies,
                    f"{part.fragment_length} bases, whole and once"
                    if copies == 1
                    else f"{copies} whole copies of its {part.fragment_length} bases",
                )
            )
        matched = sum(self.product.extract(one.span) == one.overhang for one in self.junctions)
        checks.append(
            Check(
                "junctions",
                "pass" if matched == len(self.junctions) else "fail",
                matched,
                ", ".join(f"{one.overhang} at {one.start}" for one in self.junctions),
            )
        )
        return tuple(checks)

    @property
    def status(self) -> Status:
        """The worst status of any check."""
        return worst(check.status for check in self.checks)

    def __getitem__(self, name: str) -> Check:
        """Return the check of that name.

        Raises
        ------
        KeyError
            If no check has it.
        """
        for check in self.checks:
            if check.name == name:
                return check
        raise KeyError(name)

checks property

checks: tuple[Check, ...]

Judge the product, as data a protocol can print.

The sites left for the enzyme come first — one that remains would cut the product open again — then a check for each part counting the copies of it the product holds, then how many junctions spell the overhang they claim. A part is counted from its template and not from the digest, so the count answers for the ligation rather than repeating it.

junction_positions property

junction_positions: tuple[int, ...]

Where each junction begins, which is what a validation design reads across.

status property

status: Status

The worst status of any check.

__getitem__

__getitem__(name: str) -> Check

Return the check of that name.

Raises:

Type Description
KeyError

If no check has it.

Source code in src/liulab_mbio/goldengate/assembly.py
414
415
416
417
418
419
420
421
422
423
424
425
def __getitem__(self, name: str) -> Check:
    """Return the check of that name.

    Raises
    ------
    KeyError
        If no check has it.
    """
    for check in self.checks:
        if check.name == name:
            return check
    raise KeyError(name)

Junction dataclass

Where two parts meet in the product: the bases their two overhangs paired on.

Parameters:

Name Type Description Default
start int

0-based index of the first of those bases in the product.

required
overhang str

What they spell, written on the top strand.

required
before str

The parts either side, named as they were given.

required
after str

The parts either side, named as they were given.

required
Source code in src/liulab_mbio/goldengate/assembly.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
@dataclass(frozen=True, slots=True)
class Junction:
    """Where two parts meet in the product: the bases their two overhangs paired on.

    Parameters
    ----------
    start
        0-based index of the first of those bases in the product.
    overhang
        What they spell, written on the top strand.
    before, after
        The parts either side, named as they were given.
    """

    start: int
    overhang: str
    before: str
    after: str

    @property
    def end(self) -> int:
        """Where the junction's bases end."""
        return self.start + len(self.overhang)

    @property
    def span(self) -> Segment:
        """The junction's bases, as a segment of the product."""
        return Segment(self.start, self.end)

end property

end: int

Where the junction's bases end.

span property

span: Segment

The junction's bases, as a segment of the product.

Part dataclass

One piece of an assembly: the PCR that makes it, and the ends the enzyme leaves on it.

Parameters:

Name Type Description Default
name str

What its tube is labelled.

required
template SequenceRecord

The record it is amplified from.

required
span tuple[int, int]

The template bases it contributes to the product, 0-based and half-open. end passes the template's length when the span runs across the origin.

required
forward Primer

The primers, tails included.

required
reverse Primer

The primers, tails included.

required
amplicon SequenceRecord

What the PCR makes: both tails, the span, and the template's features and primers carried to their new coordinates.

required
left_overhang str

The bases the enzyme leaves at the amplicon's left end, which stand in for the span's own first bases.

required
right_overhang str

The bases at its right end. They belong to the next part round the circle, so they reach this part only through the reverse primer's tail.

required
dpni bool

Whether DpnI should be used to take the template away afterwards.

required
report PairReport

What the pair scored on the template, carrying the annealing temperature and the extension time the PCR needs.

required
Source code in src/liulab_mbio/goldengate/assembly.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@dataclass(frozen=True, slots=True)
class Part:
    """One piece of an assembly: the PCR that makes it, and the ends the enzyme leaves on it.

    Parameters
    ----------
    name
        What its tube is labelled.
    template
        The record it is amplified from.
    span
        The template bases it contributes to the product, 0-based and half-open. `end` passes
        the template's length when the span runs across the origin.
    forward, reverse
        The primers, tails included.
    amplicon
        What the PCR makes: both tails, the span, and the template's features and primers
        carried to their new coordinates.
    left_overhang
        The bases the enzyme leaves at the amplicon's left end, which stand in for the span's
        own first bases.
    right_overhang
        The bases at its right end. They belong to the next part round the circle, so they
        reach this part only through the reverse primer's tail.
    dpni
        Whether DpnI should be used to take the template away afterwards.
    report
        What the pair scored on the template, carrying the annealing temperature and the
        extension time the PCR needs.
    """

    name: str
    template: SequenceRecord
    span: tuple[int, int]
    forward: Primer
    reverse: Primer
    amplicon: SequenceRecord
    left_overhang: str
    right_overhang: str
    dpni: bool
    report: PairReport

    @property
    def length(self) -> int:
        """Bases of amplicon, which is what a gel measures and a PCR program times."""
        return len(self.amplicon)

    @property
    def fragment_length(self) -> int:
        """Bases this part puts into the product, its own overhang counted."""
        return self.span[1] - self.span[0]

    @property
    def bases(self) -> str:
        """What this part puts into the product, its own overhang standing first."""
        start, end = self.span
        return self.left_overhang + self.template.extract(
            Segment(start + len(self.left_overhang), end)
        )

bases property

bases: str

What this part puts into the product, its own overhang standing first.

fragment_length property

fragment_length: int

Bases this part puts into the product, its own overhang counted.

length property

length: int

Bases of amplicon, which is what a gel measures and a PCR program times.

amplify

amplify(
    template: SequenceRecord,
    enzyme: EnzymeLike,
    start: int,
    end: int,
    *,
    left_overhang: str,
    right_overhang: str,
    name: str = "",
    dpni: bool | None = None,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    spacer: str | None = None,
    spacer_length: int = SPACER_LENGTH,
    avoid: Iterable[EnzymeLike] = (),
) -> Part

Design the PCR that turns template[start:end] into one part of an assembly.

Each primer carries a tail holding the recognition site pointing back into the part, so cutting the amplicon leaves left_overhang at its left end and right_overhang at its right. left_overhang stands in for the span's own first bases, so the annealing region begins after them.

end passes the template's length when the span runs across the origin; open_vector is that case with the arithmetic done for you.

Parameters:

Name Type Description Default
template SequenceRecord

The record to amplify.

required
enzyme EnzymeLike

The Type IIS enzyme the assembly is cut with.

required
start int

The span of template this part puts into the product.

required
end int

The span of template this part puts into the product.

required
left_overhang str

The junctions at this part's two ends, written on the top strand.

required
right_overhang str

The junctions at this part's two ends, written on the top strand.

required
name str

Names the part, its amplicon and its two primers.

''
dpni bool | None

Whether DpnI removes the template. Decided from the template when not given: a circular one is a plasmid from a Dam-positive host, and dam_sites says DpnI can cut it.

None
polymerase Polymerase

Passed to liulab_mbio.primers and liulab_mbio.sites.primer_tail.

Q5
thresholds Polymerase

Passed to liulab_mbio.primers and liulab_mbio.sites.primer_tail.

Q5
spacer Polymerase

Passed to liulab_mbio.primers and liulab_mbio.sites.primer_tail.

Q5
spacer_length Polymerase

Passed to liulab_mbio.primers and liulab_mbio.sites.primer_tail.

Q5
avoid Polymerase

Passed to liulab_mbio.primers and liulab_mbio.sites.primer_tail.

Q5

Returns:

Type Description
Part

The primers, the amplicon they make, and the ends a digest will leave.

Raises:

Type Description
ValueError

If an overhang is not one this enzyme leaves, if the span is no longer than its own left overhang, or if no annealing region fits either end.

Source code in src/liulab_mbio/goldengate/assembly.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def amplify(
    template: SequenceRecord,
    enzyme: EnzymeLike,
    start: int,
    end: int,
    *,
    left_overhang: str,
    right_overhang: str,
    name: str = "",
    dpni: bool | None = None,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    spacer: str | None = None,
    spacer_length: int = SPACER_LENGTH,
    avoid: Iterable[EnzymeLike] = (),
) -> Part:
    """Design the PCR that turns ``template[start:end]`` into one part of an assembly.

    Each primer carries a tail holding the recognition site pointing back into the part, so
    cutting the amplicon leaves `left_overhang` at its left end and `right_overhang` at its
    right. `left_overhang` stands in for the span's own first bases, so the annealing region
    begins after them.

    `end` passes the template's length when the span runs across the origin; `open_vector` is
    that case with the arithmetic done for you.

    Parameters
    ----------
    template
        The record to amplify.
    enzyme
        The Type IIS enzyme the assembly is cut with.
    start, end
        The span of `template` this part puts into the product.
    left_overhang, right_overhang
        The junctions at this part's two ends, written on the top strand.
    name
        Names the part, its amplicon and its two primers.
    dpni
        Whether DpnI removes the template. Decided from the template when not given: a circular
        one is a plasmid from a Dam-positive host, and `dam_sites` says DpnI can cut it.
    polymerase, thresholds, spacer, spacer_length, avoid
        Passed to `liulab_mbio.primers` and `liulab_mbio.sites.primer_tail`.

    Returns
    -------
    Part
        The primers, the amplicon they make, and the ends a digest will leave.

    Raises
    ------
    ValueError
        If an overhang is not one this enzyme leaves, if the span is no longer than its own
        left overhang, or if no annealing region fits either end.
    """
    one = _enzyme(enzyme)
    left, right = left_overhang.upper(), right_overhang.upper()
    anneal = start + len(left)
    if anneal >= end:
        raise ValueError(
            f"the span {start}-{end} is shorter than the overhang {left!r} standing in for "
            "its first bases"
        )
    tails = tuple(
        primer_tail(one, bases, spacer=spacer, spacer_length=spacer_length, avoid=avoid)
        for bases in (left, reverse_complement(right))
    )
    forward, reverse = design_pair(
        template,
        anneal,
        end,
        forward_tail=tails[0],
        reverse_tail=tails[1],
        forward_name=f"{name} forward".strip(),
        reverse_name=f"{name} reverse".strip(),
        polymerase=polymerase,
        thresholds=thresholds,
    )
    bases = tails[0] + template.extract(Segment(anneal, end)) + reverse_complement(tails[1])
    features, carried = _carried(template, start, end, len(tails[0]) - len(left) - start)
    placed = (
        _placed(forward, len(tails[0]), Strand.FORWARD),
        _placed(reverse, len(bases) - len(tails[1]), Strand.REVERSE),
    )
    return Part(
        name,
        template,
        (start, end),
        forward,
        reverse,
        SequenceRecord(
            bases,
            name=name or template.name,
            features=features,
            primers=carried + placed,
        ),
        left,
        right,
        (template.topology == "circular" and dam_sites(template) > 0) if dpni is None else dpni,
        evaluate_pair(forward, reverse, template, polymerase=polymerase, thresholds=thresholds),
    )

assemble

assemble(
    parts: Sequence[Part],
    enzyme: EnzymeLike,
    *,
    name: str = "",
) -> Assembly

Cut every part with enzyme and ligate them into one circular product.

Two ends join where their overhangs are equal, both being written on the top strand, so the parts are chained from the first one round until the circle closes. That first part sets the origin: the product is turned so its template's own first base keeps the place it had, which leaves the vector's coordinates readable and keeps a junction off base zero.

Parameters:

Name Type Description Default
parts Sequence[Part]

Two or more, the linearised vector first.

required
enzyme EnzymeLike

The Type IIS enzyme, which must be the one the parts carry tails for.

required
name str

What to call the product.

''

Returns:

Type Description
Assembly

The product, the parts that made it, and the junctions between them.

Raises:

Type Description
ValueError

If fewer than two parts are given, if cutting one does not leave the fragment its tails were designed for, or if the overhangs do not chain into one circle.

Source code in src/liulab_mbio/goldengate/assembly.py
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
def assemble(parts: Sequence[Part], enzyme: EnzymeLike, *, name: str = "") -> Assembly:
    """Cut every part with `enzyme` and ligate them into one circular product.

    Two ends join where their overhangs are equal, both being written on the top strand, so the
    parts are chained from the first one round until the circle closes. That first part sets the
    origin: the product is turned so its template's own first base keeps the place it had, which
    leaves the vector's coordinates readable and keeps a junction off base zero.

    Parameters
    ----------
    parts
        Two or more, the linearised vector first.
    enzyme
        The Type IIS enzyme, which must be the one the parts carry tails for.
    name
        What to call the product.

    Returns
    -------
    Assembly
        The product, the parts that made it, and the junctions between them.

    Raises
    ------
    ValueError
        If fewer than two parts are given, if cutting one does not leave the fragment its tails
        were designed for, or if the overhangs do not chain into one circle.
    """
    one = _enzyme(enzyme)
    if len(parts) < 2:
        raise ValueError(f"an assembly joins at least two parts, got {len(parts)}")
    order = _chain(parts)
    bases = ""
    features: list[Feature] = []
    primers: list[Primer] = []
    joins: list[tuple[int, Part, Part]] = []
    for place, index in enumerate(order):
        part, piece = parts[index], _cut(parts[index], one)
        at = len(bases)
        bases += part.amplicon.extract(Segment(piece.start, piece.end))
        carried, kept = _carried(part.template, *part.span, at - part.span[0])
        features.extend(carried)
        primers.extend(kept)
        primers.append(_placed(part.forward, at + len(part.left_overhang), Strand.FORWARD))
        primers.append(_placed(part.reverse, at + part.fragment_length, Strand.REVERSE))
        joins.append((at, parts[order[place - 1]], part))
    features.extend(_junction_feature(at, before, after, one) for at, before, after in joins)
    origin = _origin(parts[order[0]])
    product = SequenceRecord(
        bases, topology="circular", name=name, features=tuple(features), primers=tuple(primers)
    )
    return Assembly(
        _ordered(rotate(product, origin) if origin else product),
        tuple(parts),
        tuple(
            sorted(
                (
                    Junction(
                        (at - origin) % len(bases), after.left_overhang, before.name, after.name
                    )
                    for at, before, after in joins
                ),
                key=lambda junction: junction.start,
            )
        ),
        one,
    )

dam_sites

dam_sites(record: SequenceRecord) -> int

Count the sites in record that DpnI can cut once Dam has methylated them.

A plasmid grown in a Dam-positive host carries them methylated and a PCR product does not, which is what lets DpnI take the template away and leave the amplicon.

Examples:

>>> dam_sites(SequenceRecord("AAGATCAA"))
1
Source code in src/liulab_mbio/goldengate/assembly.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def dam_sites(record: SequenceRecord) -> int:
    """Count the sites in `record` that DpnI can cut once Dam has methylated them.

    A plasmid grown in a Dam-positive host carries them methylated and a PCR product does not,
    which is what lets DpnI take the template away and leave the amplicon.

    Examples
    --------
    >>> dam_sites(SequenceRecord("AAGATCAA"))
    1
    """
    haystack = record.sequence
    if record.topology == "circular":
        haystack += record.sequence[: len(DAM_SITE) - 1]
    return haystack.count(DAM_SITE)

open_vector

open_vector(
    vector: SequenceRecord,
    enzyme: EnzymeLike,
    start: int,
    end: int,
    *,
    overhangs: tuple[str, str],
    name: str = "",
    dpni: bool | None = None,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    spacer: str | None = None,
    spacer_length: int = SPACER_LENGTH,
    avoid: Iterable[EnzymeLike] = (),
) -> Part

Linearise a circular vector by PCR, replacing vector[start:end] with the assembly.

The primers face outward from that span, so the whole backbone amplifies and the plasmid that templated it is taken away with DpnI.

Parameters:

Name Type Description Default
vector SequenceRecord

The circular record to open.

required
enzyme EnzymeLike

The Type IIS enzyme the assembly is cut with.

required
start int

The span the assembly replaces.

required
end int

The span the assembly replaces.

required
overhangs tuple[str, str]

The two junctions, in the vector's own coordinates: the one standing at start, which the next part round the circle brings, and the one at end, which the backbone itself begins with.

required
name str

As amplify.

''
dpni str

As amplify.

''
polymerase str

As amplify.

''
thresholds str

As amplify.

''
spacer str

As amplify.

''
spacer_length str

As amplify.

''
avoid str

As amplify.

''

Returns:

Type Description
Part

The backbone, ready to assemble.

Raises:

Type Description
ValueError

If vector is not circular, or for any reason amplify refuses.

Source code in src/liulab_mbio/goldengate/assembly.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def open_vector(
    vector: SequenceRecord,
    enzyme: EnzymeLike,
    start: int,
    end: int,
    *,
    overhangs: tuple[str, str],
    name: str = "",
    dpni: bool | None = None,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    spacer: str | None = None,
    spacer_length: int = SPACER_LENGTH,
    avoid: Iterable[EnzymeLike] = (),
) -> Part:
    """Linearise a circular vector by PCR, replacing ``vector[start:end]`` with the assembly.

    The primers face outward from that span, so the whole backbone amplifies and the plasmid
    that templated it is taken away with DpnI.

    Parameters
    ----------
    vector
        The circular record to open.
    enzyme
        The Type IIS enzyme the assembly is cut with.
    start, end
        The span the assembly replaces.
    overhangs
        The two junctions, in the vector's own coordinates: the one standing at `start`, which
        the next part round the circle brings, and the one at `end`, which the backbone itself
        begins with.
    name, dpni, polymerase, thresholds, spacer, spacer_length, avoid
        As `amplify`.

    Returns
    -------
    Part
        The backbone, ready to assemble.

    Raises
    ------
    ValueError
        If `vector` is not circular, or for any reason `amplify` refuses.
    """
    if vector.topology != "circular":
        raise ValueError("a vector is opened outward across its origin, so it must be circular")
    at_start, at_end = overhangs
    return amplify(
        vector,
        enzyme,
        end,
        start + len(vector),
        left_overhang=at_end,
        right_overhang=at_start,
        name=name,
        dpni=dpni,
        polymerase=polymerase,
        thresholds=thresholds,
        spacer=spacer,
        spacer_length=spacer_length,
        avoid=avoid,
    )

liulab_mbio.goldengate.bench

What a Golden Gate assembly takes at the bench: its reaction and its cycling.

Every number is NEB's, through docs/research/golden-gate-assembly.md. Functions return liulab_mbio.protocol values, so a protocol prints them unchanged. What any cloning pipeline shares -- DNA amounts, PCR, gels and validation -- is liulab_mbio.bench.

NEB ships two Golden Gate systems and their tables do not mix. LIGASE_MASTER_MIX is NEBridge Ligase Master Mix (M1100), which takes any NEB Type IIS enzyme; KIT is one of the kits, which carry their own enzyme mix and so exist only for BsaI-HFv2 and BsmBI-v2.

Dose dataclass

How much Type IIS enzyme one reaction takes, and the units that is.

Source code in src/liulab_mbio/goldengate/bench.py
76
77
78
79
80
81
@dataclass(frozen=True, slots=True)
class Dose:
    """How much Type IIS enzyme one reaction takes, and the units that is."""

    volume_ul: float
    units: float

assembly_amounts

assembly_amounts(
    vector: tuple[str, int],
    inserts: tuple[tuple[str, int], ...],
    *,
    vector_pmol: float = FRAGMENT_PMOL,
    insert_ratio: float = INSERT_RATIO,
) -> tuple[Amount, ...]

Return what to put in the assembly, vector first.

The vector and each insert are a name and a length in base pairs. NEB asks for FRAGMENT_PMOL of the destination plasmid and the same of each precloned insert; insert_ratio is the insert to vector molar ratio for amplicon inserts.

Raises:

Type Description
ValueError

If a length is not positive.

Source code in src/liulab_mbio/goldengate/bench.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def assembly_amounts(
    vector: tuple[str, int],
    inserts: tuple[tuple[str, int], ...],
    *,
    vector_pmol: float = FRAGMENT_PMOL,
    insert_ratio: float = INSERT_RATIO,
) -> tuple[Amount, ...]:
    """Return what to put in the assembly, vector first.

    The vector and each insert are a name and a length in base pairs. NEB asks for
    `FRAGMENT_PMOL` of the destination plasmid and the same of each precloned insert;
    `insert_ratio` is the insert to vector molar ratio for amplicon inserts.

    Raises
    ------
    ValueError
        If a length is not positive.
    """
    return tuple(
        dna_amount(name, length_bp, pmol=pmol)
        for (name, length_bp), pmol in (
            (vector, vector_pmol),
            *((insert, vector_pmol * insert_ratio) for insert in inserts),
        )
    )

assembly_program

assembly_program(
    enzyme: Enzyme,
    *,
    fragments: int,
    system: System = LIGASE_MASTER_MIX,
    library: bool = False,
) -> ThermocyclerProgram

Return the cycling for this enzyme and fragment count, ending with the 60 °C end soak.

fragments counts the vector. library picks NEB's longer single-insert incubation, which it recommends for library construction rather than for cloning one gene.

Raises:

Type Description
ValueError

If fewer than two fragments are given, or the enzyme has no NEB protocol.

Source code in src/liulab_mbio/goldengate/bench.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def assembly_program(
    enzyme: Enzyme,
    *,
    fragments: int,
    system: System = LIGASE_MASTER_MIX,
    library: bool = False,
) -> ThermocyclerProgram:
    """Return the cycling for this enzyme and fragment count, ending with the 60 °C end soak.

    `fragments` counts the vector. `library` picks NEB's longer single-insert incubation, which
    it recommends for library construction rather than for cloning one gene.

    Raises
    ------
    ValueError
        If fewer than two fragments are given, or the enzyme has no NEB protocol.
    """
    if fragments < 2:
        raise ValueError("a Golden Gate reaction joins at least two fragments")
    celsius = golden_gate_temperature(enzyme)
    stages = (
        _kit_stages(celsius, fragments, library=library)
        if system == KIT
        else _master_mix_stages(celsius, fragments, library=library)
    )
    end_soak = Stage((Incubation("End soak", END_SOAK_CELSIUS, END_SOAK_SECONDS),))
    return ThermocyclerProgram((*stages, end_soak), title="Golden Gate assembly")

assembly_reaction

assembly_reaction(
    enzyme: Enzyme,
    amounts: tuple[Amount, ...],
    *,
    system: System = LIGASE_MASTER_MIX,
    reactions: int = 1,
) -> ReactionTable

Return the Golden Gate reaction for these fragments, the vector counted among them.

DNA goes in each tube, everything else into the master mix, which is the order NEB asks for. The PaqCI activator line appears only for PaqCI.

Raises:

Type Description
ValueError

If fewer than two fragments are given, if the enzyme has no NEB table for system, or if the DNA does not fit the reaction volume NEB's table sets.

Source code in src/liulab_mbio/goldengate/bench.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def assembly_reaction(
    enzyme: Enzyme,
    amounts: tuple[Amount, ...],
    *,
    system: System = LIGASE_MASTER_MIX,
    reactions: int = 1,
) -> ReactionTable:
    """Return the Golden Gate reaction for these fragments, the vector counted among them.

    DNA goes in each tube, everything else into the master mix, which is the order NEB asks for.
    The PaqCI activator line appears only for PaqCI.

    Raises
    ------
    ValueError
        If fewer than two fragments are given, if the enzyme has no NEB table for `system`, or
        if the DNA does not fit the reaction volume NEB's table sets.
    """
    if len(amounts) < 2:
        raise ValueError("a Golden Gate reaction joins at least two fragments")
    golden_gate_temperature(enzyme)
    total, components = (
        _kit_components(enzyme, amounts)
        if system == KIT
        else _master_mix_components(enzyme, amounts)
    )
    used = sum(component.volume_ul for component in components)
    if used >= total:
        raise ValueError(
            f"the DNA and enzyme take {used:g} µL of a {total:g} µL reaction; "
            "concentrate the fragments or scale the reaction up"
        )
    components.append(
        Component("Nuclease-free water", round(total - used, 2), final=f"to {total:g} µL")
    )
    return ReactionTable(
        tuple(components), title=_reaction_title(enzyme, system), reactions=reactions
    )

enzyme_component

enzyme_component(
    enzyme: Enzyme, fragments: int
) -> Component

Return the Type IIS enzyme component of a Ligase Master Mix reaction joining fragments.

Raises:

Type Description
ValueError

If NEB's Ligase Master Mix table has no row for the enzyme.

Source code in src/liulab_mbio/goldengate/bench.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
def enzyme_component(enzyme: Enzyme, fragments: int) -> Component:
    """Return the Type IIS enzyme component of a Ligase Master Mix reaction joining `fragments`.

    Raises
    ------
    ValueError
        If NEB's Ligase Master Mix table has no row for the enzyme.
    """
    if enzyme.name not in _ENZYME_DOSE:
        raise ValueError(f"NEB's Ligase Master Mix table has no row for {enzyme.name}")
    dose = _ENZYME_DOSE[enzyme.name][_dose_tier(fragments)]
    return Component(
        enzyme.supplier_label,
        dose.volume_ul,
        stock=f"{dose.units / dose.volume_ul:g} U/µL",
        final=f"{dose.units:g} units",
    )

golden_gate_temperature

golden_gate_temperature(enzyme: Enzyme) -> float

Return the temperature NEB's Golden Gate cycling runs this enzyme at, °C.

Raises:

Type Description
ValueError

If NEB publishes no Golden Gate protocol for it.

Examples:

>>> from liulab_mbio.enzymes import get_enzyme
>>> golden_gate_temperature(get_enzyme("BbsI-HF"))
37.0
Source code in src/liulab_mbio/goldengate/bench.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def golden_gate_temperature(enzyme: Enzyme) -> float:
    """Return the temperature NEB's Golden Gate cycling runs this enzyme at, °C.

    Raises
    ------
    ValueError
        If NEB publishes no Golden Gate protocol for it.

    Examples
    --------
    >>> from liulab_mbio.enzymes import get_enzyme
    >>> golden_gate_temperature(get_enzyme("BbsI-HF"))
    37.0
    """
    if enzyme.name not in _GOLDEN_GATE_CELSIUS:
        raise ValueError(f"NEB publishes no Golden Gate protocol for {enzyme.name}")
    return float(_GOLDEN_GATE_CELSIUS[enzyme.name])

ligase_master_mix_component

ligase_master_mix_component(fragments: int) -> Component

Return the NEBridge Ligase Master Mix component of a reaction joining fragments.

Source code in src/liulab_mbio/goldengate/bench.py
235
236
237
238
def ligase_master_mix_component(fragments: int) -> Component:
    """Return the NEBridge Ligase Master Mix component of a reaction joining `fragments`."""
    _, volume = _master_mix_volumes(fragments)
    return Component("NEBridge Ligase Master Mix", volume, stock="3X", final="1X")

liulab_mbio.goldengate.ligase

A ligase's own overhang profile, read from a matrix file the user already holds.

Potapov et al. 2018 profiled T4 DNA ligase across all 256 four-base overhangs, which covers the Type IIS enzymes nobody has published a matrix for. That archive is CC BY-NC 4.0, so none of it is in this package: read_profile reads a copy from the user's own disk and nothing is redistributed. docs/research/ligation-fidelity.md says where the archive is and what it holds.

A profile belongs to the ligase and the conditions it was measured under, not to the Type IIS enzyme, and liulab_mbio.goldengate.design.fidelity says so in the report it returns.

The two shapes the archive uses are both read here with the standard library alone: an .xlsx is a zip of XML, so zipfile and xml.etree are enough, and a .csv is read by csv.

LigaseProfile dataclass

How often each overhang pair was seen ligating, measured with one ligase.

Parameters:

Name Type Description Default
path Path

The file it was read from.

required
conditions str

The ligase, the incubation and the temperature. Empty where nothing states them.

required
overhang_length int

How many bases the overhangs on both axes have.

required
observations int

Every ligation event counted.

required
counts Mapping[str, Mapping[str, int]]

Top-strand overhang to the bottom-strand overhangs it was seen ligating to. Both are written 5' to 3', so a row pairs with the column spelling its reverse complement.

required
Source code in src/liulab_mbio/goldengate/ligase.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@dataclass(frozen=True, slots=True)
class LigaseProfile:
    """How often each overhang pair was seen ligating, measured with one ligase.

    Parameters
    ----------
    path
        The file it was read from.
    conditions
        The ligase, the incubation and the temperature. Empty where nothing states them.
    overhang_length
        How many bases the overhangs on both axes have.
    observations
        Every ligation event counted.
    counts
        Top-strand overhang to the bottom-strand overhangs it was seen ligating to. Both are
        written 5' to 3', so a row pairs with the column spelling its reverse complement.
    """

    path: Path
    _: KW_ONLY
    conditions: str
    overhang_length: int
    observations: int
    counts: Mapping[str, Mapping[str, int]] = field(hash=False)

    @property
    def overhangs(self) -> tuple[str, ...]:
        """Every overhang the measurement covers."""
        return tuple(self.counts)

    @property
    def source(self) -> str:
        """Where a report should say this number came from."""
        stated = self.conditions or "conditions not stated by the file name"
        return f"{stated}, read from {self.path.name}"

    def count(self, top: str, bottom: str) -> int:
        """How often a top-strand overhang was seen ligating to a bottom-strand one."""
        return self.counts.get(top.upper(), {}).get(bottom.upper(), 0)

    def normalised(self, top: str, bottom: str) -> float:
        """Return the count per 100,000 ligation events, the scale NEB's thresholds use."""
        return 100_000 * self.count(top, bottom) / self.observations

overhangs property

overhangs: tuple[str, ...]

Every overhang the measurement covers.

source property

source: str

Where a report should say this number came from.

count

count(top: str, bottom: str) -> int

How often a top-strand overhang was seen ligating to a bottom-strand one.

Source code in src/liulab_mbio/goldengate/ligase.py
76
77
78
def count(self, top: str, bottom: str) -> int:
    """How often a top-strand overhang was seen ligating to a bottom-strand one."""
    return self.counts.get(top.upper(), {}).get(bottom.upper(), 0)

normalised

normalised(top: str, bottom: str) -> float

Return the count per 100,000 ligation events, the scale NEB's thresholds use.

Source code in src/liulab_mbio/goldengate/ligase.py
80
81
82
def normalised(self, top: str, bottom: str) -> float:
    """Return the count per 100,000 ligation events, the scale NEB's thresholds use."""
    return 100_000 * self.count(top, bottom) / self.observations

column_index

column_index(reference: str) -> int

Return the 1-based column number of a cell reference such as "IW257".

Raises:

Type Description
ValueError

If the reference carries no column letters.

Examples:

>>> column_index("A1"), column_index("AA1")
(1, 27)
Source code in src/liulab_mbio/goldengate/ligase.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def column_index(reference: str) -> int:
    """Return the 1-based column number of a cell reference such as ``"IW257"``.

    Raises
    ------
    ValueError
        If the reference carries no column letters.

    Examples
    --------
    >>> column_index("A1"), column_index("AA1")
    (1, 27)
    """
    letters = _COLUMN.match(reference)
    if letters is None:
        raise ValueError(f"{reference!r} is not a cell reference")
    number = 0
    for letter in letters.group():
        number = number * 26 + ord(letter) - ord("A") + 1
    return number

read_csv

read_csv(text: str) -> dict[str, dict[str, int]]

Read a comma-separated count matrix into the sparse form read_workbook returns.

Raises:

Type Description
ValueError

If the file holds no row, or a cell holds something that is not a count.

Source code in src/liulab_mbio/goldengate/ligase.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def read_csv(text: str) -> dict[str, dict[str, int]]:
    """Read a comma-separated count matrix into the sparse form `read_workbook` returns.

    Raises
    ------
    ValueError
        If the file holds no row, or a cell holds something that is not a count.
    """
    rows = [row for row in csv.reader(io.StringIO(text)) if any(cell.strip() for cell in row)]
    if not rows:
        raise ValueError("it holds no rows")
    header, *body = rows
    columns = [cell.strip() for cell in header[1:]]
    counts: dict[str, dict[str, int]] = {}
    for cells in body:
        counts[cells[0].strip()] = {
            column: number
            for column, value in zip(columns, cells[1:], strict=False)
            if (number := _number(value))
        }
    return counts

read_profile

read_profile(
    path: str | PathLike[str], *, conditions: str = ""
) -> LigaseProfile

Read a ligation count matrix the user holds, as an .xlsx or a .csv file.

Parameters:

Name Type Description Default
path str | PathLike[str]

The matrix. Its conditions are read from its name where that is named the way the archive names one, such as FileS03_T4_18h_25C.xlsx.

required
conditions str

What the reaction was, for a file whose name does not say, or to correct one that does.

''

Raises:

Type Description
ValueError

If the file is not a count matrix, saying what one is.

Examples:

>>> read_profile("FileS03_T4_18h_25C.xlsx").conditions
'T4 DNA ligase, 18 h at 25 °C'
Source code in src/liulab_mbio/goldengate/ligase.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def read_profile(path: str | os.PathLike[str], *, conditions: str = "") -> LigaseProfile:
    """Read a ligation count matrix the user holds, as an `.xlsx` or a `.csv` file.

    Parameters
    ----------
    path
        The matrix. Its conditions are read from its name where that is named the way the
        archive names one, such as ``FileS03_T4_18h_25C.xlsx``.
    conditions
        What the reaction was, for a file whose name does not say, or to correct one that does.

    Raises
    ------
    ValueError
        If the file is not a count matrix, saying what one is.

    Examples
    --------
    >>> read_profile("FileS03_T4_18h_25C.xlsx").conditions  # doctest: +SKIP
    'T4 DNA ligase, 18 h at 25 °C'
    """
    one = Path(path)
    try:
        counts, length = _matrix(one)
    except (KeyError, ValueError, zipfile.BadZipFile, ElementTree.ParseError) as error:
        raise ValueError(
            f"{one.name} is not a ligation count matrix ({error}); expected {EXPECTED}"
        ) from error
    return LigaseProfile(
        one,
        conditions=conditions or _conditions(one.name),
        overhang_length=length,
        observations=sum(sum(row.values()) for row in counts.values()),
        counts=counts,
    )

read_workbook

read_workbook(
    blob: bytes,
) -> tuple[str, dict[str, dict[str, int]]]

Read one workbook into its sheet name and a sparse count matrix.

The first row and the first column hold the overhang labels; every other cell is an observation count, and a zero is dropped.

Raises:

Type Description
ValueError

If a cell is neither a shared string nor a number, which is not a shape these workbooks have and so is a sign the file is not the one expected.

Source code in src/liulab_mbio/goldengate/ligase.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def read_workbook(blob: bytes) -> tuple[str, dict[str, dict[str, int]]]:
    """Read one workbook into its sheet name and a sparse count matrix.

    The first row and the first column hold the overhang labels; every other cell is an
    observation count, and a zero is dropped.

    Raises
    ------
    ValueError
        If a cell is neither a shared string nor a number, which is not a shape these
        workbooks have and so is a sign the file is not the one expected.
    """
    with zipfile.ZipFile(io.BytesIO(blob)) as archive:
        sheet_name = _sheet_name(archive)
        shared = _shared_strings(archive)
        rows = _rows(archive, shared)
    header, *body = rows
    # Keyed by column number, because a row drops the cells it has no count for.
    columns = {column_index(reference): str(label) for reference, label in header}
    counts: dict[str, dict[str, int]] = {}
    for cells in body:
        values = {column_index(reference): value for reference, value in cells}
        counts[str(values[1])] = {
            columns[index]: value
            for index, value in values.items()
            if index != 1 and isinstance(value, int) and value
        }
    return sheet_name, counts

liulab_mbio.goldengate.oligos

Every oligo a Golden Gate plan orders, and what it is for.

DesignedOligo dataclass

One oligo a plan orders, and what it is for.

Parameters:

Name Type Description Default
report PrimerReport

What it scored, the primer included.

required
role PrimerRole

What it is for: amplifying a part, colony PCR, or sequencing the clone. It chooses the thresholds the oligo is designed and judged by.

required
part Part | None

The part it amplifies, given for an amplification primer and for nothing else.

None

Raises:

Type Description
ValueError

If a part is given for any role but amplification, or not given for amplification.

Source code in src/liulab_mbio/goldengate/oligos.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@dataclass(frozen=True, slots=True)
class DesignedOligo:
    """One oligo a plan orders, and what it is for.

    Parameters
    ----------
    report
        What it scored, the primer included.
    role
        What it is for: amplifying a part, colony PCR, or sequencing the clone. It chooses the
        thresholds the oligo is designed and judged by.
    part
        The part it amplifies, given for an amplification primer and for nothing else.

    Raises
    ------
    ValueError
        If a part is given for any role but amplification, or not given for amplification.
    """

    report: PrimerReport
    role: PrimerRole
    part: Part | None = None

    def __post_init__(self) -> None:
        """Refuse a part on anything but an amplification primer, and one missing from it."""
        if (self.role == "amplification") != (self.part is not None):
            raise ValueError(
                f"oligo {self.report.primer.name!r}: an amplification primer names its part, "
                "and no other role does"
            )

__post_init__

__post_init__() -> None

Refuse a part on anything but an amplification primer, and one missing from it.

Source code in src/liulab_mbio/goldengate/oligos.py
33
34
35
36
37
38
39
def __post_init__(self) -> None:
    """Refuse a part on anything but an amplification primer, and one missing from it."""
    if (self.role == "amplification") != (self.part is not None):
        raise ValueError(
            f"oligo {self.report.primer.name!r}: an amplification primer names its part, "
            "and no other role does"
        )

liulab_mbio.goldengate.steps

The Golden Gate bench protocol: its own two steps, its own notes, and the step order.

The steps any bench shares are liulab_mbio.bench.steps. This module runs them around the assembly and its cycling and adds what only Golden Gate has to say. Every number is computed by liulab_mbio.goldengate.plan or by the modules it calls; the constants below are the choices no table of NEB's covers, and each says where it comes from.

protocol

protocol(
    *,
    vector: SequenceRecord,
    span: tuple[int, int],
    overhangs: OverhangSet,
    linearised_vector: Part,
    insert_parts: Sequence[Part],
    assembly: Assembly,
    colony: ColonyCheck,
    reads: Sequence[SangerRead],
    amounts: tuple[Amount, ...],
    phenotype: Phenotype,
    oligos: Sequence[DesignedOligo],
    checks: Sequence[Check],
    host: str,
    polymerase: Polymerase,
    thresholds: Mapping[PrimerRole, Thresholds],
) -> Protocol

Return the bench protocol for one planned assembly, ready to render.

Each argument is the liulab_mbio.goldengate.plan.Plan field or property of that name, and oligos is Plan.designed_oligos. The steps run in the order someone does them: one PCR per part, the gel that checks them, the DpnI digest and cleanup, quantification, the assembly, transformation and plating, colony PCR, and sequencing. Every step is per experiment, however many parts there are.

Source code in src/liulab_mbio/goldengate/steps.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def protocol(
    *,
    vector: SequenceRecord,
    span: tuple[int, int],
    overhangs: OverhangSet,
    linearised_vector: Part,
    insert_parts: Sequence[Part],
    assembly: Assembly,
    colony: ColonyCheck,
    reads: Sequence[SangerRead],
    amounts: tuple[Amount, ...],
    phenotype: Phenotype,
    oligos: Sequence[DesignedOligo],
    checks: Sequence[judged.Check],
    host: str,
    polymerase: Polymerase,
    thresholds: Mapping[PrimerRole, Thresholds],
) -> Protocol:
    """Return the bench protocol for one planned assembly, ready to render.

    Each argument is the `liulab_mbio.goldengate.plan.Plan` field or property of that name, and
    `oligos` is `Plan.designed_oligos`. The steps run in the order someone does them: one PCR
    per part, the gel that checks them, the DpnI digest and cleanup, quantification, the
    assembly, transformation and plating, colony PCR, and sequencing. Every step is per
    experiment, however many parts there are.
    """
    parts = (linearised_vector, *insert_parts)
    names = tuple(part.name for part in insert_parts)
    enzyme = assembly.enzyme
    inserts = listed(names)
    return Protocol(
        f"Golden Gate assembly: {inserts} into {vector.name}",
        summary=(
            f"Open {vector.name} by PCR across {span[0]}-{span[1]}, amplify "
            f"{inserts} with {enzyme.name} tails, join the {len(parts)} fragments in "
            "one Golden Gate reaction, and confirm the clone by colony PCR and sequencing."
        ),
        overview=_overview(vector, insert_parts, assembly, overhangs, phenotype),
        highlights=_highlights(parts, phenotype, names),
        checks=_checks(checks),
        materials=_materials(
            vector=vector,
            parts=parts,
            inserts=names,
            colony=colony,
            enzyme=enzyme,
            host=host,
            polymerase=polymerase,
            phenotype=phenotype,
        ),
        oligos=tuple(
            oligo_row(oligo.report, purpose=_purpose(oligo), thresholds=thresholds[oligo.role])
            for oligo in oligos
        ),
        equipment=EQUIPMENT,
        steps=_steps(
            parts=parts,
            inserts=names,
            assembly=assembly,
            overhangs=overhangs,
            amounts=amounts,
            colony=colony,
            reads=reads,
            phenotype=phenotype,
            host=host,
            polymerase=polymerase,
        ),
        references=_references(parts, overhangs, phenotype),
    )

The command line

The whole module, because typer makes every verb a plain function with a docstring, and liulab_mbio.cli:app — the object [project.scripts] registers — is built from them.

liulab_mbio.cli

The command line: a version, and a sub-app for each thing the package does.

Typer, because every lab repo that ships a command line uses it: one typer.Typer named app, no_args_is_help=True so a bare invocation prints help instead of nothing, and a version command. A verb over a pipeline is mounted as a sub-app with app.add_typer.

version

version() -> None

Print the installed package version.

Source code in src/liulab_mbio/cli.py
25
26
27
28
@app.command()
def version() -> None:
    """Print the installed package version."""
    typer.echo(_package_version)

liulab_mbio.goldengate.cli

The goldengate verbs, mounted on the package command line.

plan

plan(
    vector: Annotated[
        Path,
        Argument(
            exists=True,
            dir_okay=False,
            readable=True,
            help="Vector sequence file.",
        ),
    ],
    inserts: Annotated[
        list[Path],
        Argument(
            exists=True,
            dir_okay=False,
            readable=True,
            help="Insert sequence files, in the order they go round the product.",
        ),
    ],
    out: Annotated[
        Path,
        Option(
            --out,
            -o,
            file_okay=False,
            help="Directory to write the three outputs into.",
        ),
    ],
    site: Annotated[
        str,
        Option(
            help="Feature name, or START-END, that the inserts replace."
        ),
    ] = "",
    orientation: Annotated[
        list[str] | None,
        Option(
            help="Which way round an insert goes: forward or reverse; once per insert."
        ),
    ] = None,
    in_frame: Annotated[
        bool,
        Option(
            "--in-frame",
            help="Hold every insert's junction on a codon boundary.",
        ),
    ] = False,
    enzyme: Annotated[
        str,
        Option(
            help="Type IIS enzyme to use; the best free one when not given."
        ),
    ] = "",
    ligase_matrix: Annotated[
        Path | None,
        Option(
            --ligase - matrix,
            envvar=LIGASE_MATRIX_ENV,
            exists=True,
            dir_okay=False,
            readable=True,
            help="A ligase fidelity matrix you hold (.xlsx or .csv), to score the overhangs of an enzyme nobody has measured.",
        ),
    ] = None,
    prefer_ligase_matrix: Annotated[
        bool,
        Option(
            --prefer - ligase - matrix,
            help="Score with that matrix even where the enzyme has a measured one.",
        ),
    ] = False,
    polymerase: Annotated[
        str, Option(help="Polymerase for the two PCRs.")
    ] = name,
    host: Annotated[
        str, Option(help="Strain the protocol names.")
    ] = DEFAULT_HOST,
    name: Annotated[
        str, Option(help="What to call the product.")
    ] = "",
) -> None

Plan an assembly and write the product, the primer sheet and the protocol into OUT.

Source code in src/liulab_mbio/goldengate/cli.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
@app.command()
def plan(
    vector: Annotated[
        Path,
        typer.Argument(exists=True, dir_okay=False, readable=True, help="Vector sequence file."),
    ],
    inserts: Annotated[
        list[Path],
        typer.Argument(
            exists=True,
            dir_okay=False,
            readable=True,
            help="Insert sequence files, in the order they go round the product.",
        ),
    ],
    out: Annotated[
        Path,
        typer.Option(
            "--out", "-o", file_okay=False, help="Directory to write the three outputs into."
        ),
    ],
    site: Annotated[
        str,
        typer.Option(help="Feature name, or START-END, that the inserts replace."),
    ] = "",
    orientation: Annotated[
        list[str] | None,
        typer.Option(help="Which way round an insert goes: forward or reverse; once per insert."),
    ] = None,
    in_frame: Annotated[
        bool,
        typer.Option("--in-frame", help="Hold every insert's junction on a codon boundary."),
    ] = False,
    enzyme: Annotated[
        str, typer.Option(help="Type IIS enzyme to use; the best free one when not given.")
    ] = "",
    ligase_matrix: Annotated[
        Path | None,
        typer.Option(
            "--ligase-matrix",
            envvar=LIGASE_MATRIX_ENV,
            exists=True,
            dir_okay=False,
            readable=True,
            help="A ligase fidelity matrix you hold (.xlsx or .csv), to score the overhangs of "
            "an enzyme nobody has measured.",
        ),
    ] = None,
    prefer_ligase_matrix: Annotated[
        bool,
        typer.Option(
            "--prefer-ligase-matrix",
            help="Score with that matrix even where the enzyme has a measured one.",
        ),
    ] = False,
    polymerase: Annotated[str, typer.Option(help="Polymerase for the two PCRs.")] = Q5.name,
    host: Annotated[str, typer.Option(help="Strain the protocol names.")] = DEFAULT_HOST,
    name: Annotated[str, typer.Option(help="What to call the product.")] = "",
) -> None:
    """Plan an assembly and write the product, the primer sheet and the protocol into OUT."""
    try:
        made = plan_assembly(
            vector,
            *inserts,
            site=_site(site),
            orientation=_orientations(orientation, len(inserts)),
            in_frame=in_frame,
            enzyme=enzyme or None,
            profile=ligase_matrix,
            prefer_profile=prefer_ligase_matrix,
            polymerase=_polymerase(polymerase),
            host=host,
            name=name,
        )
        outputs = made.write(out)
    except (KeyError, ValueError) as error:
        typer.echo(f"error: {error}", err=True)
        raise typer.Exit(1) from error
    scored = made.overhangs.fidelity
    typer.echo(
        f"{made.product.name}: {len(made.product)} bp, {made.enzyme.name}, "
        f"{len(made.parts)} fragments, overhangs {', '.join(made.overhangs.overhangs)}, "
        f"fidelity {scored.value:.0%} ({scored.label}), checks {made.status}"
    )
    for path in (outputs.product, outputs.primers, outputs.protocol):
        typer.echo(str(path))

liulab_mbio.protocol.cli

The protocol verbs, mounted on the package command line.

render

render(
    source: Annotated[
        Path,
        Argument(
            exists=True,
            dir_okay=False,
            readable=True,
            help="Protocol JSON file.",
        ),
    ],
    output: Annotated[
        Path | None,
        Option(
            --output,
            -o,
            help="HTML file to write; default: SOURCE with .html.",
        ),
    ] = None,
) -> None

Render a protocol JSON file to one self-contained HTML file.

Source code in src/liulab_mbio/protocol/cli.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@app.command()
def render(
    source: Annotated[
        Path,
        typer.Argument(exists=True, dir_okay=False, readable=True, help="Protocol JSON file."),
    ],
    output: Annotated[
        Path | None,
        typer.Option("--output", "-o", help="HTML file to write; default: SOURCE with .html."),
    ] = None,
) -> None:
    """Render a protocol JSON file to one self-contained HTML file."""
    try:
        protocol = read_protocol(source)
    except ValueError as error:
        typer.echo(f"error: {source}: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(str(write_html(protocol, output or source.with_suffix(".html"))))