Skip to content

Python reference

These are the calls a script makes. The list is kept by hand, so it stays short. Anything not here is read from the source in src/mbio/ and src/synbio/.

What to import

The root package re-exports only __version__. Import a name from the module that owns it.

from mbio.io import read_record
from mbio.cloning.goldengate import plan_assembly

A few names are spelled more than once, and each one means something different. The module path is what tells them apart.

Name Resolves to
Check mbio.checks.Check is the judged check, with the value it measured. mbio.protocol.Check is how a protocol page shows one
Plan Four of them, one per cloning method, each with its own fields. All four are on this page
Build, read_build synbio.igga and synbio.dmx each define both, with different fields. Both pairs are on this page
Protocol mbio.protocol.Protocol is the bench protocol. synbio.igga.protocols.protocol.Protocol is one page of a library run
Files Six of them, one per pipeline. None is on this page. Read what write returns

Open and save a record

read_record takes a SnapGene .dna file, a GenBank file or a FASTA file. read_region pulls one span out of a large FASTA, through the .fai index beside it.

mbio.io.read_record

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

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

The colours SnapGene writes into a GenBank file's notes become Feature.color and Segment.color, and those notes leave the qualifiers.

Raises:

Type Description
ValueError

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

Source code in src/mbio/io.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def read_record(path: str | os.PathLike[str]) -> SequenceRecord:
    """Read a ``.dna``, GenBank or FASTA file holding one sequence.

    The colours SnapGene writes into a GenBank file's notes become `Feature.color` and
    `Segment.color`, and those notes leave the qualifiers.

    Raises
    ------
    ValueError
        If the suffix names no reader, or the file holds no single record.
    """
    suffix = Path(path).suffix.lower()
    if suffix == ".dna":
        from 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

    text = Path(path).read_text()
    return _converted(SeqIO.read(StringIO(text), fmt), _segment_lists(text))

mbio.io.read_region

read_region(
    path: str | PathLike[str],
    sequence_name: str,
    start: int,
    end: int,
) -> SequenceRecord

Read one span of one sequence of a FASTA, through the .fai index beside it.

Only the bytes the index points at are read, so a region of a chromosome costs the same on a whole genome as on a small file. start and end are 0-based and half-open: a start below zero reads from the first base, and an end past the sequence reads to its last. The record comes back linear and upper-case, named as the FASTA spells it.

Raises:

Type Description
FileNotFoundError

If no index lies beside the FASTA.

ValueError

If the index names no such sequence, the span holds no base of it, or the FASTA does not read as the index describes.

Examples:

>>> read_region("hg38.fa", "chr1", 156710401, 156710421).sequence
'ACCTAGGAGAAGTGGCCAGC'
Source code in src/mbio/io.py
 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
def read_region(
    path: str | os.PathLike[str], sequence_name: str, start: int, end: int
) -> SequenceRecord:
    """Read one span of one sequence of a FASTA, through the `.fai` index beside it.

    Only the bytes the index points at are read, so a region of a chromosome costs the same on
    a whole genome as on a small file. `start` and `end` are 0-based and half-open: a `start`
    below zero reads from the first base, and an `end` past the sequence reads to its last. The
    record comes back linear and upper-case, named as the FASTA spells it.

    Raises
    ------
    FileNotFoundError
        If no index lies beside the FASTA.
    ValueError
        If the index names no such sequence, the span holds no base of it, or the FASTA does
        not read as the index describes.

    Examples
    --------
    >>> read_region("hg38.fa", "chr1", 156710401, 156710421).sequence  # doctest: +SKIP
    'ACCTAGGAGAAGTGGCCAGC'
    """
    fasta = Path(path)
    index = fasta.with_name(fasta.name + ".fai")
    if not index.is_file():
        raise FileNotFoundError(
            f"{fasta} has no index beside it: `genome assembly register` writes {index.name}"
        )
    length, offset, bases_per_line, bytes_per_line = _indexed(index, sequence_name)
    first, last = max(start, 0), min(end, length)
    if first >= last:
        raise ValueError(f"{sequence_name} is {length} bases: none of it lies in {start}-{end}")
    here = offset + _byte(first, bases_per_line, bytes_per_line)
    with fasta.open("rb") as handle:
        handle.seek(here)
        read = handle.read(offset + _byte(last, bases_per_line, bytes_per_line) - here)
    bases = read.replace(b"\r", b"").replace(b"\n", b"").decode()
    if len(bases) != last - first:
        raise ValueError(f"{fasta} does not read as {index.name} describes it")
    return SequenceRecord(bases, name=sequence_name)

mbio.snapgene.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/mbio/snapgene.py
259
260
261
262
263
264
265
266
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))

The record

Coordinates are 0-based and half-open. The first base is 0, and a span's end is the first base after it. A span that crosses the origin of a circular record ends past the record's length.

mbio.sequence.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 and primer binding site must fit the sequence under its topology. On a circular record a feature's first segment fits, the rest follow it in ascending order, and the whole spans at most one turn.

()
primers tuple[Feature, ...]

Every feature and primer binding site must fit the sequence under its topology. On a circular record a feature's first segment fits, the rest follow it in ascending order, and the whole spans at most one turn.

()
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/mbio/sequence.py
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
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
@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 and primer binding site must fit the sequence under its topology. On a
        circular record a feature's first segment fits, the rest follow it in ascending order,
        and the whole spans at most one turn.
    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:
            self._check_feature(feature)
        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. Each segment reads as `bases` reads it.

        Raises
        ------
        ValueError
            If the feature or segment does not fit this record, as an annotation must.

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

    def bases(self, start: int, end: int) -> str:
        """Return the top-strand bases in ``[start, end)``, reading across the origin.

        On a circular record a position counts round the circle, so `start` may lie outside the
        record; the span is at most one turn long.

        Raises
        ------
        ValueError
            If `end` comes before `start`, or the span runs off a linear record or is longer than
            a circular one.

        Examples
        --------
        >>> SequenceRecord("AACCGGTTAC", topology="circular").bases(-2, 2)
        'ACAA'
        """
        n = len(self.sequence)
        span = f"span {start}-{end}"
        if end < start:
            raise ValueError(f"{span} ends before it starts")
        if self.topology == "linear":
            if start < 0 or end > n:
                raise ValueError(f"{span} runs past the end of a linear record of {n} bases")
            return self.sequence[start:end]
        if end - start > n:
            raise ValueError(f"{span} is longer than a circular record of {n} bases")
        if start == end:
            return ""
        first = start % n
        last = first + end - start
        if last <= n:
            return self.sequence[first:last]
        return self.sequence[first:] + self.sequence[: last - n]

    def covers(
        self, span: Feature | Segment | BindingSite, inner: int | Segment | BindingSite
    ) -> bool:
        """Whether `span` holds a position, or the whole of a shorter span, across the origin too.

        A feature holds what one of its segments holds. On a circular record a position counts
        round the circle, so one past the length names the base it reaches there.

        Examples
        --------
        >>> SequenceRecord("AACCGGTTAC", topology="circular").covers(Segment(8, 12), 1)
        True
        """
        segments = span.segments if isinstance(span, Feature) else (span,)
        first, last = (inner, inner + 1) if isinstance(inner, int) else (inner.start, inner.end)
        n = len(self.sequence)
        if self.topology == "linear":
            return any(one.start <= first and last <= one.end for one in segments)
        return any(
            (first - one.start) % n + last - first <= one.end - one.start for one in segments
        )

    def fits(self, start: int, end: int) -> bool:
        """Whether ``[start, end)`` lies on this record, as every span it annotates must.

        On a linear record it lies inside the bases. On a circular one it starts inside them and
        is at most one turn long, ending past the length across the origin. An empty span fits
        wherever a base could be inserted.

        Examples
        --------
        >>> SequenceRecord("AACCGGTTAC", topology="circular").fits(8, 12)
        True
        """
        n = len(self.sequence)
        if not 0 <= start <= end:
            return False
        if self.topology == "linear":
            return end <= n
        return start < n and end - start <= n

    def span(self, start: int, width: int) -> Segment:
        """Return the `width` bases at `start`, counted round the origin, clipped on a line.

        A circular record counts `start` round the circle, so a span reaching past the last
        base ends past the record's length. A linear record has nothing outside its bases, so
        a span hanging off either end is cut back to them. `bases` is handed the far end
        instead of a width.

        Raises
        ------
        ValueError
            If nothing is left: a width of no bases, or a linear span wholly off the record.

        Examples
        --------
        >>> SequenceRecord("AACCGGTTAC", topology="circular").span(18, 4)
        Segment(start=8, end=12, name='', color=None)
        >>> SequenceRecord("AACCGGTTAC").span(-2, 6)
        Segment(start=0, end=4, name='', color=None)
        """
        if self.topology == "circular":
            first = start % len(self)
            return Segment(first, first + width)
        return Segment(max(start, 0), min(start + width, len(self)))

    def _check_feature(self, feature: Feature) -> None:
        owner, segments = f"feature {feature.name!r}", feature.segments
        if self.topology == "linear":
            for segment in segments:
                self._check_fits(segment.start, segment.end, owner)
            return
        for before, after in pairwise(segments):
            if after.start < before.start:
                raise ValueError(
                    f"{owner} lists span {after.start}-{after.end} after {before.start}-"
                    f"{before.end}: past the origin of a circular record, a span starts past "
                    "its length"
                )
        self._check_fits(segments[0].start, max(one.end for one in segments), owner)

    def _check_fits(self, start: int, end: int, owner: str) -> None:
        if self.fits(start, end):
            return
        n = len(self.sequence)
        span = f"{owner} span {start}-{end}"
        if self.topology == "linear":
            raise ValueError(f"{span} runs past the end of a linear record of {n} bases")
        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/mbio/sequence.py
274
275
276
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/mbio/sequence.py
263
264
265
266
267
268
269
270
271
272
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:
        self._check_feature(feature)
    for primer in self.primers:
        for site in primer.binding_sites:
            self._check_fits(site.start, site.end, f"primer {primer.name!r}")

bases

bases(start: int, end: int) -> str

Return the top-strand bases in [start, end), reading across the origin.

On a circular record a position counts round the circle, so start may lie outside the record; the span is at most one turn long.

Raises:

Type Description
ValueError

If end comes before start, or the span runs off a linear record or is longer than a circular one.

Examples:

>>> SequenceRecord("AACCGGTTAC", topology="circular").bases(-2, 2)
'ACAA'
Source code in src/mbio/sequence.py
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
def bases(self, start: int, end: int) -> str:
    """Return the top-strand bases in ``[start, end)``, reading across the origin.

    On a circular record a position counts round the circle, so `start` may lie outside the
    record; the span is at most one turn long.

    Raises
    ------
    ValueError
        If `end` comes before `start`, or the span runs off a linear record or is longer than
        a circular one.

    Examples
    --------
    >>> SequenceRecord("AACCGGTTAC", topology="circular").bases(-2, 2)
    'ACAA'
    """
    n = len(self.sequence)
    span = f"span {start}-{end}"
    if end < start:
        raise ValueError(f"{span} ends before it starts")
    if self.topology == "linear":
        if start < 0 or end > n:
            raise ValueError(f"{span} runs past the end of a linear record of {n} bases")
        return self.sequence[start:end]
    if end - start > n:
        raise ValueError(f"{span} is longer than a circular record of {n} bases")
    if start == end:
        return ""
    first = start % n
    last = first + end - start
    if last <= n:
        return self.sequence[first:last]
    return self.sequence[first:] + self.sequence[: last - n]

covers

covers(
    span: Feature | Segment | BindingSite,
    inner: int | Segment | BindingSite,
) -> bool

Whether span holds a position, or the whole of a shorter span, across the origin too.

A feature holds what one of its segments holds. On a circular record a position counts round the circle, so one past the length names the base it reaches there.

Examples:

>>> SequenceRecord("AACCGGTTAC", topology="circular").covers(Segment(8, 12), 1)
True
Source code in src/mbio/sequence.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def covers(
    self, span: Feature | Segment | BindingSite, inner: int | Segment | BindingSite
) -> bool:
    """Whether `span` holds a position, or the whole of a shorter span, across the origin too.

    A feature holds what one of its segments holds. On a circular record a position counts
    round the circle, so one past the length names the base it reaches there.

    Examples
    --------
    >>> SequenceRecord("AACCGGTTAC", topology="circular").covers(Segment(8, 12), 1)
    True
    """
    segments = span.segments if isinstance(span, Feature) else (span,)
    first, last = (inner, inner + 1) if isinstance(inner, int) else (inner.start, inner.end)
    n = len(self.sequence)
    if self.topology == "linear":
        return any(one.start <= first and last <= one.end for one in segments)
    return any(
        (first - one.start) % n + last - first <= one.end - one.start for one in segments
    )

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. Each segment reads as bases reads it.

Raises:

Type Description
ValueError

If the feature or segment does not fit this record, as an annotation must.

Examples:

>>> SequenceRecord("AACCGGTTAC", topology="circular").extract(Segment(8, 12))
'ACAA'
Source code in src/mbio/sequence.py
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
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. Each segment reads as `bases` reads it.

    Raises
    ------
    ValueError
        If the feature or segment does not fit this record, as an annotation must.

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

fits

fits(start: int, end: int) -> bool

Whether [start, end) lies on this record, as every span it annotates must.

On a linear record it lies inside the bases. On a circular one it starts inside them and is at most one turn long, ending past the length across the origin. An empty span fits wherever a base could be inserted.

Examples:

>>> SequenceRecord("AACCGGTTAC", topology="circular").fits(8, 12)
True
Source code in src/mbio/sequence.py
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def fits(self, start: int, end: int) -> bool:
    """Whether ``[start, end)`` lies on this record, as every span it annotates must.

    On a linear record it lies inside the bases. On a circular one it starts inside them and
    is at most one turn long, ending past the length across the origin. An empty span fits
    wherever a base could be inserted.

    Examples
    --------
    >>> SequenceRecord("AACCGGTTAC", topology="circular").fits(8, 12)
    True
    """
    n = len(self.sequence)
    if not 0 <= start <= end:
        return False
    if self.topology == "linear":
        return end <= n
    return start < n and end - start <= n

span

span(start: int, width: int) -> Segment

Return the width bases at start, counted round the origin, clipped on a line.

A circular record counts start round the circle, so a span reaching past the last base ends past the record's length. A linear record has nothing outside its bases, so a span hanging off either end is cut back to them. bases is handed the far end instead of a width.

Raises:

Type Description
ValueError

If nothing is left: a width of no bases, or a linear span wholly off the record.

Examples:

>>> SequenceRecord("AACCGGTTAC", topology="circular").span(18, 4)
Segment(start=8, end=12, name='', color=None)
>>> SequenceRecord("AACCGGTTAC").span(-2, 6)
Segment(start=0, end=4, name='', color=None)
Source code in src/mbio/sequence.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def span(self, start: int, width: int) -> Segment:
    """Return the `width` bases at `start`, counted round the origin, clipped on a line.

    A circular record counts `start` round the circle, so a span reaching past the last
    base ends past the record's length. A linear record has nothing outside its bases, so
    a span hanging off either end is cut back to them. `bases` is handed the far end
    instead of a width.

    Raises
    ------
    ValueError
        If nothing is left: a width of no bases, or a linear span wholly off the record.

    Examples
    --------
    >>> SequenceRecord("AACCGGTTAC", topology="circular").span(18, 4)
    Segment(start=8, end=12, name='', color=None)
    >>> SequenceRecord("AACCGGTTAC").span(-2, 6)
    Segment(start=0, end=4, name='', color=None)
    """
    if self.topology == "circular":
        first = start % len(self)
        return Segment(first, first + width)
    return Segment(max(start, 0), min(start + width, len(self)))

mbio.sequence.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 the order the feature reads them along the top strand, so a reverse-strand feature reads them last to first. On a circular record they start in ascending order: a segment after the origin starts past the record's length, as one across it ends past it. A linear record has no origin to cross, so a feature cut apart at its two ends lists the higher start first.

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/mbio/sequence.py
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
@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 the order the feature reads them along the top strand, so a reverse-strand feature
        reads them last to first. On a circular record they start in ascending order: a segment
        after the origin starts past the record's length, as one across it ends past it. A linear
        record has no origin to cross, so a feature cut apart at its two ends lists the higher
        start first.
    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")

    def counted_round(self, length: int) -> "Feature":
        """Return this feature as a circular record of `length` bases holds it.

        Each segment keeps its width and its place in the reading order, starts where it lies in
        the record, and is then counted round past the length if it comes after the origin.

        Examples
        --------
        >>> Feature("f", "CDS", (Segment(91, 98), Segment(2, 8))).counted_round(100).segments[1]
        Segment(start=102, end=108, name='', color=None)
        """
        starts = counted_round((one.start % length for one in self.segments), length)
        return dataclasses.replace(
            self,
            segments=tuple(
                dataclasses.replace(one, start=start, end=start + one.end - one.start)
                for start, one in zip(starts, self.segments, strict=True)
            ),
        )

__post_init__

__post_init__() -> None

Refuse a feature with no segment.

Source code in src/mbio/sequence.py
139
140
141
142
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")

counted_round

counted_round(length: int) -> Feature

Return this feature as a circular record of length bases holds it.

Each segment keeps its width and its place in the reading order, starts where it lies in the record, and is then counted round past the length if it comes after the origin.

Examples:

>>> Feature("f", "CDS", (Segment(91, 98), Segment(2, 8))).counted_round(100).segments[1]
Segment(start=102, end=108, name='', color=None)
Source code in src/mbio/sequence.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def counted_round(self, length: int) -> "Feature":
    """Return this feature as a circular record of `length` bases holds it.

    Each segment keeps its width and its place in the reading order, starts where it lies in
    the record, and is then counted round past the length if it comes after the origin.

    Examples
    --------
    >>> Feature("f", "CDS", (Segment(91, 98), Segment(2, 8))).counted_round(100).segments[1]
    Segment(start=102, end=108, name='', color=None)
    """
    starts = counted_round((one.start % length for one in self.segments), length)
    return dataclasses.replace(
        self,
        segments=tuple(
            dataclasses.replace(one, start=start, end=start + one.end - one.start)
            for start, one in zip(starts, self.segments, strict=True)
        ),
    )

mbio.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". A .dna file carries it both ways; a GenBank file read here carries it in, and nothing here writes it out.

''
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/mbio/sequence.py
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
@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"``. A ``.dna`` file carries
        it both ways; a GenBank file read here carries it in, and nothing here writes it out.
    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/mbio/sequence.py
97
98
99
def __post_init__(self) -> None:
    """Refuse an empty or negative span."""
    _check_span(self.start, self.end)

mbio.sequence.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/mbio/sequence.py
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
@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/mbio/sequence.py
221
222
223
def __post_init__(self) -> None:
    """Upper-case and check the sequence."""
    object.__setattr__(self, "sequence", _dna(self.sequence))

mbio.sequence.Strand

Bases: IntEnum

The strand a feature or primer binding site lies on.

Source code in src/mbio/sequence.py
60
61
62
63
64
65
66
67
class Strand(IntEnum):
    """The strand a feature or primer binding site lies on."""

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

mbio.sequence.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/mbio/sequence.py
37
38
39
40
41
42
43
44
45
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]

A check

Most of what the package judges comes back as a list of these. One check holds the name of what was measured, the measurement, and a status. Where no sourced threshold can judge the value, the check carries no verdict rather than a pass.

mbio.checks.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/mbio/checks.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@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 = ""

Plan a cloning job

Four methods, four ways in. Each one returns a Plan. Each Plan.write puts the product, the oligo sheet and the protocol into one directory.

One field name collides. gibson.Plan.product is the assembly kit: the reaction mix and the rules that come with it. The plasmid the assembly makes is gibson.Plan.plasmid. Every other method's .product is the record.

Two other fields are easy to miss. restriction.Plan.refusals says why every other enzyme pair was refused. gateway.Plan.write adds the entry clone as a fifth file, where a BP reaction was planned.

mbio.cloning.goldengate.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,
    codon_table: str = DEFAULT_TABLE,
    profile: LigaseProfile
    | str
    | PathLike[str]
    | None = None,
    prefer_profile: bool = False,
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    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 mbio.cloning.plan.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
codon_table str

Whose codon usage a proposed domestication picks its codons from, named as mbio.codons.codon_tables lists them. Not the strain, which is host.

DEFAULT_TABLE
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 mbio.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
cleanup_kit str

The spin-column kit the protocol names, by catalogue number or by name, as mbio.bench.materials.kit reads it. Empty takes that function's default.

''
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 mbio.primers.

THRESHOLDS_FOR

Returns:

Type Description
Plan

The design, the simulated product and the validation.

Raises:

Type Description
KeyError

If no shipped codon usage table is called codon_table.

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/mbio/cloning/goldengate/plan.py
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
434
435
436
437
438
439
440
441
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,
    codon_table: str = DEFAULT_TABLE,
    profile: LigaseProfile | str | os.PathLike[str] | None = None,
    prefer_profile: bool = False,
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    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 `mbio.cloning.plan.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.
    codon_table
        Whose codon usage a proposed domestication picks its codons from, named as
        `mbio.codons.codon_tables` lists them. Not the strain, which is `host`.
    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 `mbio.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.
    cleanup_kit
        The spin-column kit the protocol names, by catalogue number or by name, as
        `mbio.bench.materials.kit` reads it. Empty takes that function's default.
    window
        How far the vector junction may slide.
    thresholds
        For each role, what its oligos are designed and judged by in `mbio.primers`.

    Returns
    -------
    Plan
        The design, the simulated product and the validation.

    Raises
    ------
    KeyError
        If no shipped codon usage table is called `codon_table`.
    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")
    usage = codon_usage(codon_table)
    one = as_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 (
            (as_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 = insertion_span(one, site)
    ranking = choose_enzyme([one, *going], usage=usage)
    choice = _chosen(ranking, enzyme, (one, *going), usage)
    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,
        insert_primer=True,
        polymerase=ONETAQ,
        thresholds=thresholds["colony PCR"],
    )
    reads = sanger_primers(built.product, junctions, thresholds=thresholds["sequencing"])
    built = replace(built, product=annotated(built.product, colony, reads))
    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,
        kit(cleanup_kit),
        thresholds,
    )

mbio.cloning.goldengate.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. No insert can go in the other way round, so there is no lane for one.

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
cleanup_kit Material

The spin-column kit the protocol names, as mbio.bench.materials.kit resolves it.

kit()
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/mbio/cloning/goldengate/plan.py
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
255
256
@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.
        No insert can go in the other way round, so there is no lane for one.
    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.
    cleanup_kit
        The spin-column kit the protocol names, as `mbio.bench.materials.kit` resolves it.
    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
    cleanup_kit: Material = field(default_factory=kit)
    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, primer_check(self.reports))

    @property
    def status(self) -> Status:
        """The worst status of any check."""
        return status(self.checks)

    def protocol(self) -> Protocol:
        """Return the bench protocol for this plan."""
        made = 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,
            cleanup_kit=self.cleanup_kit,
            thresholds=self.thresholds,
        )
        return ordered_from_sheet(made)

    def write(self, directory: str | os.PathLike[str]) -> Files:
        """Write the product, the amplicons, the primer sheet, the protocol, its page and maps.

        The directory is made when it is not there. The files are named by `PRODUCT_FILE`,
        `amplicon_files` and `PRIMER_FILE`, and by `mbio.protocol.render` for the protocol
        pair, 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)
        amplicons = tuple(out / name for name in amplicon_files(one.name for one in self.parts))
        for part, path in zip(self.parts, amplicons, strict=True):
            write_dna(part.amplicon, path)
        sheet = out / PRIMER_FILE
        sheet.write_text(primer_sheet(self.reports), encoding="utf-8")
        written = write_run_files(as_project(self.protocol()), out)
        return Files(product, amplicons, sheet, written.data, written.page, written.maps)

product property

product: SequenceRecord

The circular plasmid the assembly makes.

checks property

checks: tuple[Check, ...]

The product's checks, with one more for the oligos.

status property

status: Status

The worst status of any check.

write

write(directory: str | PathLike[str]) -> Files

Write the product, the amplicons, the primer sheet, the protocol, its page and maps.

The directory is made when it is not there. The files are named by PRODUCT_FILE, amplicon_files and PRIMER_FILE, and by mbio.protocol.render for the protocol pair, and a second run over the same inputs writes the same bytes.

Source code in src/mbio/cloning/goldengate/plan.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def write(self, directory: str | os.PathLike[str]) -> Files:
    """Write the product, the amplicons, the primer sheet, the protocol, its page and maps.

    The directory is made when it is not there. The files are named by `PRODUCT_FILE`,
    `amplicon_files` and `PRIMER_FILE`, and by `mbio.protocol.render` for the protocol
    pair, 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)
    amplicons = tuple(out / name for name in amplicon_files(one.name for one in self.parts))
    for part, path in zip(self.parts, amplicons, strict=True):
        write_dna(part.amplicon, path)
    sheet = out / PRIMER_FILE
    sheet.write_text(primer_sheet(self.reports), encoding="utf-8")
    written = write_run_files(as_project(self.protocol()), out)
    return Files(product, amplicons, sheet, written.data, written.page, written.maps)

mbio.cloning.gibson.plan_gibson

plan_gibson(
    vector: SequenceRecord | str | PathLike[str],
    *inserts: SequenceRecord | str | PathLike[str],
    site: Site = None,
    orientation: Orientation
    | Sequence[Orientation] = "forward",
    route: Route | Sequence[Route] = "amplify",
    bridge: Sequence[tuple[str, str]] = (),
    product: AssemblyProduct = NEBUILDER_HIFI,
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    name: str = "",
    thresholds: Mapping[
        PrimerRole, Thresholds
    ] = THRESHOLDS_FOR,
) -> Plan

Plan one Gibson experiment putting inserts into vector.

The inserts go round the product in the order they are given. A circular vector is opened by PCR across the span they replace; one that is already linear is taken as the opened part as it is. Every junction takes its overlap at the length and melting temperature product documents, by the rules mbio.cloning.gibson.design states, and the part on the other side carries those bases as a 5' tail, so the junction adds nothing.

Which side the bases come from is the note's vector rule: a junction with the vector on either side takes the vector's own bases, so the opened backbone needs no tail and can be made once and reused. Every other junction takes the bases of the insert before it.

A colony PCR and two sequencing primers are then designed over mbio.cloning.gibson.assembly.Assembly.boundaries, where one part gives way to the next: the overlap is one part's own bases, so the boundary is not where the junction begins.

Parameters:

Name Type Description Default
vector SequenceRecord | str | PathLike[str]

A record, or a path to a .dna, GenBank or FASTA file holding one. At least one insert, in the order they go round the product.

required
inserts SequenceRecord | str | PathLike[str]

A record, or a path to a .dna, GenBank or FASTA file holding one. At least one insert, in the order they go round the product.

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 mbio.cloning.plan.MCS_FEATURE feature. A vector handed in linear replaces nothing, so it takes no site.

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'
route Route | Sequence[Route]

"stitch" makes an insert out of overlapping oligos tiling both strands instead of out of a PCR, which suits a linker, a tag or a short promoter. One value covers every insert; a sequence gives one for each.

'amplify'
bridge Sequence[tuple[str, str]]

Junctions joined by one oligo carrying homology to both ends, each named by the two parts it joins in the order they go round the product. Neither of the two then carries a tail, so a fragment amplified for something else goes in as it is.

()
product AssemblyProduct

The assembly product on the bench, which sets the overlap rule, the reaction, the incubation, the molar ratio and the fragment count it is documented for.

NEBUILDER_HIFI
polymerase Polymerase

For the PCRs.

Q5
host str

The strain the protocol names, and what to call the plasmid.

DEFAULT_HOST
name str

The strain the protocol names, and what to call the plasmid.

DEFAULT_HOST
cleanup_kit str

The spin-column kit the protocol names, by catalogue number or by name, as mbio.bench.materials.kit reads it. Empty takes that function's default.

''
thresholds Mapping[PrimerRole, Thresholds]

For each role, what its oligos are designed and judged by in mbio.primers.

THRESHOLDS_FOR

Returns:

Type Description
Plan

The design, the simulated plasmid and what the bench takes.

Raises:

Type Description
ValueError

If no insert is given, if an orientation is neither forward nor reverse or a route neither amplify nor stitch, or there is not one per insert, if a bridge names a junction this assembly does not have, if a site is named for a vector that is already linear, if no insertion site is named and a circular vector annotates none, if product documents no single-stranded oligo and either oligo route is asked for, if a part is too long to stitch or too short to take an overlap from, or if no annealing region fits a part's end.

Source code in src/mbio/cloning/gibson/plan.py
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
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
def plan_gibson(
    vector: SequenceRecord | str | os.PathLike[str],
    *inserts: SequenceRecord | str | os.PathLike[str],
    site: Site = None,
    orientation: Orientation | Sequence[Orientation] = "forward",
    route: Route | Sequence[Route] = "amplify",
    bridge: Sequence[tuple[str, str]] = (),
    product: AssemblyProduct = NEBUILDER_HIFI,
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    name: str = "",
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR,
) -> Plan:
    """Plan one Gibson experiment putting `inserts` into `vector`.

    The inserts go round the product in the order they are given. A circular vector is opened
    by PCR across the span they replace; one that is already linear is taken as the opened part
    as it is. Every junction takes its overlap at the length and melting temperature `product`
    documents, by the rules `mbio.cloning.gibson.design` states, and the part on the
    other side carries those bases as a 5' tail, so the junction adds nothing.

    **Which side the bases come from** is the note's vector rule: a junction with the vector on
    either side takes the vector's own bases, so the opened backbone needs no tail and can be
    made once and reused. Every other junction takes the bases of the insert before it.

    A colony PCR and two sequencing primers are then designed over
    `mbio.cloning.gibson.assembly.Assembly.boundaries`, where one part gives way to the
    next: the overlap is one part's own bases, so the boundary is not where the junction begins.

    Parameters
    ----------
    vector, inserts
        A record, or a path to a ``.dna``, GenBank or FASTA file holding one. At least one
        insert, in the order they go round the product.
    site
        Where the inserts go: a feature name, a ``(start, end)`` span of the vector, or
        ``None`` to use the vector's own `mbio.cloning.plan.MCS_FEATURE` feature. A
        vector handed in linear replaces nothing, so it takes no site.
    orientation
        ``"reverse"`` puts the other strand of an insert into the product. One value covers
        every insert; a sequence gives one for each.
    route
        ``"stitch"`` makes an insert out of overlapping oligos tiling both strands instead of
        out of a PCR, which suits a linker, a tag or a short promoter. One value covers every
        insert; a sequence gives one for each.
    bridge
        Junctions joined by one oligo carrying homology to both ends, each named by the two
        parts it joins in the order they go round the product. Neither of the two then carries
        a tail, so a fragment amplified for something else goes in as it is.
    product
        The assembly product on the bench, which sets the overlap rule, the reaction, the
        incubation, the molar ratio and the fragment count it is documented for.
    polymerase
        For the PCRs.
    host, name
        The strain the protocol names, and what to call the plasmid.
    cleanup_kit
        The spin-column kit the protocol names, by catalogue number or by name, as
        `mbio.bench.materials.kit` reads it. Empty takes that function's default.
    thresholds
        For each role, what its oligos are designed and judged by in `mbio.primers`.

    Returns
    -------
    Plan
        The design, the simulated plasmid and what the bench takes.

    Raises
    ------
    ValueError
        If no insert is given, if an orientation is neither ``forward`` nor ``reverse`` or a
        route neither ``amplify`` nor ``stitch``, or there is not one per insert, if a bridge
        names a junction this assembly does not have, if a site is named for a vector that is
        already linear, if no insertion site is named and a circular vector annotates none, if
        `product` documents no single-stranded oligo and either oligo route is asked for, if a
        part is too long to stitch or too short to take an overlap from, or if no annealing
        region fits a part's end.
    """
    if not inserts:
        raise ValueError("a Gibson plan needs a vector and at least one insert")
    one = as_record(vector)
    linear = one.topology == "linear"
    if linear and site is not None:
        raise ValueError(
            f"{one.name or 'the vector'} is already linear, so it is used as it is and there "
            "is no span to replace; drop the site"
        )
    ways = orientations(orientation, len(inserts))
    going = tuple(
        flipped(read) if way == "reverse" else read
        for read, way in (
            (as_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)]
    routes = _routes(route, len(going))
    span = (len(one), len(one)) if linear else insertion_span(one, site)
    opened = (0, len(one)) if linear else (span[1], span[0] + len(one))
    backbone = f"{one.name} backbone".strip()
    joins = tuple(zip((backbone, *labels), (*labels, backbone), strict=True))
    bridged = _bridged(bridge, joins)
    if "stitch" in routes:
        requires_oligos(product, "stitch a part out of overlapping oligos")
    if bridged:
        requires_oligos(product, "join two fragments with a bridging oligo")
    rule = product.tier(len(going) + 1).overlap
    tails = _tails(one, going, opened, rule, bridged)
    linearised_vector = (
        given_vector(one, name=backbone)
        if linear
        else open_vector(
            one,
            *span,
            name=backbone,
            polymerase=polymerase,
            thresholds=thresholds["amplification"],
        )
    )
    insert_parts = tuple(
        _insert_part(
            record,
            label,
            way,
            (left, right),
            product=product,
            polymerase=polymerase,
            thresholds=thresholds["amplification"],
        )
        for label, record, way, (left, right) in zip(labels, going, routes, tails, strict=True)
    )
    parts, bridges = _bridge_parts(
        [linearised_vector, *insert_parts], joins, bridged, product=product
    )
    linearised_vector, insert_parts = parts[0], tuple(parts[1:])
    built = assemble(parts, name=name or "-".join([one.name, *labels]).strip("-"))
    boundaries = built.boundaries
    colony = colony_pcr_check(
        built.product,
        boundaries,
        vector=one,
        insert_primer=True,
        polymerase=ONETAQ,
        thresholds=thresholds["colony PCR"],
    )
    reads = sanger_primers(built.product, boundaries, thresholds=thresholds["sequencing"])
    built = dataclasses.replace(built, product=annotated(built.product, colony, reads))
    return Plan(
        one,
        going,
        span,
        product,
        linearised_vector,
        insert_parts,
        built,
        colony,
        reads,
        assembly_amounts(
            (linearised_vector.name, linearised_vector.fragment_length),
            tuple((part.name, part.fragment_length) for part in insert_parts),
            product=product,
        ),
        read_phenotype(built.product, built.insert_span, vector=one, span=span),
        (
            *(
                DesignedOligo(report, "amplification", part)
                for part in parts
                if part.report is not None
                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
            ),
        ),
        _ordered_oligos(parts, bridges),
        host,
        polymerase,
        kit(cleanup_kit),
        thresholds,
    )

mbio.cloning.gibson.Plan dataclass

One planned Gibson experiment.

Parameters:

Name Type Description Default
vector SequenceRecord

The records the plan was made from, each insert on the strand that goes in.

required
inserts SequenceRecord

The records the plan was made from, each insert on the strand that goes in.

required
span tuple[int, int]

The vector bases the inserts replace. Empty, at the vector's end, where the vector was handed in linear and nothing was taken out of it.

required
product AssemblyProduct

The assembly product on the bench, which sets the overlap rule, the reaction and the incubation.

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 plasmid, the parts and the junctions.

required
colony ColonyCheck

The colony PCR that reads every junction and tells a correct clone from an empty vector. No insert can go in the other way round, so there is no lane for one.

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 plasmid says about itself.

required
designed_oligos tuple[DesignedOligo, ...]

Every designed oligo and what it is for, in order: each part's forward and reverse primer, the colony PCR, the reads.

required
ordered_oligos tuple[OrderedOligo, ...]

Every oligo the plan orders that primes nothing: a stitched part's, then any bridging oligo. Nothing measures one, so its row carries no verdict.

required
host str

The choices the protocol names.

required
polymerase str

The choices the protocol names.

required
cleanup_kit Material

The spin-column kit the protocol names, as mbio.bench.materials.kit resolves it.

kit()
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/mbio/cloning/gibson/plan.py
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
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
@dataclass(frozen=True, slots=True)
class Plan:
    """One planned Gibson experiment.

    Parameters
    ----------
    vector, inserts
        The records the plan was made from, each insert on the strand that goes in.
    span
        The vector bases the inserts replace. Empty, at the vector's end, where the vector was
        handed in linear and nothing was taken out of it.
    product
        The assembly product on the bench, which sets the overlap rule, the reaction and the
        incubation.
    linearised_vector
        The part the vector is opened into.
    insert_parts
        The part each insert is amplified into, in insert order.
    assembly
        The simulated plasmid, the parts and the junctions.
    colony
        The colony PCR that reads every junction and tells a correct clone from an empty vector.
        No insert can go in the other way round, so there is no lane for one.
    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 plasmid says about itself.
    designed_oligos
        Every designed oligo and what it is for, in order: each part's forward and reverse
        primer, the colony PCR, the reads.
    ordered_oligos
        Every oligo the plan orders that primes nothing: a stitched part's, then any bridging
        oligo. Nothing measures one, so its row carries no verdict.
    host, polymerase
        The choices the protocol names.
    cleanup_kit
        The spin-column kit the protocol names, as `mbio.bench.materials.kit` resolves it.
    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]
    product: AssemblyProduct
    linearised_vector: Part
    insert_parts: tuple[Part, ...]
    assembly: Assembly
    colony: ColonyCheck
    reads: tuple[SangerRead, SangerRead]
    amounts: tuple[Amount, ...]
    phenotype: Phenotype
    designed_oligos: tuple[DesignedOligo, ...]
    ordered_oligos: tuple[OrderedOligo, ...]
    host: str
    polymerase: Polymerase
    cleanup_kit: Material = field(default_factory=kit)
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR

    @property
    def plasmid(self) -> SequenceRecord:
        """The circular plasmid the assembly makes.

        Named apart from `product`, which is the kit the reaction is run with.
        """
        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 overlaps(self) -> tuple[str, ...]:
        """The bases carried at each junction, in the product's own order.

        `mbio.cloning.gibson.assembly.Junction` says which molecule carries them.
        """
        return tuple(one.overlap for one in self.assembly.junctions)

    @property
    def junction_names(self) -> tuple[tuple[str, str], ...]:
        """Each junction's name and the bases carried there, in the product's own order."""
        return tuple((f"{one.before}-{one.after}", one.overlap) for one in self.assembly.junctions)

    @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, ...]:
        """What the plan is judged on: the plasmid, every overlap, the reaction and the oligos.

        The ones no sourced threshold judges carry no verdict, and say in their detail that
        nothing measured them.
        """
        return (
            *self.assembly.checks,
            *overlap_checks(self.junction_names, self.product),
            *stitch_checks(self.parts),
            fragment_check(self.product, len(self.insert_parts)),
            assembly_dna_check(self.product, self.amounts),
            primer_check(self.reports),
        )

    @property
    def status(self) -> Status:
        """The worst status of any check."""
        return status(self.checks)

    def protocol(self) -> Protocol:
        """Return the bench protocol for this plan."""
        made = protocol_for(
            vector=self.vector,
            span=self.span,
            product=self.product,
            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,
            ordered=self.ordered_oligos,
            checks=self.checks,
            host=self.host,
            polymerase=self.polymerase,
            cleanup_kit=self.cleanup_kit,
            thresholds=self.thresholds,
        )
        return ordered_from_sheet(made)

    def write(self, directory: str | os.PathLike[str]) -> Files:
        """Write the plasmid, the oligo sheet, the protocol, its page and its maps into `directory`.

        The directory is made when it is not there. The files are named by
        `mbio.cloning.plan` and `mbio.protocol.render`, and a second run over the same inputs
        writes the same bytes.
        """
        out = Path(directory)
        out.mkdir(parents=True, exist_ok=True)
        plasmid = out / PRODUCT_FILE
        write_dna(self.plasmid, plasmid)
        sheet = out / PRIMER_FILE
        sheet.write_text(primer_sheet(self.reports, oligos=self.ordered_oligos), encoding="utf-8")
        written = write_run_files(as_project(self.protocol()), out)
        return Files(plasmid, sheet, written.data, written.page, written.maps)

plasmid property

plasmid: SequenceRecord

The circular plasmid the assembly makes.

Named apart from product, which is the kit the reaction is run with.

product instance-attribute

product: AssemblyProduct

checks property

checks: tuple[Check, ...]

What the plan is judged on: the plasmid, every overlap, the reaction and the oligos.

The ones no sourced threshold judges carry no verdict, and say in their detail that nothing measured them.

status property

status: Status

The worst status of any check.

write

write(directory: str | PathLike[str]) -> Files

Write the plasmid, the oligo sheet, the protocol, its page and its maps into directory.

The directory is made when it is not there. The files are named by mbio.cloning.plan and mbio.protocol.render, and a second run over the same inputs writes the same bytes.

Source code in src/mbio/cloning/gibson/plan.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def write(self, directory: str | os.PathLike[str]) -> Files:
    """Write the plasmid, the oligo sheet, the protocol, its page and its maps into `directory`.

    The directory is made when it is not there. The files are named by
    `mbio.cloning.plan` and `mbio.protocol.render`, and a second run over the same inputs
    writes the same bytes.
    """
    out = Path(directory)
    out.mkdir(parents=True, exist_ok=True)
    plasmid = out / PRODUCT_FILE
    write_dna(self.plasmid, plasmid)
    sheet = out / PRIMER_FILE
    sheet.write_text(primer_sheet(self.reports, oligos=self.ordered_oligos), encoding="utf-8")
    written = write_run_files(as_project(self.protocol()), out)
    return Files(plasmid, sheet, written.data, written.page, written.maps)

mbio.cloning.restriction.plan_restriction

plan_restriction(
    vector: SequenceRecord | str | PathLike[str],
    insert: SequenceRecord | str | PathLike[str],
    *,
    enzymes: Sequence[EnzymeLike] = (),
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    name: str = "",
    thresholds: Mapping[
        PrimerRole, Thresholds
    ] = THRESHOLDS_FOR,
) -> Plan

Plan getting an insert to two cut ends and ligating it into vector.

The vector is cut with the enzymes named and its longest piece is the backbone, gel-purified from what the same digest leaves beside it.

insert reaches the same two ends by one of two routes, and what it carries picks one. A record already reading a site of either enzyme is cut, and the piece between the two sites is what goes in -- a plasmid the insert already sits in, or a fragment ordered with the sites on it. A record reading no site of either is amplified first: each primer carries a spacer and a recognition site as a 5' tail, and the amplicon is cut instead. Either way the insert is written on whichever strand closes the circle, so which way round it lay does not matter.

Naming no enzyme hands the choice to mbio.cloning.restriction.design.choose_pair, which weighs every pair the package ships and leaves Plan.refusals saying what refused the rest. Naming enzymes wins over it, and an unusable pair is refused in the chooser's own words.

The junction is not scarless. The two ends came from a recognition site and ligating them puts that site back, so the product gains those bases and Plan.junctions says what each one spells.

Where the backbone's two ends anneal to each other -- one enzyme cutting both, two enzymes leaving compatible ends, or blunt ends -- the vector closes on itself with no insert and the insert goes in either way round. Neither is refused: the plan dephosphorylates the backbone, and its colony PCR reads out of the insert itself, which is what tells a reversed clone from a correct one.

Parameters:

Name Type Description Default
vector SequenceRecord | str | PathLike[str]

A record, or a path to a .dna, GenBank or FASTA file holding one. The vector is a circular plasmid; insert is the insert, or the plasmid it is cut out of.

required
insert SequenceRecord | str | PathLike[str]

A record, or a path to a .dna, GenBank or FASTA file holding one. The vector is a circular plasmid; insert is the insert, or the plasmid it is cut out of.

required
enzymes Sequence[EnzymeLike]

One or two enzymes, each an mbio.enzymes.Enzyme or a name the package ships. Each must read a site in the vector, and the two of them no more than twice in whatever is cut for the insert: one enzyme reading two sites cuts it out on its own. Chosen for the caller where none is named.

()
polymerase Polymerase

For the insert's PCR, where one is run.

Q5
host str

The competent strain the protocol names, and what to call the product. The colony PCR uses OneTaq, which is what NEB's protocol asks for.

DEFAULT_HOST
name str

The competent strain the protocol names, and what to call the product. The colony PCR uses OneTaq, which is what NEB's protocol asks for.

DEFAULT_HOST
cleanup_kit str

The spin-column kit the protocol names, by catalogue number or by name, as mbio.bench.materials.kit reads it. Empty takes that function's default.

''
thresholds Mapping[PrimerRole, Thresholds]

For each role, what its oligos are designed and judged by in mbio.primers.

THRESHOLDS_FOR

Returns:

Type Description
Plan

The digests, the simulated product and the validation.

Raises:

Type Description
KeyError

If a name is not one the package ships.

ValueError

If the vector is not circular, if an enzyme does not cut what is cut or the enzymes cut it too often, if no tail spells one site, if the insert's ends do not anneal to the backbone's, or if no pair can be chosen at all.

Source code in src/mbio/cloning/restriction/plan.py
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
434
def plan_restriction(
    vector: SequenceRecord | str | os.PathLike[str],
    insert: SequenceRecord | str | os.PathLike[str],
    *,
    enzymes: Sequence[EnzymeLike] = (),
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    name: str = "",
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR,
) -> Plan:
    """Plan getting an insert to two cut ends and ligating it into `vector`.

    The vector is cut with the enzymes named and its longest piece is the backbone, gel-purified
    from what the same digest leaves beside it.

    `insert` reaches the same two ends by one of two routes, and what it carries picks one. A
    record already reading a site of either enzyme is cut, and the piece between the two sites is
    what goes in -- a plasmid the insert already sits in, or a fragment ordered with the sites on
    it. A record reading no site of either is amplified first: each primer carries a spacer and a
    recognition site as a 5' tail, and the amplicon is cut instead. Either way the insert is
    written on whichever strand closes the circle, so which way round it lay does not matter.

    Naming no enzyme hands the choice to `mbio.cloning.restriction.design.choose_pair`,
    which weighs every pair the package ships and leaves `Plan.refusals` saying what refused the
    rest. Naming enzymes wins over it, and an unusable pair is refused in the chooser's own words.

    The junction is not scarless. The two ends came from a recognition site and ligating them
    puts that site back, so the product gains those bases and `Plan.junctions` says what each one
    spells.

    Where the backbone's two ends anneal to each other -- one enzyme cutting both, two enzymes
    leaving compatible ends, or blunt ends -- the vector closes on itself with no insert and the
    insert goes in either way round. Neither is refused: the plan dephosphorylates the backbone,
    and its colony PCR reads out of the insert itself, which is what tells a reversed clone from
    a correct one.

    Parameters
    ----------
    vector, insert
        A record, or a path to a ``.dna``, GenBank or FASTA file holding one. The vector is a
        circular plasmid; `insert` is the insert, or the plasmid it is cut out of.
    enzymes
        One or two enzymes, each an `mbio.enzymes.Enzyme` or a name the package ships.
        Each must read a site in the vector, and the two of them no more than twice in
        whatever is cut for the insert: one enzyme reading two sites cuts it out on its own.
        Chosen for the caller where none is named.
    polymerase
        For the insert's PCR, where one is run.
    host, name
        The competent strain the protocol names, and what to call the product. The colony PCR
        uses OneTaq, which is what NEB's protocol asks for.
    cleanup_kit
        The spin-column kit the protocol names, by catalogue number or by name, as
        `mbio.bench.materials.kit` reads it. Empty takes that function's default.
    thresholds
        For each role, what its oligos are designed and judged by in `mbio.primers`.

    Returns
    -------
    Plan
        The digests, the simulated product and the validation.

    Raises
    ------
    KeyError
        If a name is not one the package ships.
    ValueError
        If the vector is not circular, if an enzyme does not cut what is cut or the enzymes cut
        it too often, if no tail spells one site, if the insert's ends do not anneal to the
        backbone's, or if no pair can be chosen at all.
    """
    into = as_record(vector)
    holder = as_record(insert)
    chosen, refusals = _pair(into, holder, enzymes)
    vector_pieces = opened(into, chosen)
    backbone = vector_pieces[0]
    amplicon = (
        None
        if _carries_a_site(holder, chosen)
        else amplified(
            holder,
            into=backbone,
            polymerase=polymerase,
            thresholds=thresholds["amplification"],
        )
    )
    digested = holder if amplicon is None else amplicon.record
    source_pieces = excised(digested, chosen, into=backbone)
    released = source_pieces[0]
    built = ligate(
        backbone,
        released,
        name=name or "-".join(part for part in (into.name, holder.name) if part) or "product",
    )
    span = _replaced(backbone, len(into))
    junctions = built.junction_positions
    either_way = reversible(backbone, released)
    backwards = (ligate(backbone, turned(released)).product,) if either_way else ()
    colony = colony_pcr_check(
        built.product,
        junctions,
        vector=into,
        insert_primer=either_way,
        reverse_flank=REVERSE_FLANK if either_way else None,
        reversed_inserts=backwards,
        polymerase=ONETAQ,
        thresholds=thresholds["colony PCR"],
    )
    reads = sanger_primers(built.product, junctions, thresholds=thresholds["sequencing"])
    built = dataclasses.replace(built, product=annotated(built.product, colony, reads))
    return Plan(
        into,
        holder,
        chosen,
        vector_pieces,
        source_pieces,
        amplicon,
        built,
        span,
        (digest_amount((into.name, len(into))), digest_amount((digested.name, len(digested)))),
        ligation_amounts((backbone.name, backbone.length), (released.name, released.length)),
        diagnostic(built.product, into, chosen),
        colony,
        reads,
        _designed(amplicon, colony, reads, built.product, thresholds),
        read_phenotype(built.product, (junctions[0], junctions[-1]), vector=into, span=span),
        host,
        polymerase,
        kit(cleanup_kit),
        thresholds,
        refusals,
    )

mbio.cloning.restriction.Plan dataclass

One planned restriction and ligation cloning.

Parameters:

Name Type Description Default
vector SequenceRecord

The records the plan was made from: the plasmid the backbone comes out of, and the one the insert comes from -- the plasmid it is cut out of, or the insert itself.

required
source SequenceRecord

The records the plan was made from: the plasmid the backbone comes out of, and the one the insert comes from -- the plasmid it is cut out of, or the insert itself.

required
enzymes tuple[Enzyme, ...]

The enzymes both digests use, in the order they were named.

required
vector_pieces tuple[Piece, ...]

Every piece each digest leaves, the one that goes on first. The rest is what the gel separates it from.

required
source_pieces tuple[Piece, ...]

Every piece each digest leaves, the one that goes on first. The rest is what the gel separates it from.

required
amplicon Amplicon | None

The PCR that put a site on each end of the insert, or None where source already carried them and the insert was cut out of it.

required
ligation Ligation

The simulated product, the pieces and the junctions.

required
span tuple[int, int]

The vector bases the insert replaces.

required
digests tuple[Amount, Amount]

What each digest takes, the vector's first.

required
amounts tuple[Amount, Amount]

What to put in the ligation, the backbone first.

required
diagnostic Diagnostic

The digest that says a miniprep carries the insert, and the bands it should give.

required
colony ColonyCheck

The colony PCR that reads both junctions and tells a correct clone from an empty vector.

required
reads tuple[SangerRead, SangerRead]

A sequencing primer reading in from outside the first junction and the last.

required
designed_oligos tuple[DesignedOligo, ...]

Every oligo the plan designs and what it is for, in the order the sheet lists them: the amplification pair where there is one, then the colony PCR, then the sequencing.

required
phenotype Phenotype

What the product says about itself.

required
host str

The competent strain the protocol names, and what the insert is amplified with.

required
polymerase str

The competent strain the protocol names, and what the insert is amplified with.

required
cleanup_kit Material

The spin-column kit the protocol names, as mbio.bench.materials.kit resolves it.

kit()
thresholds Mapping[PrimerRole, Thresholds]

What the oligos were designed and judged by, for each role, so a page prints the band beside the value.

THRESHOLDS_FOR
refusals tuple[Refusal, ...]

Every pair the chooser weighed and refused, with the rule that refused each. Empty where the enzymes were named, because then nothing was weighed against them.

()
Source code in src/mbio/cloning/restriction/plan.py
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
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
@dataclass(frozen=True, slots=True)
class Plan:
    """One planned restriction and ligation cloning.

    Parameters
    ----------
    vector, source
        The records the plan was made from: the plasmid the backbone comes out of, and the one
        the insert comes from -- the plasmid it is cut out of, or the insert itself.
    enzymes
        The enzymes both digests use, in the order they were named.
    vector_pieces, source_pieces
        Every piece each digest leaves, the one that goes on first. The rest is what the gel
        separates it from.
    amplicon
        The PCR that put a site on each end of the insert, or ``None`` where `source` already
        carried them and the insert was cut out of it.
    ligation
        The simulated product, the pieces and the junctions.
    span
        The vector bases the insert replaces.
    digests
        What each digest takes, the vector's first.
    amounts
        What to put in the ligation, the backbone first.
    diagnostic
        The digest that says a miniprep carries the insert, and the bands it should give.
    colony
        The colony PCR that reads both junctions and tells a correct clone from an empty vector.
    reads
        A sequencing primer reading in from outside the first junction and the last.
    designed_oligos
        Every oligo the plan designs and what it is for, in the order the sheet lists them: the
        amplification pair where there is one, then the colony PCR, then the sequencing.
    phenotype
        What the product says about itself.
    host, polymerase
        The competent strain the protocol names, and what the insert is amplified with.
    cleanup_kit
        The spin-column kit the protocol names, as `mbio.bench.materials.kit` resolves it.
    thresholds
        What the oligos were designed and judged by, for each role, so a page prints the band
        beside the value.
    refusals
        Every pair the chooser weighed and refused, with the rule that refused each. Empty where
        the enzymes were named, because then nothing was weighed against them.
    """

    vector: SequenceRecord
    source: SequenceRecord
    enzymes: tuple[Enzyme, ...]
    vector_pieces: tuple[Piece, ...]
    source_pieces: tuple[Piece, ...]
    amplicon: Amplicon | None
    ligation: Ligation
    span: tuple[int, int]
    digests: tuple[Amount, Amount]
    amounts: tuple[Amount, Amount]
    diagnostic: Diagnostic
    colony: ColonyCheck
    reads: tuple[SangerRead, SangerRead]
    designed_oligos: tuple[DesignedOligo, ...]
    phenotype: Phenotype
    host: str
    polymerase: Polymerase
    cleanup_kit: Material = field(default_factory=kit)
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR
    refusals: tuple[Refusal, ...] = ()

    @property
    def backbone(self) -> Piece:
        """The vector piece the insert goes into."""
        return self.vector_pieces[0]

    @property
    def insert(self) -> Piece:
        """The source piece that goes in."""
        return self.source_pieces[0]

    @property
    def digested(self) -> SequenceRecord:
        """The record the insert's own digest cuts: the amplicon, or `source` where there is none.

        An amplicon carries no methylation, whatever the plasmid that templated it carried.
        """
        return self.source if self.amplicon is None else self.amplicon.record

    @property
    def dephosphorylates(self) -> bool:
        """Whether the plan takes the backbone's 5' phosphates off before the ligation.

        A backbone whose two ends anneal to each other closes on itself with no insert, and
        `mbio.cloning.restriction.bench.PHOSPHATASE` is what stops it.
        """
        return self_closing(self.backbone)

    @property
    def product(self) -> SequenceRecord:
        """The circular plasmid the ligation makes."""
        return self.ligation.product

    @property
    def junctions(self) -> tuple[Junction, ...]:
        """Where the two pieces meet, and what each join now spells."""
        return self.ligation.junctions

    @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 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 checks(self) -> tuple[Check, ...]:
        """Every verdict the plan carries, in the order the bench meets them.

        The digest's five first, then the product's own, then what the ligation takes and what
        confirms it. `mbio.cloning.restriction.verdicts` is where each one's threshold and
        its source are written down, and the buffer is the one nothing sourced can judge.
        """
        return (
            buffer_check(self.enzymes),
            temperature_check(self.enzymes),
            cleanup_check(self.enzymes),
            methylation_check(self.enzymes, (self.vector, self.digested)),
            self_ligation_check(self.backbone),
            *self.ligation.checks,
            frame_check(self.product, self.junctions),
            ratio_check(*self.amounts),
            diagnostic_check(self.diagnostic),
            primer_check(self.reports),
        )

    @property
    def status(self) -> Status:
        """The worst status of any check."""
        return status(self.checks)

    def protocol(self) -> Protocol:
        """Return the bench protocol for this plan."""
        made = protocol_for(
            vector=self.vector,
            source=self.source,
            enzymes=self.enzymes,
            vector_pieces=self.vector_pieces,
            source_pieces=self.source_pieces,
            amplicon=self.amplicon,
            ligation=self.ligation,
            digests=self.digests,
            amounts=self.amounts,
            diagnostic=self.diagnostic,
            colony=self.colony,
            reads=self.reads,
            oligos=self.designed_oligos,
            phenotype=self.phenotype,
            checks=self.checks,
            refusals=self.refusals,
            host=self.host,
            polymerase=self.polymerase,
            cleanup_kit=self.cleanup_kit,
            thresholds=self.thresholds,
        )
        return ordered_from_sheet(made)

    def write(self, directory: str | os.PathLike[str]) -> Files:
        """Write the product, the oligo sheet, the protocol, its page and its maps into `directory`.

        The directory is made when it is not there. The names are `mbio.cloning.plan`'s and
        `mbio.protocol.render`'s, 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")
        written = write_run_files(as_project(self.protocol()), out)
        return Files(product, sheet, written.data, written.page, written.maps)

product property

product: SequenceRecord

The circular plasmid the ligation makes.

refusals class-attribute instance-attribute

refusals: tuple[Refusal, ...] = ()

checks property

checks: tuple[Check, ...]

Every verdict the plan carries, in the order the bench meets them.

The digest's five first, then the product's own, then what the ligation takes and what confirms it. mbio.cloning.restriction.verdicts is where each one's threshold and its source are written down, and the buffer is the one nothing sourced can judge.

status property

status: Status

The worst status of any check.

write

write(directory: str | PathLike[str]) -> Files

Write the product, the oligo sheet, the protocol, its page and its maps into directory.

The directory is made when it is not there. The names are mbio.cloning.plan's and mbio.protocol.render's, and a second run over the same inputs writes the same bytes.

Source code in src/mbio/cloning/restriction/plan.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def write(self, directory: str | os.PathLike[str]) -> Files:
    """Write the product, the oligo sheet, the protocol, its page and its maps into `directory`.

    The directory is made when it is not there. The names are `mbio.cloning.plan`'s and
    `mbio.protocol.render`'s, 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")
    written = write_run_files(as_project(self.protocol()), out)
    return Files(product, sheet, written.data, written.page, written.maps)

mbio.cloning.gateway.plan_gateway

plan_gateway(
    carrier: SequenceRecord | str | PathLike[str],
    destination: SequenceRecord | str | PathLike[str],
    *,
    donor: SequenceRecord
    | str
    | PathLike[str]
    | None = None,
    amplify: bool = False,
    fusion: Fusion = "none",
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    name: str = "",
    thresholds: Mapping[
        PrimerRole, Thresholds
    ] = THRESHOLDS_FOR,
) -> Plan

Plan the reactions that put one insert into a destination vector.

There are three routes. With no donor vector the carrier is an entry clone and one LR reaction is planned. With a donor vector the carrier is an attB-flanked insert -- a synthesised fragment, or an amplicon made elsewhere -- and BP is planned first, its entry clone feeding LR. With amplify as well, the carrier is a plain insert carrying no att site: it is amplified onto attB ends first, and that amplicon is what BP takes.

The att sites are found in every record rather than matched against a constant, because the vendor states that the sequences drift between products. Each record is then read on the strand its site 1 lies on, the segment between the carrier's sites is recombined into the acceptor's backbone, and each product is simulated base for base. The four G residues an attB primer carries leave with the by-product, so the entry clone is the same record whichever of the two BP routes made it.

Parameters:

Name Type Description Default
carrier SequenceRecord | str | PathLike[str]

The entry clone, the attB-flanked insert when donor is given, or the plain insert when amplify is set as well; or a path to a .dna, GenBank or FASTA file holding it. An insert may be linear.

required
destination SequenceRecord | str | PathLike[str]

The destination vector, the same way. It must be circular.

required
donor SequenceRecord | str | PathLike[str] | None

The donor vector, the same way, to plan the BP reaction that makes the entry clone. It must be circular.

None
amplify bool

Design attB-tailed primers for the carrier and amplify it, for an insert carrying no att site of its own.

False
fusion Fusion

Which tag the insert is read into: it decides the frame bases the tails carry, and which att junctions the reading frame is judged at.

'none'
polymerase Polymerase

For the attB PCR.

Q5
host str

The strain the protocol names for selecting each clone.

DEFAULT_HOST
cleanup_kit str

The spin-column kit the protocol names, by catalogue number or by name, as mbio.bench.materials.kit reads it. Empty takes that function's default.

''
name str

What to call the expression clone.

''
thresholds Mapping[PrimerRole, Thresholds]

For each role, what its oligos are designed and judged by in mbio.primers.

THRESHOLDS_FOR

Returns:

Type Description
Plan

The reactions planned, what each takes, the oligos designed, and the checks.

Raises:

Type Description
ValueError

If any record does not carry the pair of att sites its side of a reaction needs, pointing at each other; if a vector that must be circular is not; if no annealing region fits an end of an insert being amplified; or if the DNA does not fit a reaction.

Source code in src/mbio/cloning/gateway/plan.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
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
def plan_gateway(
    carrier: SequenceRecord | str | os.PathLike[str],
    destination: SequenceRecord | str | os.PathLike[str],
    *,
    donor: SequenceRecord | str | os.PathLike[str] | None = None,
    amplify: bool = False,
    fusion: Fusion = "none",
    polymerase: Polymerase = Q5,
    host: str = DEFAULT_HOST,
    cleanup_kit: str = "",
    name: str = "",
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR,
) -> Plan:
    """Plan the reactions that put one insert into a destination vector.

    There are three routes. With no donor vector the carrier is an entry clone and one LR
    reaction is planned. With a donor vector the carrier is an attB-flanked insert -- a
    synthesised fragment, or an amplicon made elsewhere -- and BP is planned first, its entry
    clone feeding LR. With `amplify` as well, the carrier is a plain insert carrying no att
    site: it is amplified onto attB ends first, and that amplicon is what BP takes.

    The att sites are found in every record rather than matched against a constant, because the
    vendor states that the sequences drift between products. Each record is then read on the
    strand its site 1 lies on, the segment between the carrier's sites is recombined into the
    acceptor's backbone, and each product is simulated base for base. The four G residues an
    attB primer carries leave with the by-product, so the entry clone is the same record
    whichever of the two BP routes made it.

    Parameters
    ----------
    carrier
        The entry clone, the attB-flanked insert when `donor` is given, or the plain insert
        when `amplify` is set as well; or a path to a ``.dna``, GenBank or FASTA file holding
        it. An insert may be linear.
    destination
        The destination vector, the same way. It must be circular.
    donor
        The donor vector, the same way, to plan the BP reaction that makes the entry clone. It
        must be circular.
    amplify
        Design attB-tailed primers for the carrier and amplify it, for an insert carrying no
        att site of its own.
    fusion
        Which tag the insert is read into: it decides the frame bases the tails carry, and
        which att junctions the reading frame is judged at.
    polymerase
        For the attB PCR.
    host
        The strain the protocol names for selecting each clone.
    cleanup_kit
        The spin-column kit the protocol names, by catalogue number or by name, as
        `mbio.bench.materials.kit` reads it. Empty takes that function's default.
    name
        What to call the expression clone.
    thresholds
        For each role, what its oligos are designed and judged by in `mbio.primers`.

    Returns
    -------
    Plan
        The reactions planned, what each takes, the oligos designed, and the checks.

    Raises
    ------
    ValueError
        If any record does not carry the pair of att sites its side of a reaction needs,
        pointing at each other; if a vector that must be circular is not; if no annealing
        region fits an end of an insert being amplified; or if the DNA does not fit a reaction.
    """
    start, other = as_record(carrier), as_record(destination)
    if amplify and donor is None:
        raise ValueError(
            "an attB PCR product is what a BP reaction takes, so amplifying an insert needs a "
            "donor vector as well"
        )
    one, bp, made = start, None, None
    if donor is not None:
        vector = as_record(donor)
        if amplify:
            made = amplify_attb(
                start,
                fusion=fusion,
                polymerase=polymerase,
                thresholds=thresholds["amplification"],
            )
            one = made.record
        bp = _planned(
            recombine(one, vector, reaction="BP", name=_named(vector.name, one.name)),
            bp_amounts((one.name, len(one)), (vector.name, len(vector))),
        )
        one = bp.product
    lr = _planned(
        recombine(one, other, reaction="LR", name=name or _named(other.name, start.name)),
        lr_amounts((one.name, len(one)), (other.name, len(other))),
    )
    boundaries = lr.recombination.boundaries
    colony = colony_pcr_check(
        lr.product,
        boundaries,
        vector=lr.recombination.backbone.record,
        polymerase=ONETAQ,
        thresholds=thresholds["colony PCR"],
    )
    reads = sanger_primers(lr.product, boundaries, thresholds=thresholds["sequencing"])
    lr = _drawn_on(lr, colony, reads)
    designed = _designed(made, colony, reads, lr.product, thresholds)
    return Plan(lr, bp, host, colony, reads, designed, made, kit(cleanup_kit), fusion, thresholds)

mbio.cloning.gateway.Plan dataclass

One planned Gateway experiment: the LR reaction, and the BP that fed it or not.

Parameters:

Name Type Description Default
lr PlannedReaction

The LR reaction, which every plan runs.

required
bp PlannedReaction | None

The BP reaction before it, or None where an entry clone was given.

required
host str

The strain the protocol names for selecting each clone.

required
colony ColonyCheck

The colony PCR reading across both attB junctions, and the lanes it should give: the correct clone and the destination vector that never recombined.

required
reads tuple[SangerRead, SangerRead]

A sequencing primer reading in from outside the first attB junction and the last.

required
designed_oligos tuple[DesignedOligo, ...]

Every oligo the plan designs and what it is for, in the order the sheet lists them: the attB pair where there is one, then the colony PCR, then the sequencing.

required
amplicon Amplicon | None

The attB PCR that made the DNA BP takes, or None where the insert already carried its att sites.

None
cleanup_kit Material

The spin-column kit the protocol names, as mbio.bench.materials.kit resolves it.

kit()
fusion Fusion

Which tag the insert is read into, which is what says where a fusion reads through an att junction and so where the reading frame is judged.

'none'
thresholds Mapping[PrimerRole, Thresholds]

What the oligos were designed and judged by, for each role, so a page prints the band beside the value.

THRESHOLDS_FOR
Source code in src/mbio/cloning/gateway/plan.py
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
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
@dataclass(frozen=True, slots=True)
class Plan:
    """One planned Gateway experiment: the LR reaction, and the BP that fed it or not.

    Parameters
    ----------
    lr
        The LR reaction, which every plan runs.
    bp
        The BP reaction before it, or ``None`` where an entry clone was given.
    host
        The strain the protocol names for selecting each clone.
    colony
        The colony PCR reading across both attB junctions, and the lanes it should give: the
        correct clone and the destination vector that never recombined.
    reads
        A sequencing primer reading in from outside the first attB junction and the last.
    designed_oligos
        Every oligo the plan designs and what it is for, in the order the sheet lists them: the
        attB pair where there is one, then the colony PCR, then the sequencing.
    amplicon
        The attB PCR that made the DNA BP takes, or ``None`` where the insert already carried
        its att sites.
    cleanup_kit
        The spin-column kit the protocol names, as `mbio.bench.materials.kit` resolves it.
    fusion
        Which tag the insert is read into, which is what says where a fusion reads through an
        att junction and so where the reading frame is judged.
    thresholds
        What the oligos were designed and judged by, for each role, so a page prints the band
        beside the value.
    """

    lr: PlannedReaction
    bp: PlannedReaction | None
    host: str
    colony: ColonyCheck
    reads: tuple[SangerRead, SangerRead]
    designed_oligos: tuple[DesignedOligo, ...]
    amplicon: Amplicon | None = None
    cleanup_kit: Material = field(default_factory=kit)
    fusion: Fusion = "none"
    thresholds: Mapping[PrimerRole, Thresholds] = THRESHOLDS_FOR

    @property
    def product(self) -> SequenceRecord:
        """The expression clone the LR reaction makes."""
        return self.lr.product

    @property
    def entry(self) -> SequenceRecord:
        """The entry clone LR ran on: the one given, or the one BP made."""
        return self.lr.recombination.moved.record

    @property
    def destination(self) -> SequenceRecord:
        """The destination vector, read on the strand the reaction ran it on."""
        return self.lr.recombination.backbone.record

    @property
    def insert(self) -> SequenceRecord | None:
        """The attB-flanked DNA BP ran on, or ``None`` where no BP reaction was planned."""
        return None if self.bp is None else self.bp.recombination.moved.record

    @property
    def reports(self) -> tuple[PrimerReport, ...]:
        """Every designed oligo's evaluation, in the order the sheet lists them."""
        return tuple(one.report for one in self.designed_oligos)

    @property
    def donor(self) -> SequenceRecord | None:
        """The donor vector, or ``None`` where no BP reaction was planned."""
        return None if self.bp is None else self.bp.recombination.backbone.record

    @property
    def route(self) -> str:
        """Which reactions were planned, for a summary line to name."""
        return "LR" if self.bp is None else "BP then LR"

    @property
    def junctions(self) -> tuple[Junction, Junction]:
        """The two att sites the LR reaction wrote, site 1 first."""
        return self.lr.junctions

    @property
    def checks(self) -> tuple[Check, ...]:
        """Every verdict the plan carries, in the order the bench meets them.

        The oligos come first, then each reaction's own, then the plan's, which span both --
        the insert, the host, the markers and the frame. One of those carries no verdict,
        because the sources judge no threshold for it.
        """
        earlier = () if self.bp is None else self.bp.recombination.checks
        return (
            primer_check(self.reports),
            *earlier,
            *self.lr.recombination.checks,
            *plan_checks(lr=self.lr, bp=self.bp, host=self.host, fusion=self.fusion),
        )

    @property
    def status(self) -> Status:
        """The worst status of any check."""
        return status(self.checks)

    def protocol(self) -> Protocol:
        """Return the bench protocol for this plan."""
        made = protocol_for(
            lr=self.lr,
            bp=self.bp,
            amplicon=self.amplicon,
            colony=self.colony,
            reads=self.reads,
            oligos=self.designed_oligos,
            checks=self.checks,
            host=self.host,
            cleanup_kit=self.cleanup_kit,
            fusion=self.fusion,
            thresholds=self.thresholds,
        )
        return ordered_from_sheet(made)

    def write(self, directory: str | os.PathLike[str]) -> Files:
        """Write the clones, the oligo sheet, the protocol, its page and its maps into `directory`.

        The directory is made when it is not there. The entry clone is written only where BP
        was planned, under `ENTRY_FILE`; the rest are named by `PRODUCT_FILE`, `PRIMER_FILE` and
        by `mbio.protocol.render` for the protocol pair. A second run over the same inputs
        writes the same bytes.
        """
        out = Path(directory)
        out.mkdir(parents=True, exist_ok=True)
        entry = None
        if self.bp is not None:
            entry = out / ENTRY_FILE
            write_dna(self.bp.product, entry)
        product = out / PRODUCT_FILE
        write_dna(self.product, product)
        sheet = out / PRIMER_FILE
        sheet.write_text(primer_sheet(self.reports), encoding="utf-8")
        written = write_run_files(as_project(self.protocol()), out)
        return Files(entry, product, sheet, written.data, written.page, written.maps)

product property

product: SequenceRecord

The expression clone the LR reaction makes.

entry property

The entry clone LR ran on: the one given, or the one BP made.

checks property

checks: tuple[Check, ...]

Every verdict the plan carries, in the order the bench meets them.

The oligos come first, then each reaction's own, then the plan's, which span both -- the insert, the host, the markers and the frame. One of those carries no verdict, because the sources judge no threshold for it.

status property

status: Status

The worst status of any check.

write

write(directory: str | PathLike[str]) -> Files

Write the clones, the oligo sheet, the protocol, its page and its maps into directory.

The directory is made when it is not there. The entry clone is written only where BP was planned, under ENTRY_FILE; the rest are named by PRODUCT_FILE, PRIMER_FILE and by mbio.protocol.render for the protocol pair. A second run over the same inputs writes the same bytes.

Source code in src/mbio/cloning/gateway/plan.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def write(self, directory: str | os.PathLike[str]) -> Files:
    """Write the clones, the oligo sheet, the protocol, its page and its maps into `directory`.

    The directory is made when it is not there. The entry clone is written only where BP
    was planned, under `ENTRY_FILE`; the rest are named by `PRODUCT_FILE`, `PRIMER_FILE` and
    by `mbio.protocol.render` for the protocol pair. A second run over the same inputs
    writes the same bytes.
    """
    out = Path(directory)
    out.mkdir(parents=True, exist_ok=True)
    entry = None
    if self.bp is not None:
        entry = out / ENTRY_FILE
        write_dna(self.bp.product, entry)
    product = out / PRODUCT_FILE
    write_dna(self.product, product)
    sheet = out / PRIMER_FILE
    sheet.write_text(primer_sheet(self.reports), encoding="utf-8")
    written = write_run_files(as_project(self.protocol()), out)
    return Files(entry, product, sheet, written.data, written.page, written.maps)

Design primers

The mbio primers design command covers the usual job, and the command line reference describes it. What a script adds is control. You can name a placement by hand, read every ranked pair instead of the winner, judge an oligo you already hold, and read each check that fired.

POLYMERASES is the four polymerases the package ships. Each one carries the buffer its Tm is computed in, and its own cycling rules.

mbio.primers.design_pair

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

Design a pair amplifying start to end, judged on what a pair adds as well.

end passes the length of a circular template when the amplicon crosses the origin. Each candidate is judged as in design_primer, and every pair of them adds pair_checks: the gap between the two Tms, the heterodimers and the products. The pair chosen ranks best on its worst status, then on the same keys summed over both primers, so a pair worse than a pass means nothing here passes; ranked_pairs gives the rest in that order. A placement widens either primer's search; the two bound the amplicon between them, which is why nothing takes an amplicon size. Neither primer takes a binding site in exclude.

Raises:

Type Description
ValueError

If no annealing region fits the template at either end, or fits that end's placement, or exclude holds every one at an end.

Examples:

>>> template = SequenceRecord(
...     "GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT"
...     "TACAGGATCCACTAGTAACGGCCGCCAGTGTGCTGGAATTCGCCCTTA"
... )
>>> [primer.sequence for primer in design_pair(template, 0, len(template))]
['GGCGTAATCATGGTCATAGC', 'TAAGGGCGAATTCCAGCA']

In a region, where each primer is free inside its own flank and the two bound the amplicon:

>>> forward = Placement(five_prime=Segment(0, 6), three_prime=Segment(15, 35))
>>> reverse = Placement(five_prime=Segment(90, 96), three_prime=Segment(60, 80))
>>> pair = design_pair(
...     template,
...     0,
...     len(template),
...     forward_placement=forward,
...     reverse_placement=reverse,
... )
>>> [primer.sequence for primer in pair]
['CGTAATCATGGTCATAGCTGTT', 'CGAATTCCAGCACACTGG']
Source code in src/mbio/primers/design.py
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
def design_pair(
    template: SequenceRecord,
    start: int,
    end: int,
    *,
    forward_placement: Placement | None = None,
    reverse_placement: Placement | None = None,
    forward_tail: str = "",
    reverse_tail: str = "",
    forward_name: str = "",
    reverse_name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    exclude: Container[BindingSite] = (),
) -> tuple[Primer, Primer]:
    """Design a pair amplifying `start` to `end`, judged on what a pair adds as well.

    `end` passes the length of a circular template when the amplicon crosses the origin. Each
    candidate is judged as in `design_primer`, and every pair of them adds `pair_checks`: the
    gap between the two Tms, the heterodimers and the products. The pair chosen ranks best on
    its worst status, then on the same keys summed over both primers, so a pair worse than a
    pass means nothing here passes; `ranked_pairs` gives the rest in that order. A placement
    widens either primer's search; the two bound the amplicon between them, which is why
    nothing takes an amplicon size. Neither primer takes a binding site in `exclude`.

    Raises
    ------
    ValueError
        If no annealing region fits the template at either end, or fits that end's placement,
        or `exclude` holds every one at an end.

    Examples
    --------
    >>> template = SequenceRecord(
    ...     "GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT"
    ...     "TACAGGATCCACTAGTAACGGCCGCCAGTGTGCTGGAATTCGCCCTTA"
    ... )
    >>> [primer.sequence for primer in design_pair(template, 0, len(template))]
    ['GGCGTAATCATGGTCATAGC', 'TAAGGGCGAATTCCAGCA']

    In a region, where each primer is free inside its own flank and the two bound the amplicon:

    >>> forward = Placement(five_prime=Segment(0, 6), three_prime=Segment(15, 35))
    >>> reverse = Placement(five_prime=Segment(90, 96), three_prime=Segment(60, 80))
    >>> pair = design_pair(
    ...     template,
    ...     0,
    ...     len(template),
    ...     forward_placement=forward,
    ...     reverse_placement=reverse,
    ... )
    >>> [primer.sequence for primer in pair]
    ['CGTAATCATGGTCATAGCTGTT', 'CGAATTCCAGCACACTGG']
    """
    best = next(
        ranked_pairs(
            template,
            start,
            end,
            forward_placement=forward_placement,
            reverse_placement=reverse_placement,
            forward_tail=forward_tail,
            reverse_tail=reverse_tail,
            forward_name=forward_name,
            reverse_name=reverse_name,
            target_tm=target_tm,
            polymerase=polymerase,
            thresholds=thresholds,
            exclude=exclude,
        )
    )
    return best.forward.primer, best.reverse.primer

mbio.primers.design_primer

design_primer(
    template: SequenceRecord,
    position: int,
    strand: Strand,
    *,
    placement: Placement | None = None,
    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, or anywhere a Placement allows.

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. placement widens the search to every binding site it allows, and position stays the 5' end asked for. Each candidate, at every length Thresholds.length does not fail, is judged whole by evaluate_primer on the template, structures, runs and off-target sites included, so a designed primer is worse than a pass only where nothing the placement allows passes. Candidates rank by worst status, then by how many checks did not pass, then by the band a length passes on, then by the Tm nearest target_tm, then by nearness to position, and the shorter primer breaks a tie. tail joins its 5' end and stays out of the binding site.

Raises:

Type Description
ValueError

If no annealing region fits the template there, or fits the placement.

Examples:

>>> template = SequenceRecord(
...     "GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT"
...     "TACAGGATCCACTAGTAACGGCCGCCAGTGTGCTGGAATTCGCCCTTA"
... )
>>> design_primer(template, 0, Strand.FORWARD, name="MCS fwd").sequence
'GGCGTAATCATGGTCATAGC'

Anchored, where a tail has to join the 5' end at one place and only the length may vary:

>>> anchored = Placement(five_prime=Segment(48, 49))
>>> design_primer(template, 48, Strand.FORWARD, placement=anchored, tail="TTGGTCTCA").sequence
'TTGGTCTCATACAGGATCCACTAGTAACGG'

Near a target, where the 3' end has to land 10 to 21 bases before a junction:

>>> junction = 48
>>> near = Placement(three_prime=Segment(junction - 21, junction - 9))
>>> primer = design_primer(template, junction - 40, Strand.FORWARD, placement=near)
>>> primer.sequence, junction - primer.binding_sites[0].end
('ATCATGGTCATAGCTGTTTCC', 21)

In a region, where both ends are free inside a flank:

>>> region = Placement(five_prime=Segment(0, 6), three_prime=Segment(20, 40))
>>> design_primer(template, 0, Strand.FORWARD, placement=region).sequence
'CGTAATCATGGTCATAGCTGTT'
Source code in src/mbio/primers/design.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
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
def design_primer(
    template: SequenceRecord,
    position: int,
    strand: Strand,
    *,
    placement: Placement | None = None,
    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, or anywhere a `Placement` allows.

    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. `placement` widens the search to every binding site it allows, and
    `position` stays the 5' end asked for. Each candidate, at every length `Thresholds.length`
    does not fail, is judged whole by `evaluate_primer` on the template, structures, runs and
    off-target sites included, so a designed primer is worse than a pass only where nothing the
    placement allows passes. Candidates rank by worst status, then by how many checks did not
    pass, then by the band a length passes on, then by the Tm nearest `target_tm`, then by
    nearness to `position`, and the shorter primer breaks a tie. `tail` joins its 5' end and
    stays out of the binding site.

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

    Examples
    --------
    >>> template = SequenceRecord(
    ...     "GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT"
    ...     "TACAGGATCCACTAGTAACGGCCGCCAGTGTGCTGGAATTCGCCCTTA"
    ... )
    >>> design_primer(template, 0, Strand.FORWARD, name="MCS fwd").sequence
    'GGCGTAATCATGGTCATAGC'

    Anchored, where a tail has to join the 5' end at one place and only the length may vary:

    >>> anchored = Placement(five_prime=Segment(48, 49))
    >>> design_primer(template, 48, Strand.FORWARD, placement=anchored, tail="TTGGTCTCA").sequence
    'TTGGTCTCATACAGGATCCACTAGTAACGG'

    Near a target, where the 3' end has to land 10 to 21 bases before a junction:

    >>> junction = 48
    >>> near = Placement(three_prime=Segment(junction - 21, junction - 9))
    >>> primer = design_primer(template, junction - 40, Strand.FORWARD, placement=near)
    >>> primer.sequence, junction - primer.binding_sites[0].end
    ('ATCATGGTCATAGCTGTTTCC', 21)

    In a region, where both ends are free inside a flank:

    >>> region = Placement(five_prime=Segment(0, 6), three_prime=Segment(20, 40))
    >>> design_primer(template, 0, Strand.FORWARD, placement=region).sequence
    'CGTAATCATGGTCATAGCTGTT'
    """
    options = _options(
        template, position, strand, placement, tail, name, target_tm, polymerase, thresholds
    )
    return min(_fitted(options, position, placement), key=lambda option: option.rank).primer

mbio.primers.ranked_pairs

ranked_pairs(
    template: SequenceRecord,
    start: int,
    end: int,
    *,
    forward_placement: Placement | None = None,
    reverse_placement: Placement | None = None,
    forward_tail: str = "",
    reverse_tail: str = "",
    forward_name: str = "",
    reverse_name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    exclude: Container[BindingSite] = (),
) -> Iterator[PairReport]

Yield every pair design_pair chooses between, best first, judged as by evaluate_pair.

The first is the pair design_pair chooses. A pair is judged only once no pair still to judge could rank above it, so taking the first few judges few. A pair holding a binding site in exclude is passed over, and exclude is read as pairs are taken: a site added to it between two pairs stays out of every later one.

Raises:

Type Description
ValueError

As design_pair does, on the call rather than when the first pair is taken.

Examples:

>>> template = SequenceRecord(
...     "GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT"
...     "TACAGGATCCACTAGTAACGGCCGCCAGTGTGCTGGAATTCGCCCTTA"
... )
>>> excluded = set()
>>> pairs = ranked_pairs(template, 0, len(template), exclude=excluded)
>>> best = next(pairs)
>>> [primer.sequence for primer in (best.forward.primer, best.reverse.primer)]
['GGCGTAATCATGGTCATAGC', 'TAAGGGCGAATTCCAGCA']

A site excluded once a pair is taken stays out of every pair after it:

>>> excluded.add(best.reverse.primer.binding_sites[0])
>>> after = next(pairs)
>>> [primer.sequence for primer in (after.forward.primer, after.reverse.primer)]
['GGCGTAATCATGGTCATAGC', 'TAAGGGCGAATTCCAGCAC']
Source code in src/mbio/primers/design.py
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
def ranked_pairs(
    template: SequenceRecord,
    start: int,
    end: int,
    *,
    forward_placement: Placement | None = None,
    reverse_placement: Placement | None = None,
    forward_tail: str = "",
    reverse_tail: str = "",
    forward_name: str = "",
    reverse_name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    exclude: Container[BindingSite] = (),
) -> Iterator[PairReport]:
    """Yield every pair `design_pair` chooses between, best first, judged as by `evaluate_pair`.

    The first is the pair `design_pair` chooses. A pair is judged only once no pair still to
    judge could rank above it, so taking the first few judges few. A pair holding a binding site
    in `exclude` is passed over, and `exclude` is read as pairs are taken: a site added to it
    between two pairs stays out of every later one.

    Raises
    ------
    ValueError
        As `design_pair` does, on the call rather than when the first pair is taken.

    Examples
    --------
    >>> template = SequenceRecord(
    ...     "GGCGTAATCATGGTCATAGCTGTTTCCTGTGTGAAATTGTTATCCGCT"
    ...     "TACAGGATCCACTAGTAACGGCCGCCAGTGTGCTGGAATTCGCCCTTA"
    ... )
    >>> excluded = set()
    >>> pairs = ranked_pairs(template, 0, len(template), exclude=excluded)
    >>> best = next(pairs)
    >>> [primer.sequence for primer in (best.forward.primer, best.reverse.primer)]
    ['GGCGTAATCATGGTCATAGC', 'TAAGGGCGAATTCCAGCA']

    A site excluded once a pair is taken stays out of every pair after it:

    >>> excluded.add(best.reverse.primer.binding_sites[0])
    >>> after = next(pairs)
    >>> [primer.sequence for primer in (after.forward.primer, after.reverse.primer)]
    ['GGCGTAATCATGGTCATAGC', 'TAAGGGCGAATTCCAGCAC']
    """
    forwards = _options(
        template,
        start,
        Strand.FORWARD,
        forward_placement,
        forward_tail,
        forward_name,
        target_tm,
        polymerase,
        thresholds,
    )
    reverses = _options(
        template,
        end,
        Strand.REVERSE,
        reverse_placement,
        reverse_tail,
        reverse_name,
        target_tm,
        polymerase,
        thresholds,
    )
    _fitted(forwards, start, forward_placement)
    _fitted(reverses, end, reverse_placement)
    for options in (forwards, reverses):
        if all(option.site in exclude for option in options):
            strand = options[0].site.strand.name.lower()
            raise ValueError(f"every {strand} binding site is excluded")
    return _in_rank_order(forwards, reverses, template, polymerase, thresholds, exclude)

mbio.primers.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/mbio/primers/evaluation.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
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')
    """
    bases = None if template is None else (template.sequence, template.topology)
    checks = _checks(primer.sequence, primer.binding_sites, bases, polymerase, thresholds)
    return PrimerReport(primer, checks)

mbio.primers.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 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/mbio/primers/evaluation.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
230
231
232
233
234
235
236
237
238
239
240
241
242
243
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 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')
    """
    reports = tuple(
        evaluate_primer(primer, template, polymerase=polymerase, thresholds=thresholds)
        for primer in (forward, reverse)
    )
    checks = pair_checks(reports[0], reports[1], template, thresholds=thresholds)
    tms = tuple(report["tm"].value for report in reports)
    length = int(checks[-1].value) or None
    return PairReport(
        reports[0],
        reports[1],
        checks,
        polymerase.annealing_temperature(*tms),
        length,
        None if length is None else polymerase.extension_seconds(length),
    )

mbio.primers.Placement dataclass

Where a primer's binding site may lie: the positions each of its two ends may take.

A tail fixes the 5' end, a target to read across bounds each end's distance from it, and a region bounds the amplicon. A position is a boundary, 0-based and half-open as everywhere: a forward site's 5' end is its start and its 3' end its end, and a reverse site's the other way round, which is the position design_primer reads. A span runs past the length of a circular template when it crosses the origin.

Parameters:

Name Type Description Default
five_prime Segment | None

The positions its 5' end may take. Segment(442, 463) allows 442 to 462. None leaves it wherever a length puts it.

None
three_prime Segment | None

The positions its 3' end may take, the same way.

None

Raises:

Type Description
ValueError

If neither end is bounded, which would say nothing at all.

Examples:

>>> template = SequenceRecord("ACGT" * 12)
>>> anchored = Placement(five_prime=Segment(0, 1))
>>> anchored.allows(BindingSite(0, 20, Strand.FORWARD), template)
True
>>> anchored.allows(BindingSite(1, 21, Strand.FORWARD), template)
False
Source code in src/mbio/primers/placement.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
@dataclass(frozen=True, slots=True)
class Placement:
    """Where a primer's binding site may lie: the positions each of its two ends may take.

    A tail fixes the 5' end, a target to read across bounds each end's distance from it, and a
    region bounds the amplicon. A position is a boundary, 0-based and half-open as everywhere:
    a forward site's 5' end is its start and its 3' end its end, and a reverse site's the other
    way round, which is the position `design_primer` reads. A span runs past the length of a
    circular template when it crosses the origin.

    Parameters
    ----------
    five_prime
        The positions its 5' end may take. `Segment(442, 463)` allows 442 to 462. ``None``
        leaves it wherever a length puts it.
    three_prime
        The positions its 3' end may take, the same way.

    Raises
    ------
    ValueError
        If neither end is bounded, which would say nothing at all.

    Examples
    --------
    >>> template = SequenceRecord("ACGT" * 12)
    >>> anchored = Placement(five_prime=Segment(0, 1))
    >>> anchored.allows(BindingSite(0, 20, Strand.FORWARD), template)
    True
    >>> anchored.allows(BindingSite(1, 21, Strand.FORWARD), template)
    False
    """

    five_prime: Segment | None = None
    three_prime: Segment | None = None

    def __post_init__(self) -> None:
        """Refuse a placement that bounds neither end."""
        if self.five_prime is None and self.three_prime is None:
            raise ValueError("a placement bounds at least one end of a binding site")

    def allows(self, site: BindingSite, template: SequenceRecord) -> bool:
        """Return whether both ends of a binding site lie where this placement puts them."""
        ends = (site.start, site.end) if site.strand is Strand.FORWARD else (site.end, site.start)
        return all(
            span is None or template.covers(span, position)
            for span, position in zip((self.five_prime, self.three_prime), ends, strict=True)
        )

__post_init__

__post_init__() -> None

Refuse a placement that bounds neither end.

Source code in src/mbio/primers/placement.py
57
58
59
60
def __post_init__(self) -> None:
    """Refuse a placement that bounds neither end."""
    if self.five_prime is None and self.three_prime is None:
        raise ValueError("a placement bounds at least one end of a binding site")

allows

allows(site: BindingSite, template: SequenceRecord) -> bool

Return whether both ends of a binding site lie where this placement puts them.

Source code in src/mbio/primers/placement.py
62
63
64
65
66
67
68
def allows(self, site: BindingSite, template: SequenceRecord) -> bool:
    """Return whether both ends of a binding site lie where this placement puts them."""
    ends = (site.start, site.end) if site.strand is Strand.FORWARD else (site.end, site.start)
    return all(
        span is None or template.covers(span, position)
        for span, position in zip((self.five_prime, self.three_prime), ends, strict=True)
    )

mbio.primers.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/mbio/primers/evaluation.py
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
@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_of(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/mbio/primers/evaluation.py
58
59
60
61
62
63
64
65
66
def __getitem__(self, name: str) -> Check:
    """Return the check of that name.

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

mbio.primers.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/mbio/primers/evaluation.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 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/mbio/primers/evaluation.py
195
196
197
198
199
200
201
202
203
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)

mbio.primers.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/mbio/primers/polymerase.py
287
288
289
290
291
292
293
294
295
296
297
298
299
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))

mbio.primers.POLYMERASES module-attribute

POLYMERASES: tuple[Polymerase, ...] = (
    Q5,
    PHUSION,
    TAQ,
    ONETAQ,
)

mbio.primers.design_pair_on_genome

design_pair_on_genome(
    fasta: str | Path,
    assembly: str,
    region: Locus,
    *,
    flank: int,
    forward_tail: str = "",
    reverse_tail: str = "",
    forward_name: str = "",
    reverse_name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    timeout: float = 300.0,
) -> GenomeDesign

Design the best-ranked pair amplifying a genome region that is specific on that genome.

The template is the region and a flank each side, read through the FASTA's index and clipped to the sequence. The forward primer is placed in the left flank and the reverse in the right, so the amplicon covers the region whole and ties go to the pair nearest it, as design_pair ranks one. Thresholds.genome_pairs_per_search pairs are then checked in one genome search, best-ranked first, and the first making no off-target amplicon wins.

Where none does, the round leaves the search narrower: a primer that makes an off-target amplicon on its own is kept out of every later pair, as is every binding site sharing its 3' end, which primes the same place; a pair that makes one only together is passed over, each of its primers still free to pair with another. After Thresholds.genome_rounds searches, or once the pairs run out, the best-ranked pair checked comes back with its off-target amplicons, marked not specific.

Raises:

Type Description
FileNotFoundError

If no index lies beside the FASTA.

RuntimeError

If ipcr is not installed, or refuses a search.

TimeoutError

If a search runs past timeout.

ValueError

If the sequence does not hold the region, or a flank holds no annealing region.

Examples:

>>> region = Locus("chr1", 156710401, 156710701)
>>> design = design_pair_on_genome("hg38.fa", "hg38", region, flank=100)
>>> design.specific, design.rounds, design.forward.sequence
(True, 1, 'ACCTAGGAGAAGTGGCCAGC')
Source code in src/mbio/primers/genome.py
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
def design_pair_on_genome(
    fasta: str | Path,
    assembly: str,
    region: Locus,
    *,
    flank: int,
    forward_tail: str = "",
    reverse_tail: str = "",
    forward_name: str = "",
    reverse_name: str = "",
    target_tm: float = TARGET_TM,
    polymerase: Polymerase = Q5,
    thresholds: Thresholds = THRESHOLDS,
    timeout: float = 300.0,
) -> GenomeDesign:
    """Design the best-ranked pair amplifying a genome region that is specific on that genome.

    The template is the region and a flank each side, read through the FASTA's index and
    clipped to the sequence. The forward primer is placed in the left flank and the reverse in
    the right, so the amplicon covers the region whole and ties go to the pair nearest it, as
    `design_pair` ranks one. `Thresholds.genome_pairs_per_search` pairs are then checked in one
    genome search, best-ranked first, and the first making no off-target amplicon wins.

    Where none does, the round leaves the search narrower: a primer that makes an off-target
    amplicon on its own is kept out of every later pair, as is every binding site sharing its
    3' end, which primes the same place; a pair that makes one only together is passed over,
    each of its primers still free to pair with another. After `Thresholds.genome_rounds`
    searches, or once the pairs run out, the best-ranked pair checked comes back with its
    off-target amplicons, marked not specific.

    Raises
    ------
    FileNotFoundError
        If no index lies beside the FASTA.
    RuntimeError
        If `ipcr` is not installed, or refuses a search.
    TimeoutError
        If a search runs past `timeout`.
    ValueError
        If the sequence does not hold the region, or a flank holds no annealing region.

    Examples
    --------
    >>> region = Locus("chr1", 156710401, 156710701)
    >>> design = design_pair_on_genome("hg38.fa", "hg38", region, flank=100)  # doctest: +SKIP
    >>> design.specific, design.rounds, design.forward.sequence  # doctest: +SKIP
    (True, 1, 'ACCTAGGAGAAGTGGCCAGC')
    """
    start = max(region.start - flank, 0)
    template = read_region(fasta, region.sequence_name, start, region.end + flank)
    where = Locus(region.sequence_name, start, start + len(template))
    left, right = region.start - where.start, region.end - where.start
    if left < 1 or right >= len(template):
        raise ValueError(
            f"{region.sequence_name} holds no flank either side of {region.start}-{region.end}"
        )
    excluded = _ThreePrimeEnds()
    pairs = ranked_pairs(
        template,
        left,
        right,
        forward_placement=Placement(five_prime=Segment(0, left), three_prime=Segment(0, left + 1)),
        reverse_placement=Placement(
            five_prime=Segment(right + 1, len(template) + 1),
            three_prime=Segment(right, len(template)),
        ),
        forward_tail=forward_tail,
        reverse_tail=reverse_tail,
        forward_name=forward_name,
        reverse_name=reverse_name,
        target_tm=target_tm,
        polymerase=polymerase,
        thresholds=thresholds,
        exclude=excluded,
    )
    best: tuple[PairReport, GenomeReport] | None = None
    rounds = 0
    exhausted = False
    while rounds < thresholds.genome_rounds and not exhausted:
        batch = list(islice(pairs, thresholds.genome_pairs_per_search))
        exhausted = len(batch) < thresholds.genome_pairs_per_search
        if not batch:
            break
        rounds += 1
        reports = evaluate_on_genome(
            [(pair.forward.primer, pair.reverse.primer) for pair in batch],
            fasta,
            assembly,
            intended=[_intended(pair, where) for pair in batch],
            thresholds=thresholds,
            timeout=timeout,
        )
        for pair, report in zip(batch, reports, strict=True):
            best = best or (pair, report)
            if not report.off_target:
                return GenomeDesign(where, pair, report, rounds, True, exhausted)
            for amplicon in report.off_target:
                if amplicon.made_by != "pair":
                    alone = pair.forward if amplicon.made_by == "forward" else pair.reverse
                    excluded.add(alone.primer.binding_sites[0])
    if best is None:
        raise ValueError(f"no pair amplifies {region.sequence_name}:{region.start}-{region.end}")
    return GenomeDesign(where, *best, rounds, False, exhausted)

mbio.primers.evaluate_pair_on_genome

evaluate_pair_on_genome(
    forward: Primer,
    reverse: Primer,
    fasta: str | Path,
    assembly: str,
    *,
    intended: Locus | None = None,
    circular: bool = False,
    thresholds: Thresholds = THRESHOLDS,
    timeout: float = 300.0,
) -> GenomeReport

Check one primer pair against a genome FASTA, as evaluate_on_genome checks several.

Examples:

>>> forward = Primer("fwd", "ACCTAGGAGAAGTGGCCAGC")
>>> reverse = Primer("rev", "GCCTTGGAGAGGAGTTCTGC")
>>> target = Locus("chr1", 156710301, 156710801)
>>> report = evaluate_pair_on_genome(forward, reverse, "hg38.fa", "hg38", intended=target)
>>> report.status, report.near_matches_checked
('pass', False)
Source code in src/mbio/primers/genome.py
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
def evaluate_pair_on_genome(
    forward: Primer,
    reverse: Primer,
    fasta: str | Path,
    assembly: str,
    *,
    intended: Locus | None = None,
    circular: bool = False,
    thresholds: Thresholds = THRESHOLDS,
    timeout: float = 300.0,
) -> GenomeReport:
    """Check one primer pair against a genome FASTA, as `evaluate_on_genome` checks several.

    Examples
    --------
    >>> forward = Primer("fwd", "ACCTAGGAGAAGTGGCCAGC")
    >>> reverse = Primer("rev", "GCCTTGGAGAGGAGTTCTGC")
    >>> target = Locus("chr1", 156710301, 156710801)
    >>> report = evaluate_pair_on_genome(forward, reverse, "hg38.fa", "hg38", intended=target)  # doctest: +SKIP
    >>> report.status, report.near_matches_checked  # doctest: +SKIP
    ('pass', False)
    """
    return evaluate_on_genome(
        [(forward, reverse)],
        fasta,
        assembly,
        intended=[intended],
        circular=circular,
        thresholds=thresholds,
        timeout=timeout,
    )[0]

mbio.primers.GenomeReport dataclass

What one primer pair makes on a genome, and the checks on it.

Parameters:

Name Type Description Default
assembly str

The genome's name, such as "hg38".

required
forward Primer

The pair checked.

required
reverse Primer

The pair checked.

required
amplicons tuple[Amplicon, ...]

Every amplicon, by sequence name and then position.

required
mismatches int

The most a binding site could carry: zero is a perfect-match screen.

required
terminal_window int

The 3'-terminal bases held to a perfect match.

required
checks tuple[Check, ...]

Off-target amplicons, and the intended amplicon where one was named.

required
Source code in src/mbio/primers/genome.py
 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
@dataclass(frozen=True, slots=True)
class GenomeReport:
    """What one primer pair makes on a genome, and the checks on it.

    Parameters
    ----------
    assembly
        The genome's name, such as ``"hg38"``.
    forward, reverse
        The pair checked.
    amplicons
        Every amplicon, by sequence name and then position.
    mismatches
        The most a binding site could carry: zero is a perfect-match screen.
    terminal_window
        The 3'-terminal bases held to a perfect match.
    checks
        Off-target amplicons, and the intended amplicon where one was named.
    """

    assembly: str
    forward: Primer
    reverse: Primer
    amplicons: tuple[Amplicon, ...]
    mismatches: int
    terminal_window: int
    checks: tuple[Check, ...]

    @property
    def near_matches_checked(self) -> bool:
        """Return whether the search found binding sites that do not match perfectly."""
        return self.mismatches > 0

    @property
    def off_target(self) -> tuple[Amplicon, ...]:
        """Return every amplicon but the intended one."""
        return tuple(amplicon for amplicon in self.amplicons if not amplicon.intended)

    @property
    def status(self) -> Status:
        """Return the worst status of any check."""
        return worst_of(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)

near_matches_checked property

near_matches_checked: bool

Return whether the search found binding sites that do not match perfectly.

off_target property

off_target: tuple[Amplicon, ...]

Return every amplicon but the intended one.

status property

status: Status

Return 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/mbio/primers/genome.py
130
131
132
133
134
135
136
137
138
139
140
141
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)

Check a clone's sequencing

mbio sequence-verify checks one clone from the command line. From a script you choose what to judge, and read every difference and where each result landed.

read_trace reads a Sanger .ab1 or .abi file. A whole-plasmid consensus is a SequencingResult made from its bases. regions finds the junctions a cloning plan marked in its product, and the inserts between them. verify gives each of those a verdict, and the clone one too. draw_page lays that out as one page to write, and read_channels reads a trace's four channels so the page can draw each one under its bases.

mbio.verification.read_trace

read_trace(path: str | PathLike[str]) -> SequencingResult

Read an .ab1 trace into a sequencing result.

Parameters:

Name Type Description Default
path str | PathLike[str]

An .ab1 file a basecaller wrote.

required

Returns:

Type Description
SequencingResult

Named for the file. bases are the bases the basecaller called, quality their Phred values, and trusted the span Biopython's abi-trim keeps by Mott's rule. A trace whose qualities are all zero trusts nothing.

A base called A, C, G or T is mixed, and written as the IUPAC code of its two bases, where another channel's signal at its peak reaches 25% of the called base's. That signal is counted from the higher of its lowest points between this peak and each neighbouring base's, so the tail of a neighbouring peak in the same channel makes no base mixed. Any other code the basecaller wrote is kept.

Raises:

Type Description
ValueError

If the file lacks the bases, qualities, peak positions or analysed channels.

Source code in src/mbio/verification/trace.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
62
63
64
65
66
67
68
def read_trace(path: str | os.PathLike[str]) -> SequencingResult:
    """Read an ``.ab1`` trace into a sequencing result.

    Parameters
    ----------
    path
        An ``.ab1`` file a basecaller wrote.

    Returns
    -------
    SequencingResult
        Named for the file. ``bases`` are the bases the basecaller called, ``quality`` their
        Phred values, and ``trusted`` the span Biopython's ``abi-trim`` keeps by Mott's rule. A
        trace whose qualities are all zero trusts nothing.

        A base called A, C, G or T is mixed, and written as the IUPAC code of its two bases,
        where another channel's signal at its peak reaches 25% of the called base's. That signal
        is counted from the higher of its lowest points between this peak and each neighbouring
        base's, so the tail of a neighbouring peak in the same channel makes no base mixed. Any
        other code the basecaller wrote is kept.

    Raises
    ------
    ValueError
        If the file lacks the bases, qualities, peak positions or analysed channels.
    """
    raw = _tags(path)
    called = raw["PBAS2"].decode("ascii").upper()
    quality = tuple(raw["PCON2"])
    channels = dict(zip(raw["FWO_1"].decode("ascii"), (raw[tag] for tag in _CHANNELS), strict=True))
    bases = "".join(
        _read_base(base, raw["PLOC2"], index, channels) if base in channels else base
        for index, base in enumerate(called)
    )
    trusted = _mott(quality) if any(quality) else (0, 0)
    return SequencingResult(Path(path).stem, bases, quality=quality, trusted=trusted)

mbio.verification.SequencingResult dataclass

One sample's sequencing, read into the bases called for it.

Parameters:

Name Type Description Default
name str

What the sample is called, such as the read's file name.

required
bases str

The called bases, stored upper-case. An N is no call; any other ambiguity code is a mixed base.

required
quality tuple[int, ...] | None

A Phred value per base, where the route gives one.

None
depth tuple[int, ...] | None

How many reads stand behind each base, where the route gives it.

None
reads int | None

How many reads stood behind the whole.

None
trusted tuple[int, int] | None

The stretch worth trusting, half-open in the result's own coordinates. It may be empty, when a reader trusts nothing; None trusts all of it.

None
others tuple[str, ...]

Any further consensus the same sample gave, which makes it mixed. Stored upper-case.

()

Raises:

Type Description
ValueError

If bases or one of others is not IUPAC DNA, naming the first letter that is not and its 1-based position; if quality or depth is not one value a base; or if trusted falls outside the bases.

Examples:

>>> SequencingResult("A1", "acgt", trusted=(1, 3)).bases
'ACGT'
Source code in src/mbio/verification/result.py
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
@dataclass(frozen=True, slots=True)
class SequencingResult:
    """One sample's sequencing, read into the bases called for it.

    Parameters
    ----------
    name
        What the sample is called, such as the read's file name.
    bases
        The called bases, stored upper-case. An ``N`` is no call; any other ambiguity code is a
        mixed base.
    quality
        A Phred value per base, where the route gives one.
    depth
        How many reads stand behind each base, where the route gives it.
    reads
        How many reads stood behind the whole.
    trusted
        The stretch worth trusting, half-open in the result's own coordinates. It may be empty,
        when a reader trusts nothing; ``None`` trusts all of it.
    others
        Any further consensus the same sample gave, which makes it mixed. Stored upper-case.

    Raises
    ------
    ValueError
        If `bases` or one of `others` is not IUPAC DNA, naming the first letter that is not and
        its 1-based position; if `quality` or `depth` is not one value a base; or if `trusted`
        falls outside the bases.

    Examples
    --------
    >>> SequencingResult("A1", "acgt", trusted=(1, 3)).bases
    'ACGT'
    """

    name: str
    bases: str
    _: KW_ONLY
    quality: tuple[int, ...] | None = None
    depth: tuple[int, ...] | None = None
    reads: int | None = None
    trusted: tuple[int, int] | None = None
    others: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        """Upper-case the bases and refuse a field that does not fit them."""
        object.__setattr__(self, "bases", _bases(self.bases, f"result {self.name!r}"))
        object.__setattr__(
            self,
            "others",
            tuple(_bases(one, f"a further consensus of {self.name!r}") for one in self.others),
        )
        length = len(self.bases)
        for field, values in (("quality", self.quality), ("depth", self.depth)):
            if values is not None and len(values) != length:
                raise ValueError(
                    f"result {self.name!r} carries {len(values)} {field} values for {length} bases"
                )
        if self.trusted is not None:
            start, end = self.trusted
            if not 0 <= start <= end <= length:
                raise ValueError(
                    f"result {self.name!r} trusts {start}-{end}, which falls outside its "
                    f"{length} bases"
                )

    @property
    def trusted_span(self) -> tuple[int, int]:
        """The trusted stretch, half-open: all of the bases when nothing narrowed it."""
        return self.trusted if self.trusted is not None else (0, len(self.bases))

trusted_span property

trusted_span: tuple[int, int]

The trusted stretch, half-open: all of the bases when nothing narrowed it.

__post_init__

__post_init__() -> None

Upper-case the bases and refuse a field that does not fit them.

Source code in src/mbio/verification/result.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def __post_init__(self) -> None:
    """Upper-case the bases and refuse a field that does not fit them."""
    object.__setattr__(self, "bases", _bases(self.bases, f"result {self.name!r}"))
    object.__setattr__(
        self,
        "others",
        tuple(_bases(one, f"a further consensus of {self.name!r}") for one in self.others),
    )
    length = len(self.bases)
    for field, values in (("quality", self.quality), ("depth", self.depth)):
        if values is not None and len(values) != length:
            raise ValueError(
                f"result {self.name!r} carries {len(values)} {field} values for {length} bases"
            )
    if self.trusted is not None:
        start, end = self.trusted
        if not 0 <= start <= end <= length:
            raise ValueError(
                f"result {self.name!r} trusts {start}-{end}, which falls outside its "
                f"{length} bases"
            )

mbio.verification.regions

regions(
    record: SequenceRecord, names: Iterable[str] = ()
) -> tuple[Feature, ...]

Return the features a verification of record judges, in record order.

These are the junctions tagged with JUNCTION_TAG, and the insert between each two consecutive ones, named for the part the first one's tag names. The stretch the junction tagged BACKBONE opens is the vector, and is left out. A name that repeats takes an ordinal. Given names, the features of record so named are returned instead.

Raises:

Type Description
ValueError

If one of names names no feature of record.

Examples:

>>> tags = ((10, "GFP"), (30, BACKBONE))
>>> joins = [Feature("j", "misc_feature", (Segment(at, at + 2),),
...          qualifiers={JUNCTION_TAG: (part,)}) for at, part in tags]
>>> plasmid = SequenceRecord("A" * 50, topology="circular", features=tuple(joins))
>>> [(one.name, one.segments[0].start) for one in regions(plasmid)]
[('j', 10), ('GFP insert', 12), ('j', 30)]
Source code in src/mbio/verification/judge.py
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
def regions(record: SequenceRecord, names: Iterable[str] = ()) -> tuple[Feature, ...]:
    """Return the features a verification of `record` judges, in record order.

    These are the junctions tagged with `JUNCTION_TAG`, and the insert between each two
    consecutive ones, named for the part the first one's tag names. The stretch the junction
    tagged `BACKBONE` opens is the vector, and is left out. A name that repeats takes an
    ordinal. Given `names`, the features of `record` so named are returned instead.

    Raises
    ------
    ValueError
        If one of `names` names no feature of `record`.

    Examples
    --------
    >>> tags = ((10, "GFP"), (30, BACKBONE))
    >>> joins = [Feature("j", "misc_feature", (Segment(at, at + 2),),
    ...          qualifiers={JUNCTION_TAG: (part,)}) for at, part in tags]
    >>> plasmid = SequenceRecord("A" * 50, topology="circular", features=tuple(joins))
    >>> [(one.name, one.segments[0].start) for one in regions(plasmid)]
    [('j', 10), ('GFP insert', 12), ('j', 30)]
    """
    wanted = tuple(names)
    if wanted:
        missing = sorted(set(wanted) - {one.name for one in record.features})
        if missing:
            raise ValueError(f"{record.name or 'the record'} has no feature named {missing[0]!r}")
        found = (one for one in record.features if one.name in wanted)
        return tuple(sorted(found, key=_place))
    junctions = sorted(
        (one for one in record.features if JUNCTION_TAG in one.qualifiers), key=_place
    )
    return tuple(sorted((*junctions, *_inserts(record, junctions)), key=_place))

mbio.verification.verify

verify(
    expected: SequenceRecord,
    results: Sequence[SequencingResult],
    regions: Sequence[Feature],
) -> Verification

Hold one clone's sequencing results against the record it should be.

A base of a result counts only inside its trusted span, at quality 20 or better where it carries a quality, and read by more than 10 reads where it carries a depth: both, where it carries both. An N reads nothing. Bases running past either end of a linear record are not listed and count for nothing below.

A result does not read as this product when more than a tenth of its counted bases are substitutions, mixed bases or bases inserted or left unplaced: ten times the error a Q20 base allows. Such a result's check fails, and its disagreements and placement are withheld, so it reads no region; the other results are judged as before. A result carrying a second consensus fails as mixed, and one trusting no base carries no verdict.

Raises:

Type Description
ValueError

If expected is empty, or a region does not fit it.

Examples:

>>> record = SequenceRecord("GATTACAGGCATTCGACCTA")
>>> region = Feature("insert", "misc_feature", (Segment(4, 16),))
>>> verify(record, (SequencingResult("r", "GATTACAGGCATTCGACCTA"),), (region,)).verified
True
Source code in src/mbio/verification/judge.py
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
def verify(
    expected: SequenceRecord,
    results: Sequence[SequencingResult],
    regions: Sequence[Feature],
) -> Verification:
    """Hold one clone's sequencing results against the record it should be.

    A base of a result counts only inside its trusted span, at quality 20 or better where it
    carries a quality, and read by more than 10 reads where it carries a depth: both, where it
    carries both. An ``N`` reads nothing. Bases running past either end of a linear record are
    not listed and count for nothing below.

    A result does not read as this product when more than a tenth of its counted bases are
    substitutions, mixed bases or bases inserted or left unplaced: ten times the error a Q20
    base allows. Such a result's check fails, and its disagreements and placement are withheld,
    so it reads no region; the other results are judged as before. A result carrying a second
    consensus fails as mixed, and one trusting no base carries no verdict.

    Raises
    ------
    ValueError
        If `expected` is empty, or a region does not fit it.

    Examples
    --------
    >>> record = SequenceRecord("GATTACAGGCATTCGACCTA")
    >>> region = Feature("insert", "misc_feature", (Segment(4, 16),))
    >>> verify(record, (SequencingResult("r", "GATTACAGGCATTCGACCTA"),), (region,)).verified
    True
    """
    n = len(expected)
    if not n:
        raise ValueError("a result is held against a record, and this one is empty")
    for region in regions:
        expected.extract(region)
    readings = [_read(expected, one) for one in results]
    judged = [one for one in readings if one.lined_up and not _gated(one)]
    shown: dict[tuple[Kind, int, int, str], list[_Reading]] = {}
    for reading in judged:
        for key in dict.fromkeys(reading.shown):
            shown.setdefault(key, []).append(reading)
    ordered = sorted(regions, key=lambda one: min(part.start for part in one.segments))
    held = [frozenset(p % n for p in _positions(one)) for one in ordered]
    found: list[tuple[Disagreement, frozenset[int], bool]] = []
    for (kind, start, end, bases), showing in sorted(shown.items(), key=lambda item: item[0][1:]):
        touched = _touched(kind, start, end, n)
        names = tuple(
            region.name
            for region, bases_held in zip(ordered, held, strict=True)
            if touched & bases_held
        )
        features = (
            ()
            if names
            else tuple(
                feature.name
                for feature in expected.features
                if touched & {p % n for p in _positions(feature)}
            )
        )
        disagreement = Disagreement(
            kind,
            start,
            end,
            bases,
            results=tuple(reading.result.name for reading in showing),
            regions=names,
            features=features,
        )
        covering = [reading for reading in judged if touched <= reading.read.keys()]
        shared = all(reading in showing for reading in covering)
        found.append((disagreement, touched, shared))
    return Verification(
        tuple(_region_check(*pair, n, judged, found) for pair in zip(ordered, held, strict=True)),
        tuple(_result_check(one, n) for one in readings),
        tuple(one for one, _, _ in found),
        tuple(
            one.placement
            if one in judged
            else Placement(one.result.name, None, None, one.placement.trusted)
            for one in readings
        ),
    )

mbio.verification.Verification dataclass

What a clone's sequencing results came to against its record.

Parameters:

Name Type Description Default
checks tuple[Check, ...]

One per region, in record order, named for it. Its value is how many of the region's bases were read.

required
result_checks tuple[Check, ...]

One per result, in the order given, named for it. Its value is the share of the result's counted bases that disagree or do not line up.

required
disagreements tuple[Disagreement, ...]

Every place the results read otherwise than the record, in record order.

required
placements tuple[Placement, ...]

Where each result landed, in the order given.

required
Source code in src/mbio/verification/judge.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
162
163
164
165
166
167
168
169
@dataclass(frozen=True, slots=True)
class Verification:
    """What a clone's sequencing results came to against its record.

    Parameters
    ----------
    checks
        One per region, in record order, named for it. Its value is how many of the region's
        bases were read.
    result_checks
        One per result, in the order given, named for it. Its value is the share of the result's
        counted bases that disagree or do not line up.
    disagreements
        Every place the results read otherwise than the record, in record order.
    placements
        Where each result landed, in the order given.
    """

    checks: tuple[Check, ...]
    result_checks: tuple[Check, ...]
    disagreements: tuple[Disagreement, ...]
    placements: tuple[Placement, ...]

    @property
    def status(self) -> Status:
        """The worst verdict of every check, by `mbio.checks.worst_of`."""
        return worst_of((*self.checks, *self.result_checks))

    @property
    def verified(self) -> bool:
        """Whether some region was judged, every region passes and no result check fails."""
        return (
            bool(self.checks)
            and all(one.status == "pass" for one in self.checks)
            and all(one.status != "fail" for one in self.result_checks)
        )

status property

status: Status

The worst verdict of every check, by mbio.checks.worst_of.

verified property

verified: bool

Whether some region was judged, every region passes and no result check fails.

mbio.verification.Disagreement dataclass

One place the results read otherwise than the record.

Parameters:

Name Type Description Default
kind Kind

A substitution, an insertion, a deletion or a mixed base.

required
start int

Where it lies on the record, as an ADR 0001 span. An insertion is empty, at the point its bases go in.

required
end int

Where it lies on the record, as an ADR 0001 span. An insertion is empty, at the point its bases go in.

required
bases str

What the results read there, on the record's top strand; nothing for a deletion.

required
results tuple[str, ...]

The names of the results that show it.

required
regions tuple[str, ...]

The regions it falls in, in record order; empty when it falls outside every one.

()
features tuple[str, ...]

The record's features it falls in, named only when it falls outside every region.

()
Source code in src/mbio/verification/judge.py
 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
@dataclass(frozen=True, slots=True)
class Disagreement:
    """One place the results read otherwise than the record.

    Parameters
    ----------
    kind
        A substitution, an insertion, a deletion or a mixed base.
    start, end
        Where it lies on the record, as an ADR 0001 span. An insertion is empty, at the point
        its bases go in.
    bases
        What the results read there, on the record's top strand; nothing for a deletion.
    results
        The names of the results that show it.
    regions
        The regions it falls in, in record order; empty when it falls outside every one.
    features
        The record's features it falls in, named only when it falls outside every region.
    """

    kind: Kind
    start: int
    end: int
    bases: str
    _: KW_ONLY
    results: tuple[str, ...]
    regions: tuple[str, ...] = ()
    features: tuple[str, ...] = ()

    def said(self, length: int) -> str:
        """Return what it is and where, as a person reads it on a record of `length` bases."""
        if self.kind == "insertion":
            return f"insertion after {position_text(self.start - 1, length)}"
        return f"{self.kind} at {_runs(range(self.start, self.end), length)}"

said

said(length: int) -> str

Return what it is and where, as a person reads it on a record of length bases.

Source code in src/mbio/verification/judge.py
104
105
106
107
108
def said(self, length: int) -> str:
    """Return what it is and where, as a person reads it on a record of `length` bases."""
    if self.kind == "insertion":
        return f"insertion after {position_text(self.start - 1, length)}"
    return f"{self.kind} at {_runs(range(self.start, self.end), length)}"

mbio.verification.draw_page

draw_page(
    expected: SequenceRecord,
    results: Sequence[SequencingResult],
    verification: Verification,
    *,
    channels: Sequence[Channels | None] = (),
) -> Page

Lay out the page for one clone's verification of results against expected.

Parameters:

Name Type Description Default
expected SequenceRecord

The record the results were held against.

required
results Sequence[SequencingResult]

The results verify was given.

required
verification Verification

What verify made of them.

required
channels Sequence[Channels | None]

Each result's channels and peaks, in the order of results, drawn under its bases in every close-up it reaches: None for a result that is no Sanger trace, and empty where none is drawn. They pair with the results by position, as the checks and the placements do, so two results of one name each draw their own.

()

Raises:

Type Description
ValueError

If channels is given, but not one for each result.

Source code in src/mbio/verification/page.py
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
276
277
278
279
280
281
282
def draw_page(
    expected: SequenceRecord,
    results: Sequence[SequencingResult],
    verification: Verification,
    *,
    channels: Sequence[Channels | None] = (),
) -> Page:
    """Lay out the page for one clone's `verification` of `results` against `expected`.

    Parameters
    ----------
    expected
        The record the results were held against.
    results
        The results `verify` was given.
    verification
        What `verify` made of them.
    channels
        Each result's channels and peaks, in the order of `results`, drawn under its bases in
        every close-up it reaches: ``None`` for a result that is no Sanger trace, and empty
        where none is drawn. They pair with the results by position, as the checks and the
        placements do, so two results of one name each draw their own.

    Raises
    ------
    ValueError
        If `channels` is given, but not one for each result.
    """
    if channels and len(channels) != len(results):
        raise ValueError(
            f"{counted(len(channels), 'set')} of channels were given for "
            f"{counted(len(results), 'result')}; give one for each, None for one with no trace"
        )
    statuses: dict[str, Status | None] = {one.name: one.status for one in verification.checks}
    # Withheld from a result that does not read as the record, and from one that trusts nothing.
    placed = [one for one in verification.placements if one.strand is not None]
    results_rows = tuple(
        TableRow(check.name, check.status, _said(check.detail, result, placement))
        for check, result, placement in zip(
            verification.result_checks, results, verification.placements, strict=True
        )
    )
    title = f"{expected.name or 'record'}: {counted(len(results), 'result')}"
    if not placed:
        return Page(title, verification.verified, results_rows, None, ())
    n = len(expected)
    named = [(_name(one, expected), one) for one in verification.disagreements]
    rows = (
        *(TableRow(check.name, check.status, check.detail) for check in verification.checks),
        *(
            TableRow(_outside(one), None, f"{one.said(n)}, read by {', '.join(one.results)}")
            for _, one in named
            if not one.regions
        ),
        *results_rows,
    )
    colored = {name: _VERDICT_COLORS[_verdict(one, statuses)] for name, one in named}
    features = tuple(
        Feature(name, "misc_difference", (Segment(one.start, one.end),), color=colored[name])
        for name, one in named
        if one.kind != "insertion"
    )
    insertions = tuple(
        layers.Insertion(name, one.start, len(one.bases), colored[name])
        for name, one in named
        if one.kind == "insertion"
    )
    primers = tuple(_primer(expected, one, verification.disagreements) for one in placed)
    copy = dataclasses.replace(
        expected,
        features=(*expected.features, *features),
        primers=(*expected.primers, *primers),
    )
    lit = [*(name for name, _ in named), *(primer.name for primer in primers)]
    drawn = draw_map(copy, cut_sites=False, insertions=insertions, highlight=lit)
    tracks = _tracks(expected, results, verification, channels or [None] * len(results))
    close_ups = []
    for name, one in named:
        start, end = _window(one, expected)
        close = draw_map(
            copy,
            region=(start, end),
            sequence_view=True,
            bases_per_row=end - start,
            cut_sites=False,
            insertions=insertions,
            tracks=tracks,
            highlight=[name, *(primer.name for primer in primers)],
        )
        close_ups.append(CloseUp(one, name, _caption(one, statuses), close))
    return Page(title, verification.verified, rows, drawn, tuple(close_ups))

mbio.verification.Page dataclass

One clone's verification laid out, to write as one page.

Parameters:

Name Type Description Default
title str

What the page is called.

required
verified bool

Whether the clone is verified.

required
rows tuple[TableRow, ...]

The table, in the page's order.

required
map Drawing | None

The record with its reads and disagreements; None where no result reads as it.

required
close_ups tuple[CloseUp, ...]

One per disagreement, in record order.

required
Source code in src/mbio/verification/page.py
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
180
181
182
183
184
185
186
187
188
189
@dataclass(frozen=True, slots=True, eq=False)
class Page:
    """One clone's verification laid out, to write as one page.

    Parameters
    ----------
    title
        What the page is called.
    verified
        Whether the clone is verified.
    rows
        The table, in the page's order.
    map
        The record with its reads and disagreements; ``None`` where no result reads as it.
    close_ups
        One per disagreement, in record order.
    """

    title: str
    verified: bool
    rows: tuple[TableRow, ...]
    map: Drawing | None
    close_ups: tuple[CloseUp, ...]

    @property
    def html(self) -> str:
        """The page, one self-contained HTML file."""
        verdict = "Verified" if self.verified else "Not verified"
        state = "pass" if self.verified else "fail"
        body = [
            f'<header><h1 class="verdict is-{state}">{verdict}</h1>'
            f"<p>{escape(self.title)}</p></header>",
            _table(self.rows),
        ]
        if self.map is not None:
            body.append(
                f'<section class="map"><h2>Map</h2><figure>{self.map.element()}</figure></section>'
            )
        if self.close_ups:
            traced = any(
                row.strips
                for one in self.close_ups
                if one.drawing.sequence_view is not None
                for row in one.drawing.sequence_view.rows
            )
            legend = (
                '<p class="legend">Each trace: '
                + ", ".join(
                    f'<span style="color: {color}">{base}</span>'
                    for base, color in _BASE_COLORS.items()
                )
                + ", over the top strand's bases.</p>"
                if traced
                else ""
            )
            figures = "".join(
                f'<figure class="close-up"><figcaption><strong>{escape(one.name)}</strong> '
                f"{escape(one.caption)}</figcaption>{one.drawing.element()}</figure>"
                for one in self.close_ups
            )
            body.append(
                f'<section class="close-ups"><h2>Each disagreement</h2>{legend}{figures}</section>'
            )
        fonts = "".join(font_face(font) for font in (SANS, BOLD, MONO))
        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'
            '<meta name="color-scheme" content="light">\n'
            f"<title>{escape(self.title)}</title>\n<style>\n{fonts}{_asset('page.css')}</style>\n"
            f'</head>\n<body>\n<main class="verification">\n{"\n".join(body)}\n</main>\n'
            "</body>\n</html>\n"
        )

    def write(self, path: str | os.PathLike[str]) -> Path:
        """Write the page to `path`, making its directory if it is not there, and return it."""
        out = Path(path)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(self.html, encoding="utf-8")
        return out

html property

html: str

The page, one self-contained HTML file.

write

write(path: str | PathLike[str]) -> Path

Write the page to path, making its directory if it is not there, and return it.

Source code in src/mbio/verification/page.py
184
185
186
187
188
189
def write(self, path: str | os.PathLike[str]) -> Path:
    """Write the page to `path`, making its directory if it is not there, and return it."""
    out = Path(path)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(self.html, encoding="utf-8")
    return out

mbio.verification.read_channels

read_channels(path: str | PathLike[str]) -> Channels

Read an .ab1 trace's four channels and the scan at each base's peak.

A page takes it in the place of the result read_trace reads from the same file.

Raises:

Type Description
ValueError

As read_trace raises.

Source code in src/mbio/verification/trace.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def read_channels(path: str | os.PathLike[str]) -> Channels:
    """Read an ``.ab1`` trace's four channels and the scan at each base's peak.

    A page takes it in the place of the result `read_trace` reads from the same file.

    Raises
    ------
    ValueError
        As `read_trace` raises.
    """
    raw = _tags(path)
    scans = {
        base: tuple(raw[tag])
        for base, tag in zip(raw["FWO_1"].decode("ascii"), _CHANNELS, strict=True)
    }
    return Channels(scans, tuple(raw["PLOC2"]))

Design barcodes

mbio barcode-design draws a set from the command line. From a script you set the rules yourself, check a set someone already holds, and ask how far apart the closest two members stand.

mbio.barcodes.BarcodeRules dataclass

What every barcode of one part list holds to, checked as it is read.

Parameters:

Name Type Description Default
length int

How many bases name one part.

required
scar str

The cloning scar joining one barcode to the next, "" where a barcode stands alone. A barcode is read with the scar on either side of it, so a forbidden site, a stop or a homopolymer run spanning that junction is found rather than missed.

''
phase int | None

The reading frame at a barcode's first base: how many bases of that codon were read before it, 0, 1 or 2. None where the construct never translates the barcode, which takes away the stop-codon rule and the whole-codon rule together.

0
forbidden Iterable[EnzymeLike]

Enzymes whose recognition sites no barcode may spell, on either strand. Each an Enzyme or a name mbio.enzymes.get_enzyme answers to.

()
distance int

The fewest mismatches, or edits, two barcodes of one part list may stand apart.

MIN_DISTANCE
max_homopolymer int | None

The longest run of one base a barcode may carry, scar included, or None for no cap.

MAX_HOMOPOLYMER
gc_band tuple[float, float] | None

The share of G and C a barcode must hold, low and high inclusive, each 0 to 1. None is no band at all, which is the default.

GC_BAND
metric Metric

How distance is counted: "hamming" in mismatches, or "sequence-levenshtein" in substitutions, insertions and deletions.

METRIC

Raises:

Type Description
ValueError

Naming what cannot be designed under: a length or a distance that is not positive, a scar that is not definite bases, a phase outside 0 to 2, a barcode and scar that are not a whole number of codons together, a GC band outside 0 to 1, or a metric that is neither.

KeyError

If a forbidden enzyme is not one the package ships.

Source code in src/mbio/barcodes.py
 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
178
179
@dataclass(frozen=True, slots=True)
class BarcodeRules:
    """What every barcode of one part list holds to, checked as it is read.

    Parameters
    ----------
    length
        How many bases name one part.
    scar
        The cloning scar joining one barcode to the next, ``""`` where a barcode stands alone.
        A barcode is read with the scar on either side of it, so a forbidden site, a stop or a
        homopolymer run spanning that junction is found rather than missed.
    phase
        The reading frame at a barcode's first base: how many bases of that codon were read
        before it, 0, 1 or 2. ``None`` where the construct never translates the barcode, which
        takes away the stop-codon rule and the whole-codon rule together.
    forbidden
        Enzymes whose recognition sites no barcode may spell, on either strand. Each an
        `Enzyme` or a name `mbio.enzymes.get_enzyme` answers to.
    distance
        The fewest mismatches, or edits, two barcodes of one part list may stand apart.
    max_homopolymer
        The longest run of one base a barcode may carry, scar included, or ``None`` for no cap.
    gc_band
        The share of G and C a barcode must hold, low and high inclusive, each 0 to 1. ``None``
        is no band at all, which is the default.
    metric
        How `distance` is counted: ``"hamming"`` in mismatches, or ``"sequence-levenshtein"`` in
        substitutions, insertions and deletions.

    Raises
    ------
    ValueError
        Naming what cannot be designed under: a length or a distance that is not positive, a
        scar that is not definite bases, a phase outside 0 to 2, a barcode and scar that are not
        a whole number of codons together, a GC band outside 0 to 1, or a metric that is
        neither.
    KeyError
        If a forbidden enzyme is not one the package ships.
    """

    length: int
    _: KW_ONLY
    scar: str = ""
    phase: int | None = 0
    forbidden: Iterable[EnzymeLike] = ()
    distance: int = MIN_DISTANCE
    max_homopolymer: int | None = MAX_HOMOPOLYMER
    gc_band: tuple[float, float] | None = GC_BAND
    metric: Metric = METRIC

    def __post_init__(self) -> None:
        """Normalise the scar, then refuse rules no set could be designed under."""
        object.__setattr__(self, "scar", self.scar.upper())
        object.__setattr__(self, "forbidden", tuple(self.forbidden))
        if self.length <= 0:
            raise ValueError(f"a barcode needs a positive length, got {self.length}")
        if self.distance < 1:
            raise ValueError(f"two barcodes stand at least one apart, got {self.distance}")
        if self.metric not in _COUNTING:
            named = " or ".join(repr(one) for one in _COUNTING)
            raise ValueError(f"a metric is {named}, got {self.metric!r}")
        if bad := sorted(set(self.scar) - set(BASES)):
            raise ValueError(
                f"a cloning scar needs definite bases, and {''.join(bad)} is not one of ACGT"
            )
        if self.max_homopolymer is not None and self.max_homopolymer < 1:
            raise ValueError(
                f"a homopolymer cap is at least one base, got {self.max_homopolymer}; "
                "pass None for no cap"
            )
        if self.gc_band is not None:
            low, high = self.gc_band
            if not 0 <= low <= high <= 1:
                raise ValueError(
                    f"a GC band runs low to high inside 0 to 1, got {low} to {high}; "
                    "pass None for no band"
                )
        self._check_frame()
        _resolve(self.forbidden)

    @property
    def enzymes(self) -> tuple[Enzyme, ...]:
        """The forbidden enzymes, each read into a record."""
        return _resolve(self.forbidden)

    def _check_frame(self) -> None:
        """Refuse a barcode the construct could not read as codons."""
        if self.phase is None:
            return
        if self.phase not in (0, 1, 2):
            raise ValueError(
                f"a phase is 0, 1 or 2 bases into a codon, got {self.phase}; pass None where "
                "the construct does not translate the barcode"
            )
        unit = self.length + len(self.scar)
        if unit % 3:
            raise ValueError(
                f"a barcode of {self.length} bases and a cloning scar of {len(self.scar)} make "
                f"{unit}, which is not a whole number of codons"
            )
        if self.phase > len(self.scar):
            raise ValueError(
                f"a phase of {self.phase} puts {self.phase} base(s) before the barcode in its "
                f"first codon, and the {len(self.scar)}-base cloning scar does not hold them"
            )

enzymes property

enzymes: tuple[Enzyme, ...]

The forbidden enzymes, each read into a record.

__post_init__

__post_init__() -> None

Normalise the scar, then refuse rules no set could be designed under.

Source code in src/mbio/barcodes.py
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
def __post_init__(self) -> None:
    """Normalise the scar, then refuse rules no set could be designed under."""
    object.__setattr__(self, "scar", self.scar.upper())
    object.__setattr__(self, "forbidden", tuple(self.forbidden))
    if self.length <= 0:
        raise ValueError(f"a barcode needs a positive length, got {self.length}")
    if self.distance < 1:
        raise ValueError(f"two barcodes stand at least one apart, got {self.distance}")
    if self.metric not in _COUNTING:
        named = " or ".join(repr(one) for one in _COUNTING)
        raise ValueError(f"a metric is {named}, got {self.metric!r}")
    if bad := sorted(set(self.scar) - set(BASES)):
        raise ValueError(
            f"a cloning scar needs definite bases, and {''.join(bad)} is not one of ACGT"
        )
    if self.max_homopolymer is not None and self.max_homopolymer < 1:
        raise ValueError(
            f"a homopolymer cap is at least one base, got {self.max_homopolymer}; "
            "pass None for no cap"
        )
    if self.gc_band is not None:
        low, high = self.gc_band
        if not 0 <= low <= high <= 1:
            raise ValueError(
                f"a GC band runs low to high inside 0 to 1, got {low} to {high}; "
                "pass None for no band"
            )
    self._check_frame()
    _resolve(self.forbidden)

mbio.barcodes.design_barcodes

design_barcodes(
    count: int, rules: BarcodeRules, *, seed: int = SEED
) -> tuple[str, ...]

Design count barcodes for one part list, holding every rule of rules.

Candidates are drawn from the whole space in a seeded random order and kept when they pass, so one seed returns one set. Walking the space in order instead opens the set on a run of one base and leaves it poor in G and C, which no filter here repairs — the reason the draw is shuffled is measured in docs/research/barcode-design.md.

Parameters:

Name Type Description Default
count int

How many barcodes the part list needs.

required
rules BarcodeRules

What each barcode, and each pair of them, must hold to.

required
seed int

The seed the draw is made with.

SEED

Returns:

Type Description
tuple of str

The barcodes, 5' to 3', in the order they were accepted.

Raises:

Type Description
SpaceExhaustedError

If no further barcode can be found, naming which rule rejected the most candidates.

ValueError

If count is negative.

KeyError

If a forbidden enzyme is not one the package ships.

Examples:

>>> design_barcodes(3, BarcodeRules(6))
('TACCAT', 'GACAGC', 'TTGATA')
>>> design_barcodes(3, BarcodeRules(6)) == design_barcodes(3, BarcodeRules(6))
True
Source code in src/mbio/barcodes.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
242
243
244
245
246
def design_barcodes(count: int, rules: BarcodeRules, *, seed: int = SEED) -> tuple[str, ...]:
    """Design `count` barcodes for one part list, holding every rule of `rules`.

    Candidates are drawn from the whole space in a seeded random order and kept when they pass,
    so one seed returns one set. Walking the space in order instead opens the set on a run of
    one base and leaves it poor in G and C, which no filter here repairs — the reason the draw
    is shuffled is measured in `docs/research/barcode-design.md`.

    Parameters
    ----------
    count
        How many barcodes the part list needs.
    rules
        What each barcode, and each pair of them, must hold to.
    seed
        The seed the draw is made with.

    Returns
    -------
    tuple of str
        The barcodes, 5' to 3', in the order they were accepted.

    Raises
    ------
    SpaceExhaustedError
        If no further barcode can be found, naming which rule rejected the most candidates.
    ValueError
        If `count` is negative.
    KeyError
        If a forbidden enzyme is not one the package ships.

    Examples
    --------
    >>> design_barcodes(3, BarcodeRules(6))
    ('TACCAT', 'GACAGC', 'TTGATA')
    >>> design_barcodes(3, BarcodeRules(6)) == design_barcodes(3, BarcodeRules(6))
    True
    """
    if count < 0:
        raise ValueError(f"a part list needs no fewer than no barcodes, got {count}")
    enzymes = rules.enzymes
    space = 4**rules.length
    generator = random.Random(seed)
    chosen: list[str] = []
    drawn: set[int] = set()
    rejected: Counter[str] = Counter()
    misses = 0
    while len(chosen) < count:
        if len(drawn) == space or misses >= _TRIES:
            raise SpaceExhaustedError(_exhausted(count, chosen, rejected, rules, drawn, space))
        index = generator.randrange(space)
        if index in drawn:
            continue
        drawn.add(index)
        barcode = _spell(index, rules.length)
        broken = _problem(barcode, rules, enzymes)
        if broken is None and (near := _nearest(barcode, chosen, rules)) is not None:
            broken = near
        if broken is None:
            chosen.append(barcode)
            misses = 0
        else:
            rejected[broken[0]] += 1
            misses += 1
    return tuple(chosen)

mbio.barcodes.check_barcodes

check_barcodes(
    barcodes: Iterable[str], rules: BarcodeRules
) -> tuple[str, ...]

Return one message per rule these barcodes break, empty where the set holds.

Each barcode is judged on its own and each pair on its distance, so a set from anywhere — a published one, or one a user wrote by hand — is read by the rules a design works to.

Examples:

>>> check_barcodes(["TACCAT", "GACAGC"], BarcodeRules(6))
()
>>> check_barcodes(["AAAAAA", "AAAAAC"], BarcodeRules(6))[0]
'AAAAAA carries a run of 6 A, over the cap of 5'
Source code in src/mbio/barcodes.py
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
def check_barcodes(barcodes: Iterable[str], rules: BarcodeRules) -> tuple[str, ...]:
    """Return one message per rule these barcodes break, empty where the set holds.

    Each barcode is judged on its own and each pair on its distance, so a set from anywhere —
    a published one, or one a user wrote by hand — is read by the rules a design works to.

    Examples
    --------
    >>> check_barcodes(["TACCAT", "GACAGC"], BarcodeRules(6))
    ()
    >>> check_barcodes(["AAAAAA", "AAAAAC"], BarcodeRules(6))[0]
    'AAAAAA carries a run of 6 A, over the cap of 5'
    """
    held = tuple(barcode.upper() for barcode in barcodes)
    enzymes = rules.enzymes
    problems = [
        f"{barcode} {broken[1]}"
        for barcode in held
        if (broken := _problem(barcode, rules, enzymes)) is not None
    ]
    counting = _COUNTING[rules.metric]
    problems.extend(
        f"{one} and {other} {_apart(apart, rules)}"
        for one, other in combinations(held, 2)
        if (counting.across_lengths or len(one) == len(other))
        and (apart := counting.measure(one, other)) < rules.distance
    )
    return tuple(problems)

mbio.barcodes.separation

separation(
    barcodes: Iterable[str], *, metric: Metric = METRIC
) -> int

Return the fewest units any two of these barcodes stand apart: how far apart the set is.

The unit is the metric's — mismatches for "hamming", edits for "sequence-levenshtein" — and the answer is the number a distance rule is read against. check_barcodes says which pair it is and which rule that breaks.

Raises:

Type Description
ValueError

If fewer than two barcodes are given, which have no pair to measure, or if metric is neither. "hamming" also refuses two barcodes of different lengths.

Examples:

>>> separation(["ACGTT", "ACGAA", "TTTTT"])
2
Source code in src/mbio/barcodes.py
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
def separation(barcodes: Iterable[str], *, metric: Metric = METRIC) -> int:
    """Return the fewest units any two of these barcodes stand apart: how far apart the set is.

    The unit is the metric's — mismatches for ``"hamming"``, edits for
    ``"sequence-levenshtein"`` — and the answer is the number a distance rule is read against.
    `check_barcodes` says which pair it is and which rule that breaks.

    Raises
    ------
    ValueError
        If fewer than two barcodes are given, which have no pair to measure, or if `metric` is
        neither. `"hamming"` also refuses two barcodes of different lengths.

    Examples
    --------
    >>> separation(["ACGTT", "ACGAA", "TTTTT"])
    2
    """
    if metric not in _COUNTING:
        named = " or ".join(repr(one) for one in _COUNTING)
        raise ValueError(f"a metric is {named}, got {metric!r}")
    held = [barcode.upper() for barcode in barcodes]
    if len(held) < 2:
        raise ValueError(f"a set stands apart from two barcodes up, and {len(held)} were given")
    measure = _COUNTING[metric].measure
    return min(measure(one, other) for one, other in combinations(held, 2))

Write a protein as DNA

Write a protein with the codons your host uses most, then take out any restriction or Type IIS site you name by synonymous codon change. codon_tables says which hosts ship.

mbio.translate.optimize_protein

optimize_protein(
    protein: str,
    *,
    host: str,
    forbidden: Iterable[EnzymeLike] = (),
    name: str = DEFAULT_NAME,
) -> CodingSequence

Write protein as a coding sequence for host with no forbidden site in it.

The codons are the ones host counts most often, and a forbidden site they spell is taken away by synonymous change, so the protein that comes back is the protein that went in.

Parameters:

Name Type Description Default
protein str

One-letter amino acids, a stop written "*".

required
host str

The name of a codon usage table.

required
forbidden Iterable[EnzymeLike]

Enzymes whose recognition sites the DNA must not spell, on either strand. Each an Enzyme or a name mbio.enzymes.get_enzyme answers to.

()
name str

What to call this sequence in the report and in a refusal.

DEFAULT_NAME

Returns:

Type Description
CodingSequence

The DNA, the protein it spells, and every codon change that took a site away.

Raises:

Type Description
KeyError

If no shipped table is called host, or no shipped enzyme answers to a name.

ValueError

If protein is not one. SiteNotRemovableError when a forbidden site is left that no synonymous change can reach.

Examples:

>>> gene = optimize_protein("MSGRW*", host="e-coli-k12", forbidden=["NotI"])
>>> gene.protein
'MSGRW*'
>>> (gene.changes[0].old_codon, gene.changes[0].new_codon)
('CGC', 'CGT')
Source code in src/mbio/translate.py
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
def optimize_protein(
    protein: str,
    *,
    host: str,
    forbidden: Iterable[EnzymeLike] = (),
    name: str = DEFAULT_NAME,
) -> CodingSequence:
    """Write `protein` as a coding sequence for `host` with no forbidden site in it.

    The codons are the ones `host` counts most often, and a forbidden site they spell is taken
    away by synonymous change, so the protein that comes back is the protein that went in.

    Parameters
    ----------
    protein
        One-letter amino acids, a stop written ``"*"``.
    host
        The name of a codon usage table.
    forbidden
        Enzymes whose recognition sites the DNA must not spell, on either strand. Each an
        `Enzyme` or a name `mbio.enzymes.get_enzyme` answers to.
    name
        What to call this sequence in the report and in a refusal.

    Returns
    -------
    CodingSequence
        The DNA, the protein it spells, and every codon change that took a site away.

    Raises
    ------
    KeyError
        If no shipped table is called `host`, or no shipped enzyme answers to a name.
    ValueError
        If `protein` is not one. `SiteNotRemovableError` when a forbidden site is left that no
        synonymous change can reach.

    Examples
    --------
    >>> gene = optimize_protein("MSGRW*", host="e-coli-k12", forbidden=["NotI"])
    >>> gene.protein
    'MSGRW*'
    >>> (gene.changes[0].old_codon, gene.changes[0].new_codon)
    ('CGC', 'CGT')
    """
    return _cleaned(
        reverse_translate(protein, host=host),
        host=host,
        forbidden=forbidden,
        name=name,
        recoded=True,
    )

mbio.translate.optimize_coding_sequence

optimize_coding_sequence(
    dna: str,
    *,
    host: str,
    forbidden: Iterable[EnzymeLike] = (),
    name: str = DEFAULT_NAME,
) -> CodingSequence

Check a coding sequence someone already holds, and take any forbidden site out of it.

A codon spelling no forbidden site is left exactly as it stands: a sequence already coded is checked, never written again. Only the codons a site sits on move, and they move to a synonym.

Parameters:

Name Type Description Default
dna str

The coding sequence, 5' to 3', definite bases and a whole number of codons.

required
host str

The name of a codon usage table, which decides which synonym a change reaches for.

required
forbidden Iterable[EnzymeLike]

Enzymes whose recognition sites the DNA must not spell, on either strand.

()
name str

What to call this sequence in the report and in a refusal.

DEFAULT_NAME

Returns:

Type Description
CodingSequence

The DNA, the protein it spells, and every codon change that took a site away.

Raises:

Type Description
KeyError

If no shipped table is called host, or no shipped enzyme answers to a name.

ValueError

If dna is not a coding sequence: an odd length, a base outside ACGT, or a stop before the end. SiteNotRemovableError when a forbidden site cannot be taken away.

Examples:

>>> gene = optimize_coding_sequence("ATGGGTCTCAAGACCTAA", host="e-coli-k12",
...                                forbidden=["BsaI", "BbsI"])
>>> gene.dna
'ATGGGCCTCAAGACCTAA'
>>> gene.recoded
False
Source code in src/mbio/translate.py
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
def optimize_coding_sequence(
    dna: str,
    *,
    host: str,
    forbidden: Iterable[EnzymeLike] = (),
    name: str = DEFAULT_NAME,
) -> CodingSequence:
    """Check a coding sequence someone already holds, and take any forbidden site out of it.

    A codon spelling no forbidden site is left exactly as it stands: a sequence already coded
    is checked, never written again. Only the codons a site sits on move, and they move to a
    synonym.

    Parameters
    ----------
    dna
        The coding sequence, 5' to 3', definite bases and a whole number of codons.
    host
        The name of a codon usage table, which decides which synonym a change reaches for.
    forbidden
        Enzymes whose recognition sites the DNA must not spell, on either strand.
    name
        What to call this sequence in the report and in a refusal.

    Returns
    -------
    CodingSequence
        The DNA, the protein it spells, and every codon change that took a site away.

    Raises
    ------
    KeyError
        If no shipped table is called `host`, or no shipped enzyme answers to a name.
    ValueError
        If `dna` is not a coding sequence: an odd length, a base outside ACGT, or a stop
        before the end. `SiteNotRemovableError` when a forbidden site cannot be taken away.

    Examples
    --------
    >>> gene = optimize_coding_sequence("ATGGGTCTCAAGACCTAA", host="e-coli-k12",
    ...                                forbidden=["BsaI", "BbsI"])
    >>> gene.dna
    'ATGGGCCTCAAGACCTAA'
    >>> gene.recoded
    False
    """
    return _cleaned(_checked_dna(dna), host=host, forbidden=forbidden, name=name, recoded=False)

mbio.translate.CodingSequence dataclass

One synthesis-ready coding sequence, and what writing it changed.

Parameters:

Name Type Description Default
name str

What this sequence is called, as the report and any refusal name it.

required
dna str

The coding sequence, 5' to 3', a whole number of codons and free of every forbidden site on both strands.

required
protein str

The amino acids dna spells, a stop written "*".

required
host str

The codon usage table it was written for.

required
forbidden tuple[str, ...]

The enzymes whose sites it is free of, by name.

()
changes tuple[Domestication, ...]

Each synonymous codon change that took a forbidden site away, in the order they were made. mbio.sites.Domestication carries the codon, the amino acid and the site.

()
recoded bool

True when the codons were written from a protein, False when a coded sequence was checked and its codons left alone.

False
Source code in src/mbio/translate.py
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
@dataclass(frozen=True, slots=True)
class CodingSequence:
    """One synthesis-ready coding sequence, and what writing it changed.

    Parameters
    ----------
    name
        What this sequence is called, as the report and any refusal name it.
    dna
        The coding sequence, 5' to 3', a whole number of codons and free of every forbidden
        site on both strands.
    protein
        The amino acids `dna` spells, a stop written ``"*"``.
    host
        The codon usage table it was written for.
    forbidden
        The enzymes whose sites it is free of, by name.
    changes
        Each synonymous codon change that took a forbidden site away, in the order they were
        made. `mbio.sites.Domestication` carries the codon, the amino acid and the site.
    recoded
        ``True`` when the codons were written from a protein, ``False`` when a coded sequence
        was checked and its codons left alone.
    """

    name: str
    dna: str
    protein: str
    _: KW_ONLY
    host: str
    forbidden: tuple[str, ...] = ()
    changes: tuple[Domestication, ...] = ()
    recoded: bool = False

mbio.codons.codon_tables

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

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

Examples:

>>> codon_tables()
('e-coli-k12', 'human', 'mouse')
Source code in src/mbio/codons.py
85
86
87
88
89
90
91
92
93
def codon_tables() -> tuple[str, ...]:
    """Return the name of every codon usage table the package ships.

    Examples
    --------
    >>> codon_tables()
    ('e-coli-k12', 'human', 'mouse')
    """
    return tuple(_shipped())

Enzymes and cut sites

get_enzyme finds one enzyme by its name, its trade name or an isoschizomer's name. free_enzymes returns the enzymes that cut none of the records you give it, which is how you pick an enzyme for a new site.

mbio.enzymes.enzymes

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

Return every shipped enzyme, ordered by name.

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

mbio.enzymes.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/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

mbio.enzymes.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
supplied_buffer str | None

The buffer that product is sold in, such as "rCutSmart Buffer". A property of the product: two products of one enzyme may differ. None where no source states one.

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/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
@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"``.
    supplied_buffer
        The buffer that product is sold in, such as ``"rCutSmart Buffer"``. A property of the
        product: two products of one enzyme may differ. ``None`` where no source states one.
    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
    supplied_buffer: 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 record.topology == "linear" and not record.fits(low, high):
            raise ValueError(f"{self.name} cut at {low}-{high} falls off a linear record")
        return record.bases(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/mbio/enzymes.py
81
82
83
84
85
86
87
88
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/mbio/enzymes.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
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/mbio/enzymes.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
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 record.topology == "linear" and not record.fits(low, high):
        raise ValueError(f"{self.name} cut at {low}-{high} falls off a linear record")
    return record.bases(low, high)

mbio.sites.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/mbio/sites.py
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
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))))

mbio.sites.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/mbio/sites.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
@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.

mbio.sites.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/mbio/sites.py
293
294
295
296
297
298
299
300
301
302
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)

Draw a map

draw_map lays a record out as a circle or a line. Drawing.write then writes that layout as an HTML page that opens offline, as a PNG or as a PDF. The layout is done once, however many formats you ask for.

mbio.plot.draw_map

draw_map(
    record: SequenceRecord | str | PathLike[str],
    *,
    region: Region | None = None,
    highlight: str | Iterable[str] = (),
    linear: bool = False,
    sequence_view: bool = False,
    features: bool = True,
    primers: bool = True,
    cut_sites: bool = True,
    enzymes: str | Iterable[str] | None = None,
    hide_types: Iterable[str] = (),
    source: bool = False,
    bases_per_row: int = 60,
    both_strands: bool = True,
    insertions: Iterable[Insertion] = (),
    tracks: Iterable[Track] = (),
) -> Drawing

Lay out a record as a map of its features, primers and cut sites: a circle or a line.

A circular record is drawn as a circle, and opened as a line when linear. A linear record, and a region of any record, is always drawn as a line, numbered as the record is. The sequence view draws the same stretch base by base, as the map numbers it.

A highlight points the map at what it names: those items keep their colours and every other one, its label with it, dims to one pale grey. Nothing moves, so each label keeps the box it was measured in; a lit label is the last to hide where labels crowd.

What is switched off is left out of a PNG or a PDF, and a page keeps it behind its switches, shown first as asked: the layers, the feature types, the sequence view of a stretch up to sequence_view.LIMIT bases, and its bottom strand. Nothing is laid out until it is needed.

Parameters:

Name Type Description Default
record SequenceRecord | str | PathLike[str]

A record, or a .dna, GenBank or FASTA file to read one from.

required
region Region | None

The stretch to draw: the name of a feature, whose first segment's start to its last's end is drawn, or a (start, end) span, 0-based and half-open, ending past the record's length across the origin. A name matches whatever its case, and the first feature in the record's order answers when several do. The whole record when None.

None
highlight str | Iterable[str]

The name, or names, of what the map points at: a feature, a primer, or an enzyme, which lights every cut site naming it. A name matches whatever its case, and every item answering to one lights. Nothing is lit, and nothing dims, when it is empty.

()
linear bool

Whether a circular record drawn whole is opened as a line.

False
sequence_view bool

Whether the sequence view is switched on, at most sequence_view.LIMIT bases.

False
features bool

Whether each is switched on.

True
primers bool

Whether each is switched on.

True
cut_sites bool

Whether each is switched on.

True
enzymes str | Iterable[str] | None

The names of the enzymes whose every cut site is drawn; the shipped unique cutters when None.

None
hide_types Iterable[str]

The feature types switched off.

()
source bool

Whether a source feature is switched on.

False
bases_per_row int

How many bases each row of the sequence view holds; a page also carries rows of half as many, rounded up, for a narrow page.

60
both_strands bool

Whether the sequence view's bottom strand, under the top one, is switched on.

True
insertions Iterable[Insertion]

Bases the record lacks, each marked at the point between two bases it lies at, labelled with how many it adds. A highlight lights one by its name.

()
tracks Iterable[Track]

Values along the record's bases, each drawn as curves in a strip under every row of the sequence view its peaks reach.

()

Raises:

Type Description
KeyError

If no shipped enzyme answers to a name in enzymes.

ValueError

If the file cannot be read as a record, the record has no bases, region names no feature or lies off the record, highlight names nothing the record draws, bases_per_row is less than 1, a sequence view would draw more than sequence_view.LIMIT bases, or an insertion adds nothing or lies off the record.

Examples:

>>> draw_map("pUC19.dna").write("pUC19.html")
PosixPath('pUC19.html')
Source code in src/mbio/plot/drawing.py
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
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
def draw_map(
    record: SequenceRecord | str | os.PathLike[str],
    *,
    region: Region | None = None,
    highlight: str | Iterable[str] = (),
    linear: bool = False,
    sequence_view: bool = False,
    features: bool = True,
    primers: bool = True,
    cut_sites: bool = True,
    enzymes: str | Iterable[str] | None = None,
    hide_types: Iterable[str] = (),
    source: bool = False,
    bases_per_row: int = 60,
    both_strands: bool = True,
    insertions: Iterable[layers.Insertion] = (),
    tracks: Iterable[view.Track] = (),
) -> Drawing:
    """Lay out a record as a map of its features, primers and cut sites: a circle or a line.

    A circular record is drawn as a circle, and opened as a line when `linear`. A linear record,
    and a region of any record, is always drawn as a line, numbered as the record is. The sequence
    view draws the same stretch base by base, as the map numbers it.

    A `highlight` points the map at what it names: those items keep their colours and every other
    one, its label with it, dims to one pale grey. Nothing moves, so each label keeps the box it
    was measured in; a lit label is the last to hide where labels crowd.

    What is switched off is left out of a PNG or a PDF, and a page keeps it behind its switches,
    shown first as asked: the layers, the feature types, the sequence view of a stretch up to
    `sequence_view.LIMIT` bases, and its bottom strand. Nothing is laid out until it is needed.

    Parameters
    ----------
    record
        A record, or a ``.dna``, GenBank or FASTA file to read one from.
    region
        The stretch to draw: the name of a feature, whose first segment's start to its last's end
        is drawn, or a ``(start, end)`` span, 0-based and half-open, ending past the record's
        length across the origin. A name matches whatever its case, and the first feature in the
        record's order answers when several do. The whole record when ``None``.
    highlight
        The name, or names, of what the map points at: a feature, a primer, or an enzyme, which
        lights every cut site naming it. A name matches whatever its case, and every item
        answering to one lights. Nothing is lit, and nothing dims, when it is empty.
    linear
        Whether a circular record drawn whole is opened as a line.
    sequence_view
        Whether the sequence view is switched on, at most `sequence_view.LIMIT` bases.
    features, primers, cut_sites
        Whether each is switched on.
    enzymes
        The names of the enzymes whose every cut site is drawn; the shipped unique cutters when
        ``None``.
    hide_types
        The feature types switched off.
    source
        Whether a `source` feature is switched on.
    bases_per_row
        How many bases each row of the sequence view holds; a page also carries rows of half as
        many, rounded up, for a narrow page.
    both_strands
        Whether the sequence view's bottom strand, under the top one, is switched on.
    insertions
        Bases the record lacks, each marked at the point between two bases it lies at, labelled
        with how many it adds. A highlight lights one by its name.
    tracks
        Values along the record's bases, each drawn as curves in a strip under every row of the
        sequence view its peaks reach.

    Raises
    ------
    KeyError
        If no shipped enzyme answers to a name in `enzymes`.
    ValueError
        If the file cannot be read as a record, the record has no bases, `region` names no
        feature or lies off the record, `highlight` names nothing the record draws,
        `bases_per_row` is less than 1, a sequence view would draw more than
        `sequence_view.LIMIT` bases, or an insertion adds nothing or lies off the record.

    Examples
    --------
    >>> draw_map("pUC19.dna").write("pUC19.html")  # doctest: +SKIP
    PosixPath('pUC19.html')
    """
    record = record if isinstance(record, SequenceRecord) else read_record(record)
    if not len(record):
        raise ValueError(f"record {record.name!r} has no bases to draw")
    if bases_per_row < 1:
        raise ValueError(f"a row of the sequence view holds at least 1 base, not {bases_per_row}")
    span = None if region is None else _span(record, region)
    start, end = span if span is not None else (0, len(record))
    if sequence_view and end - start > view.LIMIT:
        raise ValueError(
            f"a sequence view draws at most {view.LIMIT:,} bases, and {record.name!r} would "
            f"draw {end - start:,}: name a region to draw it"
        )
    named = enzymes if enzymes is None or isinstance(enzymes, str) else tuple(enzymes)
    inserted = tuple(insertions)
    # Reads the names and the insertions alone, so a wrong one raises here rather than when a
    # layout needs it.
    layers.items(
        record, features=False, primers=False, cut_sites=False, enzymes=named, insertions=inserted
    )
    kinds: dict[layers.Kind, bool] = {
        "feature": features,
        "primer": primers,
        "cut_site": cut_sites,
        "insertion": True,
    }
    switches = Switches(
        frozenset(kind for kind, on in kinds.items() if on),
        frozenset(hide_types) | (frozenset() if source else {"source"}),
    )
    drawing = Drawing(
        record,
        switches,
        named,
        span,
        linear,
        sequence_view,
        bases_per_row,
        both_strands,
        (highlight,) if isinstance(highlight, str) else tuple(highlight),
        inserted,
        tuple(tracks),
    )
    _lit(drawing)
    return drawing

mbio.plot.Drawing dataclass

A record to lay out as a map, to write once per format without laying it out again.

A PNG or a PDF draws only the items that show, and the sequence view only when it is switched on. A page carries every item the record draws, laid out together, and the sequence view of any stretch up to sequence_view.LIMIT bases with both its strands, at bases_per_row and at half of it for a narrow page; it shows first what is switched on, and switches the rest in place. Each distinct layout runs when it is first needed, and is kept.

Parameters:

Name Type Description Default
record SequenceRecord

What is drawn.

required
switches Switches

Which items show.

required
enzymes str | tuple[str, ...] | None

The names of the enzymes whose every cut site is drawn; the shipped unique cutters when None.

required
span tuple[int, int] | None

The stretch drawn, 0-based and half-open, ending past the record's length across the origin; the whole record when None.

required
linear bool

Whether a circular record drawn whole is opened as a line.

required
with_sequence_view bool

Whether the sequence view is switched on.

required
bases_per_row int

How many bases each row of the sequence view holds.

required
both_strands bool

Whether the sequence view's bottom strand, under the top one, is switched on.

required
highlight tuple[str, ...]

The names lit; every other item is dimmed, in every view and every format. Nothing is lit when it is empty.

()
insertions tuple[Insertion, ...]

Each marked where it lies, in every view.

()
tracks tuple[Track, ...]

Each drawn under the sequence view's rows.

()
Source code in src/mbio/plot/drawing.py
 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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
@dataclass(frozen=True, slots=True, eq=False)
class Drawing:
    """A record to lay out as a map, to write once per format without laying it out again.

    A PNG or a PDF draws only the items that show, and the sequence view only when it is switched
    on. A page carries every item the record draws, laid out together, and the sequence view of
    any stretch up to `sequence_view.LIMIT` bases with both its strands, at `bases_per_row` and at
    half of it for a narrow page; it shows first what is switched on, and switches the rest in
    place. Each distinct layout runs when it is first needed, and is kept.

    Parameters
    ----------
    record
        What is drawn.
    switches
        Which items show.
    enzymes
        The names of the enzymes whose every cut site is drawn; the shipped unique cutters when
        ``None``.
    span
        The stretch drawn, 0-based and half-open, ending past the record's length across the
        origin; the whole record when ``None``.
    linear
        Whether a circular record drawn whole is opened as a line.
    with_sequence_view
        Whether the sequence view is switched on.
    bases_per_row
        How many bases each row of the sequence view holds.
    both_strands
        Whether the sequence view's bottom strand, under the top one, is switched on.
    highlight
        The names lit; every other item is dimmed, in every view and every format. Nothing is lit
        when it is empty.
    insertions
        Each marked where it lies, in every view.
    tracks
        Each drawn under the sequence view's rows.
    """

    record: SequenceRecord
    switches: Switches
    enzymes: str | tuple[str, ...] | None
    span: tuple[int, int] | None
    linear: bool
    with_sequence_view: bool
    bases_per_row: int
    both_strands: bool
    highlight: tuple[str, ...] = ()
    insertions: tuple[layers.Insertion, ...] = ()
    tracks: tuple[view.Track, ...] = ()
    _kept: dict[tuple[object, ...], object] = field(default_factory=dict, init=False, repr=False)

    @property
    def layout(self) -> circular.CircularMap | line.LinearMap:
        """Where everything that shows went on the map, as a PNG or a PDF draws it."""
        shown = _shown(self)
        return (
            _line(self, shown) if self.linear or not _whole_circle(self) else _circle(self, shown)
        )

    @property
    def sequence_view(self) -> view.SequenceView | None:
        """Where everything that shows went in the sequence view, when it is switched on."""
        if not self.with_sequence_view:
            return None
        return _rows(self, _shown(self), self.bases_per_row, both_strands=self.both_strands)

    @property
    def hidden(self) -> tuple[layers.Item, ...]:
        """The item of each label the map left out for want of room, in the order they hid.

        Each gives its kind, its name and the spans where it lies. `layers.notice` words them as
        the map does. This is the map a PNG or a PDF draws; a page counts what its own layout hid.
        """
        return self.layout.hidden

    @property
    def answering(self) -> frozenset[str]:
        """Return every name the record draws something under, casefolded, for a highlight.

        A feature's or a primer's name, and the name of every enzyme that cuts. A caller lighting
        one name across several records asks each which of them it answers to.
        """
        found: set[str] = set()
        for item in _everything(self):
            if item.cutters:
                found.update(cutter.name.casefold() for cutter in item.cutters)
            else:
                found.add(item.name.casefold())
        return frozenset(found)

    def element(self, *, outlines: bool = False) -> str:
        """Return the map as one SVG element, for a page or a protocol to embed.

        It draws what a PNG draws: the items that show, with the sequence view under the map where
        it is switched on, and no switch to turn any of them off. With `outlines` it is drawn on
        white, every letter as its outline; as text it needs the faces it was measured in, which
        the page embedding it writes into its own styles.
        """
        extent, shapes = _stacked(self)
        return _outlined(shapes, extent) if outlines else svg.document(shapes, extent)

    def write(self, path: str | os.PathLike[str], *, dpi: float = 300) -> Path:
        """Write the drawing to `path`, in the format its suffix names, and return the path.

        A ``.html`` page keeps its text as text, and puts the sequence view beside the map, or
        under it on a narrow page, in rows half as long where full ones would shrink. A ``.png``
        at `dpi` and a ``.pdf`` draw every letter as its outline, so they look the same on any
        machine; `dpi` counts for the PNG alone. The PNG is one image, the sequence view under the
        map. The PDF has the map on its first page, and the sequence view's rows on the pages
        after, as many to a page as fit whole.

        A page carries every item behind its switches, a circular record drawn whole both as a
        circle and as a line, the line laid out again at two, four and eight times its length to
        zoom in steps, and the sequence view of a stretch up to `sequence_view.LIMIT` bases. A PNG
        and a PDF draw only what is switched on, the line at its one length.

        A directory `path` names and that is not there is made, as every verb taking an output
        directory makes its own.

        Raises
        ------
        ValueError
            If the suffix names no format this writes, or a PNG at `dpi` would be too large to
            draw: past `convert.PNG_SIDE` pixels a side, or `convert.PNG_PIXELS` in all.
        """
        out = Path(path)
        writer = _WRITERS.get(out.suffix.lower())
        if writer is None:
            formats = ", ".join(_WRITERS)
            raise ValueError(f"cannot write a map as {out.name!r}: the suffix must be {formats}")
        out.parent.mkdir(parents=True, exist_ok=True)
        writer(self, out, dpi)
        return out

answering property

answering: frozenset[str]

Return every name the record draws something under, casefolded, for a highlight.

A feature's or a primer's name, and the name of every enzyme that cuts. A caller lighting one name across several records asks each which of them it answers to.

hidden property

hidden: tuple[Item, ...]

The item of each label the map left out for want of room, in the order they hid.

Each gives its kind, its name and the spans where it lies. layers.notice words them as the map does. This is the map a PNG or a PDF draws; a page counts what its own layout hid.

layout property

layout: CircularMap | LinearMap

Where everything that shows went on the map, as a PNG or a PDF draws it.

sequence_view property

sequence_view: SequenceView | None

Where everything that shows went in the sequence view, when it is switched on.

element

element(*, outlines: bool = False) -> str

Return the map as one SVG element, for a page or a protocol to embed.

It draws what a PNG draws: the items that show, with the sequence view under the map where it is switched on, and no switch to turn any of them off. With outlines it is drawn on white, every letter as its outline; as text it needs the faces it was measured in, which the page embedding it writes into its own styles.

Source code in src/mbio/plot/drawing.py
151
152
153
154
155
156
157
158
159
160
def element(self, *, outlines: bool = False) -> str:
    """Return the map as one SVG element, for a page or a protocol to embed.

    It draws what a PNG draws: the items that show, with the sequence view under the map where
    it is switched on, and no switch to turn any of them off. With `outlines` it is drawn on
    white, every letter as its outline; as text it needs the faces it was measured in, which
    the page embedding it writes into its own styles.
    """
    extent, shapes = _stacked(self)
    return _outlined(shapes, extent) if outlines else svg.document(shapes, extent)

write

write(
    path: str | PathLike[str], *, dpi: float = 300
) -> Path

Write the drawing to path, in the format its suffix names, and return the path.

A .html page keeps its text as text, and puts the sequence view beside the map, or under it on a narrow page, in rows half as long where full ones would shrink. A .png at dpi and a .pdf draw every letter as its outline, so they look the same on any machine; dpi counts for the PNG alone. The PNG is one image, the sequence view under the map. The PDF has the map on its first page, and the sequence view's rows on the pages after, as many to a page as fit whole.

A page carries every item behind its switches, a circular record drawn whole both as a circle and as a line, the line laid out again at two, four and eight times its length to zoom in steps, and the sequence view of a stretch up to sequence_view.LIMIT bases. A PNG and a PDF draw only what is switched on, the line at its one length.

A directory path names and that is not there is made, as every verb taking an output directory makes its own.

Raises:

Type Description
ValueError

If the suffix names no format this writes, or a PNG at dpi would be too large to draw: past convert.PNG_SIDE pixels a side, or convert.PNG_PIXELS in all.

Source code in src/mbio/plot/drawing.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
def write(self, path: str | os.PathLike[str], *, dpi: float = 300) -> Path:
    """Write the drawing to `path`, in the format its suffix names, and return the path.

    A ``.html`` page keeps its text as text, and puts the sequence view beside the map, or
    under it on a narrow page, in rows half as long where full ones would shrink. A ``.png``
    at `dpi` and a ``.pdf`` draw every letter as its outline, so they look the same on any
    machine; `dpi` counts for the PNG alone. The PNG is one image, the sequence view under the
    map. The PDF has the map on its first page, and the sequence view's rows on the pages
    after, as many to a page as fit whole.

    A page carries every item behind its switches, a circular record drawn whole both as a
    circle and as a line, the line laid out again at two, four and eight times its length to
    zoom in steps, and the sequence view of a stretch up to `sequence_view.LIMIT` bases. A PNG
    and a PDF draw only what is switched on, the line at its one length.

    A directory `path` names and that is not there is made, as every verb taking an output
    directory makes its own.

    Raises
    ------
    ValueError
        If the suffix names no format this writes, or a PNG at `dpi` would be too large to
        draw: past `convert.PNG_SIDE` pixels a side, or `convert.PNG_PIXELS` in all.
    """
    out = Path(path)
    writer = _WRITERS.get(out.suffix.lower())
    if writer is None:
        formats = ", ".join(_WRITERS)
        raise ValueError(f"cannot write a map as {out.name!r}: the suffix must be {formats}")
    out.parent.mkdir(parents=True, exist_ok=True)
    writer(self, out, dpi)
    return out

Edit a protocol

Every pipeline writes protocol.json beside the HTML page rendered from it. Read the JSON, reword a step or drop one, write it back, and render the page again.

mbio.protocol.read_protocol

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

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

Source code in src/mbio/protocol/model.py
2101
2102
2103
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")))

mbio.protocol.Protocol dataclass

A bench protocol.

Parameters:

Name Type Description Default
title str

The page heading.

required
key str

What the rendered page remembers the bench's check marks under, minted from the content where a pipeline writes the JSON and left alone after, so an agent's edit keeps the ticks already made. Empty where nobody has minted one.

''
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.

()
background tuple[Topic, ...]

Why the work is shaped as it is, a topic at a time, read before the first step. A run of several protocols says it once, on Project.background; a protocol written as a page of its own says it here, so the rationale reaches its reader either way.

()
checks tuple[Check, ...]

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

()
choice str

The job this protocol is one way of doing, where several ways are offered. Protocols naming the same job are the ways: they stand together in Project.protocols, take one place in the run, and the bench does one of them. It is read at the bench, so it is a lowercase job that sits inside "one of two ways to read every well back". Empty where the protocol is simply a step of the chain.

''
consumes tuple[Item, ...]

What the bench is handed before this protocol, and what it leaves for the next one. A Project chains protocols by these names; a protocol rendered alone states them for its reader. Everything else a protocol needs it declares for itself, in materials, equipment and oligos.

()
produces tuple[Item, ...]

What the bench is handed before this protocol, and what it leaves for the next one. A Project chains protocols by these names; a protocol rendered alone states them for its reader. Everything else a protocol needs it declares for itself, in materials, equipment and oligos.

()
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.

()
order_sheet str

The file holding oligos as a sheet to send a supplier, as a path from the page. The page links it rather than being the thing that is ordered from.

''
files tuple[str, ...]

The files the run writes, each as a path from the page. Rendered text naming one by its file name links it, so a bare filename is never one the reader cannot open. A file the page never names costs nothing, so a run declares what it writes once rather than each page declaring what it mentions.

()
vessels tuple[Vessel, ...]

What the run holds material in, and where each thing sits.

()
plates tuple[Vessel, ...]

What the run holds material in, and where each thing sits.

()
steps tuple[Step, ...]

In the order they are shown.

()
sources Mapping[str, Source]

Every document a claim on the page rests on, keyed by what a Citation names it. A page lists only the ones its citations name.

dict()
holes tuple[Hole, ...]

Numbers missing from the run as a whole. One belonging to a step sits on that step.

()
bill Bill | None

What the run consumes, and what it costs where a price record prices it.

None
Source code in src/mbio/protocol/model.py
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
@dataclass(frozen=True, slots=True)
class Protocol:
    """A bench protocol.

    Parameters
    ----------
    title
        The page heading.
    key
        What the rendered page remembers the bench's check marks under, minted from the content
        where a pipeline writes the JSON and left alone after, so an agent's edit keeps the ticks
        already made. Empty where nobody has minted one.
    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`.
    background
        Why the work is shaped as it is, a topic at a time, read before the first step. A run of
        several protocols says it once, on `Project.background`; a protocol written as a page of
        its own says it here, so the rationale reaches its reader either way.
    checks
        Verdicts on the work, shown as a strip of badges, so a warning is seen and not read.
    choice
        The job this protocol is one way of doing, where several ways are offered. Protocols
        naming the same job are the ways: they stand together in `Project.protocols`, take one
        place in the run, and the bench does one of them. It is read at the bench, so it is a
        lowercase job that sits inside "one of two ways to read every well back". Empty where
        the protocol is simply a step of the chain.
    consumes, produces
        What the bench is handed before this protocol, and what it leaves for the next one. A
        `Project` chains protocols by these names; a protocol rendered alone states them for its
        reader. Everything else a protocol needs it declares for itself, in `materials`,
        `equipment` and `oligos`.
    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.
    order_sheet
        The file holding `oligos` as a sheet to send a supplier, as a path from the page. The
        page links it rather than being the thing that is ordered from.
    files
        The files the run writes, each as a path from the page. Rendered text naming one by its
        file name links it, so a bare filename is never one the reader cannot open. A file the
        page never names costs nothing, so a run declares what it writes once rather than each
        page declaring what it mentions.
    vessels, plates
        What the run holds material in, and where each thing sits.
    steps
        In the order they are shown.
    sources
        Every document a claim on the page rests on, keyed by what a `Citation` names it. A
        page lists only the ones its citations name.
    holes
        Numbers missing from the run as a whole. One belonging to a step sits on that step.
    bill
        What the run consumes, and what it costs where a price record prices it.
    """

    title: str
    _: KW_ONLY
    key: str = ""
    summary: str = ""
    overview: Mapping[str, str] = field(default_factory=dict, hash=False)
    highlights: tuple[str, ...] = ()
    background: tuple[Topic, ...] = ()
    checks: tuple[Check, ...] = ()
    choice: str = ""
    consumes: tuple[Item, ...] = ()
    produces: tuple[Item, ...] = ()
    materials: tuple[Material, ...] = ()
    oligos: tuple[Oligo, ...] = ()
    order_sheet: str = ""
    files: tuple[str, ...] = ()
    equipment: tuple[str, ...] = ()
    vessels: tuple[Vessel, ...] = ()
    plates: tuple[Plate, ...] = ()
    steps: tuple[Step, ...] = ()
    sources: Mapping[str, Source] = field(default_factory=dict, hash=False)
    holes: tuple[Hole, ...] = ()
    bill: Bill | None = None

    def __post_init__(self) -> None:
        """Refuse an empty title, or an overview value too long to be a card; slug the key."""
        _require(bool(self.title.strip()), "a protocol needs a title")
        object.__setattr__(self, "key", slug(self.key))
        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",
            )

    @property
    def all_holes(self) -> tuple[Hole, ...]:
        """Every hole the protocol carries, the run's own first, then each step's, then the bill's.

        One id is one hole: a number the run and a step both miss is the same number, so it is
        collected once, where it is first shown.
        """
        found: dict[str, Hole] = {}
        for hole in (
            *self.holes,
            *(hole for step in self.steps for hole in step.holes),
            *(row.hole for row in (self.bill.rows if self.bill else ()) if row.hole),
        ):
            found.setdefault(hole.id, hole)
        return tuple(found.values())

    @property
    def held_seconds(self) -> tuple[float, int]:
        """How long this protocol holds the bench, and how many of its steps hold nothing.

        One place computes it, so a protocol's own page and the schedule a project draws can
        never disagree. A step nothing times is counted, never summed as a zero.

        Examples
        --------
        >>> steps = (Step("Mix", key="mix"), Step("Rest", key="rest", timers=(Timer("rest", 60),)))
        >>> Protocol("Demo", steps=steps).held_seconds
        (60.0, 1)
        """
        held = [step.held_seconds for step in self.steps]
        return sum((one for one in held if one is not None), 0.0), held.count(None)

    def materials_for(self, step: Step) -> tuple[Material, ...]:
        """Return the materials `step` names, which are the ones whose facts reach it.

        What a material carries reaches a step through this and nowhere else.
        """
        return tuple(one for one in self.materials if names(one.name, step.named))

    def rules_for(self, step: Step) -> tuple[tuple[str, Rule], ...]:
        """Return each rule that bears on `step`, with the name of whatever carries it.

        A material, a plate, or anything the protocol is handed or leaves bears on a step that
        names it, and its rules come with it: a rule cannot be edited out of a step because it
        was never written into one. Only the carrier's name is handed back, which is all a
        reader and the audit need.
        """
        contents = self.contents_of(step)
        handled: tuple[Material | Plate | Item, ...] = (
            *self.plates,
            *self.consumes,
            *self.produces,
        )
        carriers: tuple[Material | Plate | Item, ...] = (
            *self.materials_for(step),
            *(one for one in handled if names(one.name, step.named)),
        )
        return tuple(
            (carrier.name, rule)
            for carrier in carriers
            for rule in carrier.rules
            if not rule.when or names(rule.when, contents)
        )

    def cautions_for(self, step: Step) -> tuple[Caution, ...]:
        """Return every caution `step` shows: its materials' first, then its own, each once.

        A step's own stands where no material carries one. Where one sentence is both, the
        one naming a document wins, so deduplication never costs the page a citation.
        """
        carried = (
            Caution(one) for material in self.materials_for(step) for one in material.cautions
        )
        each: dict[str, Caution] = {}
        for one in (*carried, *step.cautioned):
            if one.text not in each or (one.citation and not each[one.text].citation):
                each[one.text] = one
        return tuple(each.values())

    def contents_of(self, step: Step) -> tuple[str, ...]:
        """Return what is in the step's tubes: what it pipettes, and what each of those brings."""
        by_name = {material.name: material for material in self.materials}
        brought = [
            one
            for pipetted in step.pipetted
            for material in (by_name.get(pipetted),)
            if material
            for one in material.contains
        ]
        return (*step.pipetted, *brought)

    def audit(self) -> tuple[Check, ...]:
        """Judge the protocol itself: its citations, its wells, its rules and its holes.

        The protocol is its own gate. A check it carries is paid by the protocol; a gate added
        to the repo's own is paid by everyone who works here afterwards.

        Examples
        --------
        >>> [check.name for check in Protocol("Demo").audit()]
        ['sources', 'wells', 'rules', 'holes']
        """
        return (self._sources(), self._wells(), self._rules(), self._holes())

    @property
    def citations(self) -> tuple[Citation, ...]:
        """Every citation this protocol's own rows and its bill carry, kind by kind.

        Examples
        --------
        >>> Protocol("Demo").citations
        ()
        """
        return tuple(
            citation
            for citation in (
                *(m.citation for m in self.materials),
                *(r.citation for m in self.materials for r in m.rules),
                *(c.citation for s in self.steps for t in s.tables for c in t.components),
                *(g.citation for s in self.steps for p in s.programs for g in p.stages),
                *(
                    i.citation
                    for s in self.steps
                    for p in s.programs
                    for g in p.stages
                    for i in g.incubations
                ),
                *(t.citation for s in self.steps for t in s.transfers),
                *(f.citation for s in self.steps for f in s.figures),
                *(t.citation for s in self.steps for t in s.troubleshooting),
                *(c.citation for s in self.steps for c in s.cautioned),
                *(n.citation for s in self.steps for n in s.noted),
                *(o.citation for s in self.steps for o in s.expectations),
                *(w.citation for s in self.steps for w in s.waits),
            )
            if citation
        ) + (self.bill.citations if self.bill else ())

    @property
    def cited(self) -> frozenset[str]:
        """Every source key this protocol's own citations name.

        Examples
        --------
        >>> Protocol("Demo").cited
        frozenset()
        """
        return frozenset(citation.source for citation in self.citations)

    def _sources(self) -> Check:
        return _sources_check(self.cited, frozenset(self.sources))

    def _wells(self) -> Check:
        known = {
            *(m.name for m in self.materials),
            *(o.name for o in self.oligos),
            *(v.name for v in self.vessels),
            *(p.name for p in self.plates),
        }
        dangling = sorted(
            f"{plate.name} {well} holds {held!r}"
            for plate in self.plates
            for well, held in plate.seating.items()
            if held not in known
        )
        seated = sum(len(plate.seating) for plate in self.plates)
        if dangling:
            return Check("wells", "fail", "; ".join(dangling))
        return Check("wells", "pass", f"{seated} seated wells resolve")

    def _rules(self) -> Check:
        broken = [
            f"{step.title}: {carrier} {rule.kind} {rule.subject}"
            for step in self.steps
            for carrier, rule in self.rules_for(step)
            if not rule.holds(step.named, self.contents_of(step))
        ]
        kept = sum(len(self.rules_for(step)) for step in self.steps)
        if broken:
            return Check("rules", "fail", "; ".join(broken))
        return Check("rules", "pass", f"{kept} rules hold where their carrier is used")

    def _holes(self) -> Check:
        holes = self.all_holes
        if not holes:
            return Check("holes", "pass", "no number is missing")
        unread = [hole.id for hole in holes if hole.kind == "unread"]
        if unread:
            return Check("holes", "fail", f"a source nobody read would fill: {', '.join(unread)}")
        ids = ", ".join(hole.id for hole in holes)
        count = f"{len(holes)} numbers have" if len(holes) > 1 else "1 number has"
        return Check("holes", None, f"{count} no source: {ids}")

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

        Each value takes its field's JSON type: a whole number for an ``int``, any number but
        not true or false for a ``float``, and null only where the field is optional.

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

all_holes property

all_holes: tuple[Hole, ...]

Every hole the protocol carries, the run's own first, then each step's, then the bill's.

One id is one hole: a number the run and a step both miss is the same number, so it is collected once, where it is first shown.

citations property

citations: tuple[Citation, ...]

Every citation this protocol's own rows and its bill carry, kind by kind.

Examples:

>>> Protocol("Demo").citations
()

cited property

cited: frozenset[str]

Every source key this protocol's own citations name.

Examples:

>>> Protocol("Demo").cited
frozenset()

held_seconds property

held_seconds: tuple[float, int]

How long this protocol holds the bench, and how many of its steps hold nothing.

One place computes it, so a protocol's own page and the schedule a project draws can never disagree. A step nothing times is counted, never summed as a zero.

Examples:

>>> steps = (Step("Mix", key="mix"), Step("Rest", key="rest", timers=(Timer("rest", 60),)))
>>> Protocol("Demo", steps=steps).held_seconds
(60.0, 1)

__post_init__

__post_init__() -> None

Refuse an empty title, or an overview value too long to be a card; slug the key.

Source code in src/mbio/protocol/model.py
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
def __post_init__(self) -> None:
    """Refuse an empty title, or an overview value too long to be a card; slug the key."""
    _require(bool(self.title.strip()), "a protocol needs a title")
    object.__setattr__(self, "key", slug(self.key))
    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",
        )

audit

audit() -> tuple[Check, ...]

Judge the protocol itself: its citations, its wells, its rules and its holes.

The protocol is its own gate. A check it carries is paid by the protocol; a gate added to the repo's own is paid by everyone who works here afterwards.

Examples:

>>> [check.name for check in Protocol("Demo").audit()]
['sources', 'wells', 'rules', 'holes']
Source code in src/mbio/protocol/model.py
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
def audit(self) -> tuple[Check, ...]:
    """Judge the protocol itself: its citations, its wells, its rules and its holes.

    The protocol is its own gate. A check it carries is paid by the protocol; a gate added
    to the repo's own is paid by everyone who works here afterwards.

    Examples
    --------
    >>> [check.name for check in Protocol("Demo").audit()]
    ['sources', 'wells', 'rules', 'holes']
    """
    return (self._sources(), self._wells(), self._rules(), self._holes())

cautions_for

cautions_for(step: Step) -> tuple[Caution, ...]

Return every caution step shows: its materials' first, then its own, each once.

A step's own stands where no material carries one. Where one sentence is both, the one naming a document wins, so deduplication never costs the page a citation.

Source code in src/mbio/protocol/model.py
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
def cautions_for(self, step: Step) -> tuple[Caution, ...]:
    """Return every caution `step` shows: its materials' first, then its own, each once.

    A step's own stands where no material carries one. Where one sentence is both, the
    one naming a document wins, so deduplication never costs the page a citation.
    """
    carried = (
        Caution(one) for material in self.materials_for(step) for one in material.cautions
    )
    each: dict[str, Caution] = {}
    for one in (*carried, *step.cautioned):
        if one.text not in each or (one.citation and not each[one.text].citation):
            each[one.text] = one
    return tuple(each.values())

contents_of

contents_of(step: Step) -> tuple[str, ...]

Return what is in the step's tubes: what it pipettes, and what each of those brings.

Source code in src/mbio/protocol/model.py
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
def contents_of(self, step: Step) -> tuple[str, ...]:
    """Return what is in the step's tubes: what it pipettes, and what each of those brings."""
    by_name = {material.name: material for material in self.materials}
    brought = [
        one
        for pipetted in step.pipetted
        for material in (by_name.get(pipetted),)
        if material
        for one in material.contains
    ]
    return (*step.pipetted, *brought)

from_dict classmethod

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

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

Each value takes its field's JSON type: a whole number for an int, any number but not true or false for a float, and null only where the field is optional.

Raises:

Type Description
ValueError

On an unknown or missing key or a value of another JSON type, naming where it is, or on a value the model refuses.

Source code in src/mbio/protocol/model.py
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "Protocol":
    """Build a protocol from parsed JSON; keys are the field names, lists become tuples.

    Each value takes its field's JSON type: a whole number for an ``int``, any number but
    not true or false for a ``float``, and null only where the field is optional.

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

materials_for

materials_for(step: Step) -> tuple[Material, ...]

Return the materials step names, which are the ones whose facts reach it.

What a material carries reaches a step through this and nowhere else.

Source code in src/mbio/protocol/model.py
1705
1706
1707
1708
1709
1710
def materials_for(self, step: Step) -> tuple[Material, ...]:
    """Return the materials `step` names, which are the ones whose facts reach it.

    What a material carries reaches a step through this and nowhere else.
    """
    return tuple(one for one in self.materials if names(one.name, step.named))

rules_for

rules_for(step: Step) -> tuple[tuple[str, Rule], ...]

Return each rule that bears on step, with the name of whatever carries it.

A material, a plate, or anything the protocol is handed or leaves bears on a step that names it, and its rules come with it: a rule cannot be edited out of a step because it was never written into one. Only the carrier's name is handed back, which is all a reader and the audit need.

Source code in src/mbio/protocol/model.py
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
def rules_for(self, step: Step) -> tuple[tuple[str, Rule], ...]:
    """Return each rule that bears on `step`, with the name of whatever carries it.

    A material, a plate, or anything the protocol is handed or leaves bears on a step that
    names it, and its rules come with it: a rule cannot be edited out of a step because it
    was never written into one. Only the carrier's name is handed back, which is all a
    reader and the audit need.
    """
    contents = self.contents_of(step)
    handled: tuple[Material | Plate | Item, ...] = (
        *self.plates,
        *self.consumes,
        *self.produces,
    )
    carriers: tuple[Material | Plate | Item, ...] = (
        *self.materials_for(step),
        *(one for one in handled if names(one.name, step.named)),
    )
    return tuple(
        (carrier.name, rule)
        for carrier in carriers
        for rule in carrier.rules
        if not rule.when or names(rule.when, contents)
    )

mbio.protocol.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
key str

What this step is, whatever its title is reworded to: the handle its anchor and the bench's check mark are kept under. The builder assigns it, for the step's job and not its wording, and it is slugged as a title is. Empty, it falls back to the title's slug.

''
section str

What stage of the protocol the step belongs to, such as "Day 1". A label and not a container: the steps stay one list and the numbering runs through it.

''
instructions tuple[str, ...]

Ordered actions, one sentence each.

()
cautions tuple[Caution | str, ...]

Shown before the instructions. A bare string is a caution citing nothing.

()
notes tuple[Note | str, ...]

Shown after them. A bare string is a note citing nothing.

()
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.

()
waits tuple[Wait, ...]

Time the step spends waiting on someone else, which nobody attends.

()
hands_on_seconds int | None

How much of the step someone stands over, which waits, timers and programs say nothing about. None is an admitted unknown and never a zero, and the unattended share is the time held less this wherever both are known. Nature Protocols publishes both numbers, so a protocol is expected to know them: docs/research/gantt-conventions.md section 5.

None
transfers tuple[Transfer, ...]

What the step moves between wells. A step references a well by holding the transfer, never by describing where a thing is.

()
figures tuple[Figure, ...]

The records the step draws, shown under its instructions.

()
gels tuple[Gel, ...]

What a successful step looks like. A bare expected line is one citing nothing, and the tuple is left as it was written, since most of the package reads an expected line as text; expectations gives each of them as an Expectation.

()
expected tuple[Gel, ...]

What a successful step looks like. A bare expected line is one citing nothing, and the tuple is left as it was written, since most of the package reads an expected line as text; expectations gives each of them as an Expectation.

()
calculator CountToNet | None

Where the reader has a count of their own to work out and read against what expected states, shown under it.

None
troubleshooting tuple[Troubleshooting, ...]

Problems the reader may see here.

()
holes tuple[Hole, ...]

The numbers this step would otherwise have to invent.

()
Source code in src/mbio/protocol/model.py
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
@dataclass(frozen=True, slots=True)
class Step:
    """One numbered step of a protocol.

    Parameters
    ----------
    title
        What the step achieves, such as ``"Run the thermocycler"``.
    key
        What this step is, whatever its title is reworded to: the handle its anchor and the
        bench's check mark are kept under. The builder assigns it, for the step's job and not
        its wording, and it is slugged as a title is. Empty, it falls back to the title's slug.
    section
        What stage of the protocol the step belongs to, such as ``"Day 1"``. A label and not a
        container: the steps stay one list and the numbering runs through it.
    instructions
        Ordered actions, one sentence each.
    cautions
        Shown before the instructions. A bare string is a caution citing nothing.
    notes
        Shown after them. A bare string is a note citing nothing.
    tables, programs, timers
        Reaction tables, thermocycler programs and countdowns the step uses.
    waits
        Time the step spends waiting on someone else, which nobody attends.
    hands_on_seconds
        How much of the step someone stands over, which `waits`, `timers` and `programs` say
        nothing about. ``None`` is an admitted unknown and never a zero, and the unattended
        share is the time held less this wherever both are known. Nature Protocols publishes
        both numbers, so a protocol is expected to know them:
        ``docs/research/gantt-conventions.md`` section 5.
    transfers
        What the step moves between wells. A step references a well by holding the transfer,
        never by describing where a thing is.
    figures
        The records the step draws, shown under its instructions.
    gels, expected
        What a successful step looks like. A bare expected line is one citing nothing, and the
        tuple is left as it was written, since most of the package reads an expected line as
        text; `expectations` gives each of them as an `Expectation`.
    calculator
        Where the reader has a count of their own to work out and read against what `expected`
        states, shown under it.
    troubleshooting
        Problems the reader may see here.
    holes
        The numbers this step would otherwise have to invent.
    """

    title: str
    _: KW_ONLY
    key: str = ""
    section: str = ""
    instructions: tuple[str, ...] = ()
    cautions: tuple[Caution | str, ...] = ()
    notes: tuple[Note | str, ...] = ()
    tables: tuple[ReactionTable, ...] = ()
    programs: tuple[ThermocyclerProgram, ...] = ()
    timers: tuple[Timer, ...] = ()
    waits: tuple[Wait, ...] = ()
    hands_on_seconds: int | None = None
    transfers: tuple[Transfer, ...] = ()
    figures: tuple[Figure, ...] = ()
    gels: tuple[Gel, ...] = ()
    expected: tuple[Expectation | str, ...] = ()
    calculator: CountToNet | None = None
    troubleshooting: tuple[Troubleshooting, ...] = ()
    holes: tuple[Hole, ...] = ()

    def __post_init__(self) -> None:
        """Refuse an empty title or a negative hands-on time; slug the key; make each text one."""
        _require(bool(self.title.strip()), "a step needs a title")
        _require(
            self.hands_on_seconds is None or self.hands_on_seconds >= 0,
            f"step {self.title!r}: hands_on_seconds is zero or more, or null where nobody "
            "stated it",
        )
        object.__setattr__(self, "key", slug(self.key or self.title))
        object.__setattr__(
            self, "notes", tuple(Note(n) if isinstance(n, str) else n for n in self.notes)
        )
        object.__setattr__(
            self,
            "cautions",
            tuple(Caution(c) if isinstance(c, str) else c for c in self.cautions),
        )

    @property
    def cautioned(self) -> tuple[Caution, ...]:
        """Every caution, each a `Caution`: `__post_init__` makes one of any bare string.

        Examples
        --------
        >>> Step("Thaw the mix", cautions=("Keep it on ice.",)).cautioned
        (Caution(text='Keep it on ice.', citation=None),)
        """
        return cast("tuple[Caution, ...]", self.cautions)

    @property
    def expectations(self) -> tuple[Expectation, ...]:
        """Every expected line, each an `Expectation`, a bare string being one citing nothing.

        Examples
        --------
        >>> Step("Run the gel", expected=("One band at 749 bp.",)).expectations
        (Expectation(text='One band at 749 bp.', citation=None),)
        """
        return tuple(Expectation(one) if isinstance(one, str) else one for one in self.expected)

    @property
    def noted(self) -> tuple[Note, ...]:
        """Every note, each a `Note`: `__post_init__` makes one of any bare string.

        Examples
        --------
        >>> Step("Rest the tube", notes=("It settles.",)).noted
        (Note(text='It settles.', citation=None),)
        """
        return cast("tuple[Note, ...]", self.notes)

    @property
    def named(self) -> tuple[str, ...]:
        """Everything the step names: its title, its actions, what it pipettes and what it runs.

        A `Rule`'s subject is matched against these.
        """
        return (
            self.title,
            *self.instructions,
            *(c.name for table in self.tables for c in table.components),
            *(p.title for p in self.programs),
            *(i.label for p in self.programs for s in p.stages for i in s.incubations),
            *(t.title for t in self.transfers),
            *(t.label for t in self.timers),
        )

    @property
    def held_seconds(self) -> float | None:
        """How long this step holds the bench, where anything in it bounds the time.

        Summed over its timers and its thermocycler programs. ``None`` where nothing bounds
        one — a step with neither, or one whose only program runs to an open end — because a
        step holding nothing is a question and never a zero.

        Examples
        --------
        >>> Step("Rest the tube", key="rest", timers=(Timer("rest", 600),)).held_seconds
        600.0
        >>> Step("Mix the reaction", key="mix").held_seconds is None
        True
        """
        bounded = [float(timer.seconds) for timer in self.timers]
        bounded += [p.timer.seconds for p in self.programs if p.timer is not None]
        return sum(bounded) if bounded else None

    @property
    def pipetted(self) -> tuple[str, ...]:
        """What the step puts in a tube: every reaction table's component names."""
        return tuple(c.name for table in self.tables for c in table.components)

cautioned property

cautioned: tuple[Caution, ...]

Every caution, each a Caution: __post_init__ makes one of any bare string.

Examples:

>>> Step("Thaw the mix", cautions=("Keep it on ice.",)).cautioned
(Caution(text='Keep it on ice.', citation=None),)

expectations property

expectations: tuple[Expectation, ...]

Every expected line, each an Expectation, a bare string being one citing nothing.

Examples:

>>> Step("Run the gel", expected=("One band at 749 bp.",)).expectations
(Expectation(text='One band at 749 bp.', citation=None),)

held_seconds property

held_seconds: float | None

How long this step holds the bench, where anything in it bounds the time.

Summed over its timers and its thermocycler programs. None where nothing bounds one — a step with neither, or one whose only program runs to an open end — because a step holding nothing is a question and never a zero.

Examples:

>>> Step("Rest the tube", key="rest", timers=(Timer("rest", 600),)).held_seconds
600.0
>>> Step("Mix the reaction", key="mix").held_seconds is None
True

named property

named: tuple[str, ...]

Everything the step names: its title, its actions, what it pipettes and what it runs.

A Rule's subject is matched against these.

noted property

noted: tuple[Note, ...]

Every note, each a Note: __post_init__ makes one of any bare string.

Examples:

>>> Step("Rest the tube", notes=("It settles.",)).noted
(Note(text='It settles.', citation=None),)

pipetted property

pipetted: tuple[str, ...]

What the step puts in a tube: every reaction table's component names.

__post_init__

__post_init__() -> None

Refuse an empty title or a negative hands-on time; slug the key; make each text one.

Source code in src/mbio/protocol/model.py
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
def __post_init__(self) -> None:
    """Refuse an empty title or a negative hands-on time; slug the key; make each text one."""
    _require(bool(self.title.strip()), "a step needs a title")
    _require(
        self.hands_on_seconds is None or self.hands_on_seconds >= 0,
        f"step {self.title!r}: hands_on_seconds is zero or more, or null where nobody "
        "stated it",
    )
    object.__setattr__(self, "key", slug(self.key or self.title))
    object.__setattr__(
        self, "notes", tuple(Note(n) if isinstance(n, str) else n for n in self.notes)
    )
    object.__setattr__(
        self,
        "cautions",
        tuple(Caution(c) if isinstance(c, str) else c for c in self.cautions),
    )

mbio.protocol.write_protocol

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

Write protocol to path as JSON that read_protocol reads back equal; return the path.

Every field is written, empty and default ones too, in the order its class declares them, indented two spaces and as UTF-8 ending in a newline, so one protocol always writes the same bytes.

Source code in src/mbio/protocol/model.py
2106
2107
2108
2109
2110
2111
2112
2113
def write_protocol(protocol: Protocol, path: str | os.PathLike[str]) -> Path:
    """Write `protocol` to `path` as JSON that `read_protocol` reads back equal; return the path.

    Every field is written, empty and default ones too, in the order its class declares them,
    indented two spaces and as UTF-8 ending in a newline, so one protocol always writes the same
    bytes.
    """
    return _write(protocol, path)

mbio.protocol.write_html

write_html(
    protocol: Protocol,
    path: str | PathLike[str],
    *,
    folder: Folder | None = None,
    base: str | PathLike[str] | None = None,
) -> Path

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

Inside a folder, the file's own name is the address the other pages link it by. A figure's record is read from base, and from beside the page where nobody says: a pipeline writes the records, the data and the page into one directory. The map each figure's record opens to is written beside the page first, so the page has it to open.

Source code in src/mbio/protocol/render.py
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
def write_html(
    protocol: Protocol,
    path: str | os.PathLike[str],
    *,
    folder: Folder | None = None,
    base: str | os.PathLike[str] | None = None,
) -> Path:
    """Write `render_html(protocol)` to `path` as UTF-8 and return the path.

    Inside a `folder`, the file's own name is the address the other pages link it by. A figure's
    record is read from `base`, and from beside the page where nobody says: a pipeline writes the
    records, the data and the page into one directory. The map each figure's record opens to is
    written beside the page first, so the page has it to open.
    """
    page, _ = _write_page(protocol, Path(path), folder=folder, base=base)
    return page

Build a library

iGGA is the lab's iterative Golden Gate method. It builds a barcoded library of every combination of the part lists it is given, one round at a time. plan_igga is the way in, and it reads what one build chooses from a Build. check_library judges a design that nothing here planned, by the same rules.

synbio.igga.plan_igga

plan_igga(
    build: Build | str | PathLike[str],
    *,
    parts: Sequence[Mapping[str, str]] | None = None,
    kind: Kind = "protein",
    site: Site | None = None,
    working_site: Site | None = None,
    pattern: str = NAME_PATTERN,
    rules: BarcodeRules | None = None,
    min_distance: int = MIN_DISTANCE,
    allow_uniform: bool = False,
    prices: PriceRecord | str | PathLike[str] | None = None,
    profile: LigaseProfile
    | str
    | PathLike[str]
    | None = None,
    profile_sheet: str | int | None = None,
) -> LibraryPlan

Plan the whole library build asks for, by the method build is built under.

One round appends one part list to every member of the library at once, so the rounds run in the build's own order and the product of each opens the next. The same inputs return the same design: the barcodes are drawn from the build's seed, and nothing else here is random.

Where the build names a working vector, the enzyme that admits cargo to it is chosen from that vector alone before any block is designed, and reserved so no block spells it. The ordering is the pipeline's, not the user's.

Parameters:

Name Type Description Default
build Build | str | PathLike[str]

What this build chooses, or a path to the JSON holding it; synbio.igga.project.read_build reads one. It names the parts FASTA and the vector by path.

required
parts Sequence[Mapping[str, str]] | None

One already-sorted mapping per position, in the build's order. The build's own parts FASTA is read where this is not given.

None
kind Kind

What the part lists hold. "dna" is checked and kept rather than written again; only a codon a junction or a forbidden site moves is this package's.

'protein'
site Site | None

Where to put an internal stuffer, as a feature name or a (start, end) span. Read only where the vector carries none.

None
working_site Site | None

The same, for the ccdB cassette of the working vector the build names. Read only where that vector carries none.

None
pattern str

How a record's name says which part list it belongs to; see NAME_PATTERN.

NAME_PATTERN
rules BarcodeRules | None

What every barcode holds to. Built from the build's own barcode length and distance by default; see synbio.igga.parts.barcode_rules.

None
min_distance int

How far apart the standard's overhangs must stand, and whether one base kind is allowed.

MIN_DISTANCE
allow_uniform int

How far apart the standard's overhangs must stand, and whether one base kind is allowed.

MIN_DISTANCE
prices PriceRecord | str | PathLike[str] | None

A price record the user holds, or a path to one; mbio.bench.prices.read_prices reads one. The protocol's bill computes its quantities either way, and prices nothing without this.

None
profile LigaseProfile | str | PathLike[str] | None

A ligase's own matrix the user holds, or a path to one, and which sheet of it to read; mbio.ligase.read_profile reads one. Every round then carries how often that ligase joins its overhangs. Nothing is designed or ranked on it, so a build plans the same design with it and without it.

None
profile_sheet LigaseProfile | str | PathLike[str] | None

A ligase's own matrix the user holds, or a path to one, and which sheet of it to read; mbio.ligase.read_profile reads one. Every round then carries how often that ligase joins its overhangs. Nothing is designed or ranked on it, so a build plans the same design with it and without it.

None

Returns:

Type Description
LibraryPlan

The design, the simulated rounds, what the bench has to do, and the gate's verdict on all of it. synbio.igga.gate.check_library judges the finished records, so this plan and a design an agent composed are judged by the same code.

Raises:

Type Description
ValueError

If a record's name says no position of the build or says more than one, if two records share a name, if a part is not a protein or not a coding sequence, if no overhang standard fits the part lists, if a block spells a site the method does not expect or gives up no cargo, if the vector cannot be made a destination, if a round cannot ligate, if prices names a file that is not a price record, or if profile names one that is not a ligation count matrix.

KeyError

If the build names a codon usage table or an enzyme this package does not ship.

SpaceExhaustedError

If a part list is larger than the barcodes its rules allow.

Examples:

>>> plan = plan_igga("project.json")
>>> plan.write("library/")
Source code in src/synbio/igga/plan.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
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
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
def plan_igga(
    build: Build | str | os.PathLike[str],
    *,
    parts: Sequence[Mapping[str, str]] | None = None,
    kind: Kind = "protein",
    site: Site | None = None,
    working_site: Site | None = None,
    pattern: str = NAME_PATTERN,
    rules: BarcodeRules | None = None,
    min_distance: int = MIN_DISTANCE,
    allow_uniform: bool = False,
    prices: PriceRecord | str | os.PathLike[str] | None = None,
    profile: LigaseProfile | str | os.PathLike[str] | None = None,
    profile_sheet: str | int | None = None,
) -> LibraryPlan:
    """Plan the whole library `build` asks for, by the method `build` is built under.

    One round appends one part list to every member of the library at once, so the rounds run in
    the build's own order and the product of each opens the next. The same inputs return the
    same design: the barcodes are drawn from the build's seed, and nothing else here is random.

    Where the build names a working vector, the enzyme that admits cargo to it is chosen from
    that vector alone before any block is designed, and reserved so no block spells it. The
    ordering is the pipeline's, not the user's.

    Parameters
    ----------
    build
        What this build chooses, or a path to the JSON holding it;
        `synbio.igga.project.read_build` reads one. It names the parts FASTA and the
        vector by path.
    parts
        One already-sorted mapping per position, in the build's order. The build's own parts
        FASTA is read where this is not given.
    kind
        What the part lists hold. ``"dna"`` is checked and kept rather than written again; only a
        codon a junction or a forbidden site moves is this package's.
    site
        Where to put an internal stuffer, as a feature name or a ``(start, end)`` span. Read only
        where the vector carries none.
    working_site
        The same, for the ccdB cassette of the working vector the build names. Read only where
        that vector carries none.
    pattern
        How a record's name says which part list it belongs to; see `NAME_PATTERN`.
    rules
        What every barcode holds to. Built from the build's own barcode length and distance by
        default; see `synbio.igga.parts.barcode_rules`.
    min_distance, allow_uniform
        How far apart the standard's overhangs must stand, and whether one base kind is allowed.
    prices
        A price record the user holds, or a path to one;
        `mbio.bench.prices.read_prices` reads one. The protocol's bill computes its
        quantities either way, and prices nothing without this.
    profile, profile_sheet
        A ligase's own matrix the user holds, or a path to one, and which sheet of it to read;
        `mbio.ligase.read_profile` reads one. Every round then carries how often that
        ligase joins its overhangs. Nothing is designed or ranked on it, so a build plans the
        same design with it and without it.

    Returns
    -------
    LibraryPlan
        The design, the simulated rounds, what the bench has to do, and the gate's verdict on all
        of it. `synbio.igga.gate.check_library` judges the finished records, so this
        plan and a design an agent composed are judged by the same code.

    Raises
    ------
    ValueError
        If a record's name says no position of the build or says more than one, if two records
        share a name, if a part is not a protein or not a coding sequence, if no overhang
        standard fits the part lists, if a block spells a site the method does not expect or
        gives up no cargo, if the vector cannot be made a destination, if a round cannot ligate,
        if `prices` names a file that is not a price record, or if `profile` names one that is
        not a ligation count matrix.
    KeyError
        If the build names a codon usage table or an enzyme this package does not ship.
    mbio.barcodes.SpaceExhaustedError
        If a part list is larger than the barcodes its rules allow.

    Examples
    --------
    >>> plan = plan_igga("project.json")  # doctest: +SKIP
    >>> plan.write("library/")  # doctest: +SKIP
    """
    chosen = build if isinstance(build, Build) else read_build(build)
    # Read before anything is designed, so a file that is not a matrix is refused at once.
    ligase = _profile(profile, profile_sheet)
    design = chosen.scheme
    positions = chosen.positions
    one = as_record(chosen.vector)
    into = None if chosen.working_vector is None else as_record(chosen.working_vector)
    # Every block has to be free of the cargo enzyme, so it is read off the working vector alone.
    admits = None if into is None else cargo_enzyme([into], scheme=design).enzyme
    reserved = (
        chosen.reserved_extra
        if admits is None
        else tuple(dict.fromkeys((*chosen.reserved_extra, admits.name)))
    )
    given = (
        parts if parts is not None else read_part_lists(chosen.parts, positions, pattern=pattern)
    )
    lists, coded = _sequences(_checked(given, positions), kind)
    compatible = _compatible(one, design)
    pinned = {positions[0]: design.entry_overhang} if compatible else {}
    standard = design_standard(
        design,
        positions,
        lists,
        pinned=pinned,
        usage=codon_usage(chosen.host),
        min_distance=min_distance,
        allow_uniform=allow_uniform,
    )
    held = (
        rules
        if rules is not None
        else barcode_rules(
            design,
            chosen.barcode.length,
            distance=chosen.barcode.min_distance,
            reserved=reserved,
        )
    )
    built = design_parts(
        design,
        positions,
        lists,
        standard,
        host=chosen.host,
        rules=held,
        coding=coded,
        seed=chosen.seed,
        reserved=reserved,
    )
    seated = _seat(chosen, design, built, standard)
    destination = destination_vector(
        one,
        design if compatible else _restandardised(design, standard.entry_overhangs[0]),
        site=site,
    )
    pool = _pool(chosen, built)
    blocks = () if pool is None else _block_vectors(destination, design, standard, positions)
    named = chosen.name or one.name
    rounds = assemble_rounds(
        destination.record, representative(built, positions), design, positions, name=named
    )
    rows = plan_coverage([len(each) for each in lists], completeness=chosen.completeness)
    working = (
        None
        if into is None
        else working_vector(
            into, [rounds[-1].product], scheme=design, site=working_site, enzyme=admits
        )
    )
    judged = check_library(
        chosen,
        destination=destination.record,
        blocks=_blocks(built, positions),
        products=[one.product for one in rounds],
        barcodes=_barcodes(built, positions),
        working=None if working is None else working.record,
        cargo=None if working is None else working.enzyme,
        profile=ligase,
    )
    return LibraryPlan(
        chosen,
        design,
        one,
        destination,
        blocks,
        tuple(lists),
        tuple(coded) if coded is not None else tuple({} for _ in lists),
        standard,
        built,
        rounds,
        rows,
        _bench(rounds, built, rows),
        judged,
        chosen.host,
        named,
        prices if prices is None or isinstance(prices, PriceRecord) else read_prices(prices),
        pool,
        working,
        seated,
    )

synbio.igga.LibraryPlan dataclass

One planned library: what it costs, what it makes, and what the bench has to do.

Parameters:

Name Type Description Default
build Build

What this build chose: its positions, its inputs and its dials.

required
scheme Scheme

The method the build was planned by.

required
vector SequenceRecord

The record the plan was made from, before any retrofit.

required
destination Destination

The vector a round can open, and what making it one changed.

required
block_vectors tuple[Destination, ...]

The block vector a position's cargo closes into, one a position in the build's order. A part enters on its own position's entry overhang, so each offers that overhang and nothing else about them differs. Empty where the build names no primer set and the blocks are ordered whole.

required
part_lists tuple[PartList, ...]

One per position, in the build's order: each member's name and the protein it codes for, whether it was given as protein or read off DNA.

required
coding tuple[Mapping[str, str], ...]

One per position: the coding sequence a member came already coded in, empty for a member written from the host's codon usage.

required
standard Standard

The overhang standard the build works to, and what it charges each part.

required
parts tuple[Part, ...]

Every part's synthesis sequence, in position order and then list order.

required
rounds tuple[Round, ...]

One a position, in the order they run. The last round's product is the representative construct and the ones before it are the intermediates.

required
coverage tuple[RoundCoverage, ...]

What each round has to cover, and what its colony floor leaves out.

required
bench tuple[RoundBench, ...]

What each round takes at the bench, computed from that round's own lengths.

required
verdict Verdict

What synbio.igga.gate made of the finished design: every tube, every cargo, every barcode set and the product.

required
host str

The codon usage table the coding bases were written for.

required
name str

What each round's product is called.

''
prices PriceRecord | None

The price record the protocol's bill is costed against, if the caller holds one. Its quantities compute either way; with no record every money cell is a hole.

None
pool PoolPlan | None

The oligo pool every block is synthesised from, where the build names a primer set. synbio.igga.cargo designs it.

None
working Working | None

The vector the finished library is moved into, where the build names one, and the enzyme chosen to admit it. None leaves the library in the destination vector.

None
seated SeatedParts | None

Every part in its own well of the carrier the build names. None where it names none, and the parts are already in hand.

None
Source code in src/synbio/igga/plan.py
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
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
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
@dataclass(frozen=True, slots=True)
class LibraryPlan:
    """One planned library: what it costs, what it makes, and what the bench has to do.

    Parameters
    ----------
    build
        What this build chose: its positions, its inputs and its dials.
    scheme
        The method the build was planned by.
    vector
        The record the plan was made from, before any retrofit.
    destination
        The vector a round can open, and what making it one changed.
    block_vectors
        The block vector a position's cargo closes into, one a position in the build's order. A
        part enters on its own position's entry overhang, so each offers that overhang and nothing
        else about them differs. Empty where the build names no primer set and the blocks are
        ordered whole.
    part_lists
        One per position, in the build's order: each member's name and the protein it codes for,
        whether it was given as protein or read off DNA.
    coding
        One per position: the coding sequence a member came already coded in, empty for a member
        written from the host's codon usage.
    standard
        The overhang standard the build works to, and what it charges each part.
    parts
        Every part's synthesis sequence, in position order and then list order.
    rounds
        One a position, in the order they run. The last round's product is the representative
        construct and the ones before it are the intermediates.
    coverage
        What each round has to cover, and what its colony floor leaves out.
    bench
        What each round takes at the bench, computed from that round's own lengths.
    verdict
        What `synbio.igga.gate` made of the finished design: every tube, every cargo,
        every barcode set and the product.
    host
        The codon usage table the coding bases were written for.
    name
        What each round's product is called.
    prices
        The price record the protocol's bill is costed against, if the caller holds one. Its
        quantities compute either way; with no record every money cell is a hole.
    pool
        The oligo pool every block is synthesised from, where the build names a primer set.
        `synbio.igga.cargo` designs it.
    working
        The vector the finished library is moved into, where the build names one, and the
        enzyme chosen to admit it. `None` leaves the library in the destination vector.
    seated
        Every part in its own well of the carrier the build names. `None` where it names none,
        and the parts are already in hand.
    """

    build: Build
    scheme: Scheme
    vector: SequenceRecord
    destination: Destination
    block_vectors: tuple[Destination, ...]
    part_lists: tuple[PartList, ...]
    coding: tuple[Mapping[str, str], ...]
    standard: Standard
    parts: tuple[Part, ...]
    rounds: tuple[Round, ...]
    coverage: tuple[RoundCoverage, ...]
    bench: tuple[RoundBench, ...]
    verdict: Verdict
    host: str
    name: str = ""
    prices: PriceRecord | None = None
    pool: PoolPlan | None = None
    working: Working | None = None
    seated: SeatedParts | None = None

    @property
    def product(self) -> SequenceRecord:
        """The representative construct: the last round's product."""
        return self.rounds[-1].product

    @property
    def constructs(self) -> int:
        """How many distinct constructs the part lists yield."""
        return constructs([len(one) for one in self.part_lists])

    @property
    def representative_parts(self) -> tuple[Part, ...]:
        """The one part a position the records were simulated from."""
        return tuple(one.part for one in self.rounds)

    @property
    def named_block_vectors(self) -> tuple[tuple[str, str], ...]:
        """Each block vector as the protocol says it: what it is called, and the file holding it."""
        return tuple(
            (one.record.name or f"block vector {number}", BLOCK_VECTOR_FILE.format(number=number))
            for number, one in enumerate(self.block_vectors, 1)
        )

    @property
    def reads(self) -> ReadPairs:
        """The pairs that read this library back, designed against the records it simulated."""
        return read_pairs(self.scheme, self.rounds, self.working)

    @property
    def validations(self) -> tuple[dmx.Validation, ...]:
        """What reading these designs back takes, one a route, and empty where none is named.

        The build's floor chooses the designs and its routes read them. The bench is sized
        from that set and not from the part list, so a design the floor leaves out costs no
        well, no plate and no reagent. Every plate it pours is selected on the destination's own
        marker, which is not the marker the published read-back was written for.

        A build naming two routes reads the same designs on each: they are the ways of one job,
        and the bench does one of them.
        """
        chosen = designs(self.parts, self.pool)
        found = (
            dmx.validation(
                dmx.ROUTES[name],
                chosen,
                self.build.validate_from,
                selection=selection_for(self.destination.record),
                index_plate=self.build.index_plate,
            )
            for name in self.build.routes
        )
        return tuple(one for one in found if one is not None)

    @property
    def checks(self) -> tuple[Check, ...]:
        """Every check the gate made, in the order it made them.

        The plan judges nothing itself: these are `verdict`'s, so a design this pipeline wrote
        and one an agent composed are judged by the same code.
        """
        return self.verdict.checks

    @property
    def status(self) -> Status:
        """The worst status of any check."""
        return status(self.checks)

    def chain(self) -> Project:
        """Return this plan as the chain of protocols the bench works through, in order.

        The `Project` returned is `mbio.protocol.model.Project`, a chain of protocols,
        and not what this plan was made from, which is a `Build`.
        """
        return chain_of(
            Run(
                scheme=self.scheme,
                positions=self.build.positions,
                barcode_length=self.build.barcode.length,
                vector=self.vector,
                destination=self.destination,
                part_lists=self.part_lists,
                standard=self.standard,
                parts=self.parts,
                rounds=self.rounds,
                bench=self.bench,
                constructs=self.constructs,
                checks=self.verdict.summary,
                host=self.host,
                sheet=PARTS_FILE,
                barcodes=BARCODE_FILE,
                changes=CHANGE_FILE,
                read_sheet=READ_PRIMER_FILE,
                validations=self.validations,
                prices=self.prices,
                pool=self.pool,
                pool_sheet=POOL_FILE,
                primer_sheet=POOL_PRIMER_FILE,
                block_vectors=self.named_block_vectors,
                block_records=self.block_vectors,
                working=self.working,
                reads=self.reads,
                marks=self.build.marks,
                linkage_fidelity=self.build.linkage_fidelity,
                working_file="" if self.working is None else WORKING_VECTOR_FILE,
                final_assembly=self.build.final_assembly,
                pcr1_cycles=self.build.pcr1_cycles,
                pcr2_cycles=self.build.pcr2_cycles,
                primer_plates=self.build.primer_plates,
                seated=self.seated,
            )
        )

    def write(self, directory: str | os.PathLike[str]) -> Files:
        """Write the sheets, the records, the protocol data and its page into `directory`.

        The directory is made when it is not there. The files are named by `PARTS_FILE`,
        `BARCODE_FILE`, `CHANGE_FILE` and `BLOCK_VECTOR_FILE`, by `synbio.igga.rounds`
        for the records and by `mbio.protocol.render`, which puts a chain of protocols in
        its own `PROTOCOL_DIR`, and a second run over the same inputs writes the same bytes.
        """
        out = Path(directory)
        out.mkdir(parents=True, exist_ok=True)
        sheet = out / PARTS_FILE
        sheet.write_text(synthesis_sheet(self.parts), encoding="utf-8")
        barcodes = out / BARCODE_FILE
        barcodes.write_text(barcode_table(self.parts, self.build.position_count), encoding="utf-8")
        changes = out / CHANGE_FILE
        changes.write_text(change_table(self.standard), encoding="utf-8")
        records = write_records(self.rounds, out)
        blocks: list[Path] = []
        for number, one in enumerate(self.block_vectors, 1):
            blocks.append(out / BLOCK_VECTOR_FILE.format(number=number))
            write_dna(one.record, blocks[-1])
        working = None
        if self.working is not None:
            working = out / WORKING_VECTOR_FILE
            write_dna(self.working.record, working)
        pool = primers = oligo = None
        if self.pool is not None:
            pool = out / POOL_FILE
            pool.write_text(pool_sheet(self.pool.pool), encoding="utf-8")
            primers = out / POOL_PRIMER_FILE
            primers.write_text(primer_inventory(self.pool.pool), encoding="utf-8")
            oligo = out / OLIGO_FILE
            write_dna(
                oligo_record(
                    self.pool.pool.oligos[0],
                    layout=self.pool.pool.layout,
                    primers=self.pool.pool.primers,
                ),
                oligo,
            )
        # The pages draw the records beside them, so every record is written before they render.
        written = write_run_files(self.chain(), out)
        reads = out / READ_PRIMER_FILE
        reads.write_text(read_sheet(self.reads), encoding="utf-8")
        return Files(
            sheet,
            barcodes,
            changes,
            records,
            written.data,
            written.pages,
            pool,
            primers,
            oligo,
            reads,
            tuple(blocks),
            working,
            written.maps,
        )

product property

product: SequenceRecord

The representative construct: the last round's product.

coverage instance-attribute

coverage: tuple[RoundCoverage, ...]

checks property

checks: tuple[Check, ...]

Every check the gate made, in the order it made them.

The plan judges nothing itself: these are verdict's, so a design this pipeline wrote and one an agent composed are judged by the same code.

status property

status: Status

The worst status of any check.

write

write(directory: str | PathLike[str]) -> Files

Write the sheets, the records, the protocol data and its page into directory.

The directory is made when it is not there. The files are named by PARTS_FILE, BARCODE_FILE, CHANGE_FILE and BLOCK_VECTOR_FILE, by synbio.igga.rounds for the records and by mbio.protocol.render, which puts a chain of protocols in its own PROTOCOL_DIR, and a second run over the same inputs writes the same bytes.

Source code in src/synbio/igga/plan.py
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
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def write(self, directory: str | os.PathLike[str]) -> Files:
    """Write the sheets, the records, the protocol data and its page into `directory`.

    The directory is made when it is not there. The files are named by `PARTS_FILE`,
    `BARCODE_FILE`, `CHANGE_FILE` and `BLOCK_VECTOR_FILE`, by `synbio.igga.rounds`
    for the records and by `mbio.protocol.render`, which puts a chain of protocols in
    its own `PROTOCOL_DIR`, and a second run over the same inputs writes the same bytes.
    """
    out = Path(directory)
    out.mkdir(parents=True, exist_ok=True)
    sheet = out / PARTS_FILE
    sheet.write_text(synthesis_sheet(self.parts), encoding="utf-8")
    barcodes = out / BARCODE_FILE
    barcodes.write_text(barcode_table(self.parts, self.build.position_count), encoding="utf-8")
    changes = out / CHANGE_FILE
    changes.write_text(change_table(self.standard), encoding="utf-8")
    records = write_records(self.rounds, out)
    blocks: list[Path] = []
    for number, one in enumerate(self.block_vectors, 1):
        blocks.append(out / BLOCK_VECTOR_FILE.format(number=number))
        write_dna(one.record, blocks[-1])
    working = None
    if self.working is not None:
        working = out / WORKING_VECTOR_FILE
        write_dna(self.working.record, working)
    pool = primers = oligo = None
    if self.pool is not None:
        pool = out / POOL_FILE
        pool.write_text(pool_sheet(self.pool.pool), encoding="utf-8")
        primers = out / POOL_PRIMER_FILE
        primers.write_text(primer_inventory(self.pool.pool), encoding="utf-8")
        oligo = out / OLIGO_FILE
        write_dna(
            oligo_record(
                self.pool.pool.oligos[0],
                layout=self.pool.pool.layout,
                primers=self.pool.pool.primers,
            ),
            oligo,
        )
    # The pages draw the records beside them, so every record is written before they render.
    written = write_run_files(self.chain(), out)
    reads = out / READ_PRIMER_FILE
    reads.write_text(read_sheet(self.reads), encoding="utf-8")
    return Files(
        sheet,
        barcodes,
        changes,
        records,
        written.data,
        written.pages,
        pool,
        primers,
        oligo,
        reads,
        tuple(blocks),
        working,
        written.maps,
    )

synbio.igga.read_build

read_build(path: str | PathLike[str]) -> Build

Read a build from JSON, resolving every file path against the file's own directory.

Raises:

Type Description
ValueError

On a missing or unknown key, a value of another JSON type, a path naming no file, or any check Build makes.

KeyError

If it names an enzyme or a codon usage table this package does not ship.

Examples:

>>> read_build("project.json").positions
('N', 'DBD', 'C')
Source code in src/synbio/igga/project.py
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
495
496
497
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
572
573
574
575
576
def read_build(path: str | os.PathLike[str]) -> Build:
    """Read a build from JSON, resolving every file path against the file's own directory.

    Raises
    ------
    ValueError
        On a missing or unknown key, a value of another JSON type, a path naming no file, or any
        check `Build` makes.
    KeyError
        If it names an enzyme or a codon usage table this package does not ship.

    Examples
    --------
    >>> read_build("project.json").positions  # doctest: +SKIP
    ('N', 'DBD', 'C')
    """
    file = Path(path)
    data = jsonfile.read_object(path)
    jsonfile.refuse_keys(data, _BUILD_KEYS, _BUILD_OPTIONAL, "a build")
    given = dict(data)
    return Build(
        jsonfile.text(given, "name", "a build's"),
        positions=tuple(
            jsonfile.one_text(one, f"positions[{index}]")
            for index, one in enumerate(jsonfile.listing(given, "positions", "a build's"))
        ),
        parts=jsonfile.named_file(
            file, jsonfile.text(given, "parts", "a build's"), "parts", "a build's"
        ),
        vector=jsonfile.named_file(
            file, jsonfile.text(given, "vector", "a build's"), "vector", "a build's"
        ),
        host=jsonfile.text(given, "host", "a build's"),
        oligo_length=jsonfile.whole(given, "oligo_length", "a build's"),
        batch_size=jsonfile.whole(given, "batch_size", "a build's"),
        completeness=jsonfile.number(given, "completeness", "a build's"),
        primers=(
            jsonfile.named_file(
                file, jsonfile.text(given, "primers", "a build's"), "primers", "a build's"
            )
            if "primers" in given
            else None
        ),
        working_vector=(
            jsonfile.named_file(
                file,
                jsonfile.text(given, "working_vector", "a build's"),
                "working_vector",
                "a build's",
            )
            if "working_vector" in given
            else None
        ),
        carrier=(
            jsonfile.named_file(
                file, jsonfile.text(given, "carrier", "a build's"), "carrier", "a build's"
            )
            if "carrier" in given
            else None
        ),
        bands=_bands(given.get("bands")),
        validate_from=(
            jsonfile.whole(given, "validate_from", "a build's")
            if "validate_from" in given
            else None
        ),
        routes=tuple(
            jsonfile.one_text(one, f"routes[{index}]")
            for index, one in enumerate(
                jsonfile.listing(given, "routes", "a build's") if "routes" in given else ()
            )
        ),
        index_plate=jsonfile.text(given, "index_plate", "a build's")
        if "index_plate" in given
        else "",
        representation_seen=(
            jsonfile.number(given, "representation_seen", "a build's")
            if "representation_seen" in given
            else None
        ),
        representation_skew=(
            jsonfile.number(given, "representation_skew", "a build's")
            if "representation_skew" in given
            else None
        ),
        reads_per_member=(
            jsonfile.whole(given, "reads_per_member", "a build's")
            if "reads_per_member" in given
            else None
        ),
        linkage_fidelity=(
            jsonfile.number(given, "linkage_fidelity", "a build's")
            if "linkage_fidelity" in given
            else None
        ),
        final_assembly=_final_assembly(given.get("final_assembly")),
        pcr1_cycles=jsonfile.whole(given, "pcr1_cycles", "a build's")
        if "pcr1_cycles" in given
        else None,
        pcr2_cycles=jsonfile.whole(given, "pcr2_cycles", "a build's")
        if "pcr2_cycles" in given
        else None,
        seed=jsonfile.whole(given, "seed", "a build's") if "seed" in given else SEED,
        reserved_extra=tuple(
            jsonfile.one_text(one, f"reserved_extra[{index}]")
            for index, one in enumerate(
                jsonfile.listing(given, "reserved_extra", "a build's")
                if "reserved_extra" in given
                else ()
            )
        ),
        barcode=_barcode(given.get("barcode")),
        primer_plates=_primer_plates(given.get("primer_plates")),
    )

synbio.igga.Build dataclass

What one build chooses, checked on construction.

Parameters:

Name Type Description Default
name str

What the run and each round's product is called.

required
positions tuple[str, ...]

The positions, in the order the rounds fill them, so the last is the terminal one. A position is a name: every one of them carries the method's own stuffers.

required
parts Path

The FASTA of every part list, each record named for the position it fills.

required
vector Path

The destination vector, circular: a .dna, GenBank or FASTA file.

required
working_vector Path | None

The vector the finished library is moved into, circular, before its ccdB cassette: a stock the lab holds and an application chooses. Omitted, the library stays in the destination vector and the final assembly is written as what it cannot say.

None
carrier Path | None

The plasmid each part is kept in until a round wants it, circular and supplied linearised: a stock the lab holds. Named, the run opens by seating every part in it; omitted, the parts are already in hand and nothing is seated. The carrier plasmid and the enzyme releasing a part from it are the method's, in synbio.dmx.carrier; which one this lab holds is the build's.

None
host str

The codon usage table the coding bases are written for.

required
oligo_length int

How long one synthesised oligo of the pool may be.

required
primers Path | None

The orthogonal primer set the pool is amplified by, as a two-column sheet the user holds. Without one no oligo can be written, because the sites are templated on it.

None
bands Mapping[str, tuple[str, ...]]

The vendor's bands for each quantity the pool is banded by, written low-high with an empty top for a tier with no top. They are what headroom is measured against, and they are reported whether or not anyone holds a price.

dict()
batch_size int

How many parts one batch of the bench work carries.

required
completeness float

The chance each round is sized for that none of its products is missing.

required
validate_from int | None

The fragment-count floor at or above which a design is read back one well at a time. Omitted, nothing is read and the library stays polyclonal; 0 reads every design. There is no default: mbio.bench.readback.clean_colony_chance gives a design's chance of a clean colony, not the chance worth paying to check.

None
routes tuple[str, ...]

Which of synbio.dmx.ROUTES read those wells back, one or more. Named exactly when validate_from is, because an unread build needs no route. Name two and the run writes a page for each, as the two ways of one job the bench does one of. A build that reads anything back names primers too: without a pool each block arrives as the vendor ships it, and DMX has no colony to pick.

()
index_plate str

What this lab calls its prepared plate of barcoded primer pairs. Only the index PCR route takes one, so only that route may name it.

''
seed int

The seed the barcodes are drawn with.

SEED
reserved_extra tuple[str, ...]

Further enzymes this build needs a block kept clear of, added to the method's own.

()
representation_seen float | None

What this build holds the representation read to: the share of combinations that must be read at all, the skew ratio the counts must stay under, and the depth both are judged at. Each takes mbio.bench.coverage.REPRESENTATION_MARKS where the build states none, and each may only be tightened.

None
representation_skew float | None

What this build holds the representation read to: the share of combinations that must be read at all, the skew ratio the counts must stay under, and the depth both are judged at. Each takes mbio.bench.coverage.REPRESENTATION_MARKS where the build states none, and each may only be tightened.

None
reads_per_member float | None

What this build holds the representation read to: the share of combinations that must be read at all, the skew ratio the counts must stay under, and the depth both are judged at. Each takes mbio.bench.coverage.REPRESENTATION_MARKS where the build states none, and each may only be tightened.

None
linkage_fidelity float | None

The share of reads whose barcode must still name its part. No default: nothing published sets a mark for it, so a build that states none is read against no mark at all.

None
final_assembly FinalAssembly | None

What this lab measured the one-pot assembly at: the working vector's mass, which is the whole of it. The cargo follows from the release. No default, as for the two cycle counts below: a build that has run the pilot states it and one that has not leaves the hole standing.

None
pcr1_cycles int | None

The cycle counts this lab measured for PCR1, against the polymerase it runs, and for PCR2. No default: nobody published either, so a build stating neither prints the band Twist gives for another polymerase and no PCR2 count at all.

None
pcr2_cycles int | None

The cycle counts this lab measured for PCR1, against the polymerase it runs, and for PCR2. No default: nobody published either, so a build stating neither prints the band Twist gives for another polymerase and no PCR2 count at all.

None
barcode Barcode

What one part's barcode holds to.

Barcode()
primer_plates PrimerPlates | None

How this lab lays the pool's primers out as a stock plate and its working copies. Omitted, no such protocol is written: an empty page is worse than a step, and the amounts are nobody's to guess. It needs primers, which is what there is to plate.

None
scheme Scheme

The method the build is made by. There is one, and it is IGGA.

IGGA

Raises:

Type Description
ValueError

If a position is repeated or missing, a number is not positive, the completeness does not lie between 0 and 1, the floor is negative, a route is neither of the two or named twice, the floor and the routes are not both there or both absent, an index plate is named without the route that takes one, a route is named over cargo DMX cannot pick, a representation mark loosens the sourced one, or the barcode and the method's cloning scar are not whole codons together — which names barcode-frame.

KeyError

If reserved_extra names an enzyme this package does not ship, or host no shipped codon usage table.

Source code in src/synbio/igga/project.py
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
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
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
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
@dataclass(frozen=True, slots=True)
class Build:
    """What one build chooses, checked on construction.

    Parameters
    ----------
    name
        What the run and each round's product is called.
    positions
        The positions, in the order the rounds fill them, so the last is the terminal one. A
        position is a name: every one of them carries the method's own stuffers.
    parts
        The FASTA of every part list, each record named for the position it fills.
    vector
        The destination vector, circular: a ``.dna``, GenBank or FASTA file.
    working_vector
        The vector the finished library is moved into, circular, before its ccdB cassette: a
        stock the lab holds and an application chooses. Omitted, the library stays in the
        destination vector and the final assembly is written as what it cannot say.
    carrier
        The plasmid each part is kept in until a round wants it, circular and supplied
        linearised: a stock the lab holds. Named, the run opens by seating every part in it;
        omitted, the parts are already in hand and nothing is seated. The carrier plasmid and
        the enzyme releasing a part from it are the method's, in
        `synbio.dmx.carrier`; which one this lab holds is the build's.
    host
        The codon usage table the coding bases are written for.
    oligo_length
        How long one synthesised oligo of the pool may be.
    primers
        The orthogonal primer set the pool is amplified by, as a two-column sheet the user
        holds. Without one no oligo can be written, because the sites are templated on it.
    bands
        The vendor's bands for each quantity the pool is banded by, written ``low-high`` with an
        empty top for a tier with no top. They are what headroom is measured against, and they
        are reported whether or not anyone holds a price.
    batch_size
        How many parts one batch of the bench work carries.
    completeness
        The chance each round is sized for that none of its products is missing.
    validate_from
        The fragment-count floor at or above which a design is read back one well at a time.
        Omitted, nothing is read and the library stays polyclonal; ``0`` reads every design.
        There is no default: `mbio.bench.readback.clean_colony_chance` gives a design's
        chance of a clean colony, not the chance worth paying to check.
    routes
        Which of `synbio.dmx.ROUTES` read those wells back, one or more. Named exactly
        when `validate_from` is, because an unread build needs no route. Name two and the run
        writes a page for each, as the two ways of one job the bench does one of. A build that
        reads anything back names `primers` too: without a pool each block arrives as the vendor
        ships it, and DMX has no colony to pick.
    index_plate
        What this lab calls its prepared plate of barcoded primer pairs. Only the index PCR
        route takes one, so only that route may name it.
    seed
        The seed the barcodes are drawn with.
    reserved_extra
        Further enzymes this build needs a block kept clear of, added to the method's own.
    representation_seen, representation_skew, reads_per_member
        What this build holds the representation read to: the share of combinations that must be
        read at all, the skew ratio the counts must stay under, and the depth both are judged at.
        Each takes `mbio.bench.coverage.REPRESENTATION_MARKS` where the build states
        none, and each may only be tightened.
    linkage_fidelity
        The share of reads whose barcode must still name its part. No default: nothing published
        sets a mark for it, so a build that states none is read against no mark at all.
    final_assembly
        What this lab measured the one-pot assembly at: the working vector's mass, which is the
        whole of it. The cargo follows from the release. No default, as for the two cycle counts
        below: a build that has run the pilot states it and one that has not leaves the hole
        standing.
    pcr1_cycles, pcr2_cycles
        The cycle counts this lab measured for PCR1, against the polymerase it runs, and for
        PCR2. No default: nobody published either, so a build stating neither prints the band
        Twist gives for another polymerase and no PCR2 count at all.
    barcode
        What one part's barcode holds to.
    primer_plates
        How this lab lays the pool's primers out as a stock plate and its working copies.
        Omitted, no such protocol is written: an empty page is worse than a step, and the
        amounts are nobody's to guess. It needs `primers`, which is what there is to plate.
    scheme
        The method the build is made by. There is one, and it is `IGGA`.

    Raises
    ------
    ValueError
        If a position is repeated or missing, a number is not positive, the completeness does not
        lie between 0 and 1, the floor is negative, a route is neither of the two or named twice,
        the floor and the routes are not both there or both absent, an index plate is named
        without the route that takes one, a route is named over cargo DMX cannot pick, a
        representation mark loosens the sourced one, or the barcode and the method's cloning
        scar are not whole codons together — which names ``barcode-frame``.
    KeyError
        If `reserved_extra` names an enzyme this package does not ship, or `host` no shipped
        codon usage table.
    """

    name: str
    _: KW_ONLY
    positions: tuple[str, ...]
    parts: Path
    vector: Path
    host: str
    oligo_length: int
    batch_size: int
    completeness: float
    primers: Path | None = None
    working_vector: Path | None = None
    carrier: Path | None = None
    bands: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
    validate_from: int | None = None
    routes: tuple[str, ...] = ()
    index_plate: str = ""
    seed: int = SEED
    reserved_extra: tuple[str, ...] = ()
    representation_seen: float | None = None
    representation_skew: float | None = None
    reads_per_member: int | None = None
    linkage_fidelity: float | None = None
    final_assembly: FinalAssembly | None = None
    pcr1_cycles: int | None = None
    pcr2_cycles: int | None = None
    barcode: Barcode = field(default_factory=Barcode)
    primer_plates: PrimerPlates | None = None
    scheme: Scheme = IGGA

    def __post_init__(self) -> None:
        """Normalise the lists and paths, then check everything a designer will read."""
        object.__setattr__(self, "positions", tuple(self.positions))
        object.__setattr__(self, "reserved_extra", tuple(self.reserved_extra))
        object.__setattr__(self, "parts", Path(self.parts))
        object.__setattr__(self, "vector", Path(self.vector))
        if self.working_vector is not None:
            object.__setattr__(self, "working_vector", Path(self.working_vector))
        if self.carrier is not None:
            object.__setattr__(self, "carrier", Path(self.carrier))
        object.__setattr__(self, "bands", dict(self.bands))
        self._check_positions()
        self._check_numbers()
        self._check_validation()
        self._check_plates()
        self._check_marks()
        self._check_barcode_frame()
        if self.host not in codon_tables():
            raise KeyError(
                f"no shipped codon usage table is called {self.host!r}: {', '.join(codon_tables())}"
            )
        for named in self.reserved_extra:
            get_enzyme(named)

    @property
    def position_count(self) -> int:
        """How many positions one product joins."""
        return len(self.positions)

    @property
    def reserved(self) -> tuple[str, ...]:
        """Every enzyme a block is kept clear of: the method's, then this build's own.

        A build adds and never replaces, so an enzyme the method reserves stays reserved
        whatever a build says.
        """
        return tuple(dict.fromkeys((*self.scheme.reserved, *self.reserved_extra)))

    @property
    def reserved_enzymes(self) -> tuple[Enzyme, ...]:
        """Those enzymes, in that order."""
        return tuple(get_enzyme(name) for name in self.reserved)

    @property
    def barcode_block_length(self) -> int:
        """How long the finished barcode block is: one barcode a position, joined by the scar."""
        return self.scheme.barcode_block_length(self.barcode.length, self.position_count)

    @property
    def retained_length(self) -> int:
        """What the finished product keeps past its last part: stuffer and barcode block."""
        return self.scheme.retained_length(self.barcode.length, self.position_count)

    def _check_positions(self) -> None:
        """Refuse a build with no position, an unnamed one, or one named twice."""
        if not self.positions:
            raise ValueError("a build needs at least one position")
        if any(not one for one in self.positions):
            raise ValueError("every position of a build is named")
        if len(set(self.positions)) != len(self.positions):
            raise ValueError(f"the positions {', '.join(self.positions)} name one of them twice")

    def _check_numbers(self) -> None:
        """Refuse a build whose lengths or counts are not positive, or whose chance is not one."""
        for named, value in (
            ("oligo_length", self.oligo_length),
            ("batch_size", self.batch_size),
            ("barcode.length", self.barcode.length),
            ("barcode.min_distance", self.barcode.min_distance),
        ):
            if value <= 0:
                raise ValueError(f"{named} is {value}, and a build states a positive one")
        if not 0.0 < self.completeness < 1.0:
            raise ValueError(
                f"completeness is {self.completeness}, and a build states a chance between 0 and 1"
            )
        for named, cycles in (("pcr1_cycles", self.pcr1_cycles), ("pcr2_cycles", self.pcr2_cycles)):
            if cycles is not None and cycles <= 0:
                raise ValueError(f"{named} is {cycles}, and a build states a positive one")

    def _check_validation(self) -> None:
        """Refuse a bad floor or route, one of the two alone, a stray plate, or unclonal cargo.

        The floor and the routes travel together: a floor with no route says which designs are
        read and not how, and a route with no floor names a read nobody asked for. Whether
        there is anything to read back at all is DMX's own question, not a build's.

        Naming a route twice is refused rather than collapsed: it would write one page twice and
        ask the bench to choose between a page and itself.
        """
        if self.validate_from is not None and self.validate_from < 0:
            raise ValueError(
                f"validate_from is {self.validate_from}, and a fragment-count floor counts "
                "fragments; omit it to read nothing, or set 0 to read every design"
            )
        for one in self.routes:
            if one not in ROUTES:
                raise ValueError(
                    f"route is {one!r}, and a build reads its wells back on one of "
                    f"{', '.join(repr(each) for each in ROUTES)}"
                )
        if len(set(self.routes)) != len(self.routes):
            raise ValueError(
                "routes names the same route twice, and the ways of one job are different ways"
            )
        if (self.validate_from is None) != (not self.routes):
            raise ValueError(
                "validate_from and routes are stated together: a build that reads designs back "
                f"says which, and on which of {', '.join(repr(one) for one in ROUTES)}"
            )
        if self.index_plate and ROUTE_INDEX_PCR.name not in self.routes:
            raise ValueError(
                "index_plate names a plate of barcoded primer pairs, which only the "
                f"{ROUTE_INDEX_PCR.name!r} route takes"
            )
        if self.routes:
            refuse_unclonal(
                "a block as the vendor ships it, which is what a build naming no primer set orders",
                clonal=self.primers is not None,
            )

    def _check_plates(self) -> None:
        """Refuse primer plates where there is no primer set to plate.

        The plates seat the primers that amplify the pool, and a build naming no primer set
        writes no pool and no primer.
        """
        if self.primer_plates is not None and self.primers is None:
            raise ValueError(
                "primer_plates lays out the primers that amplify the pool, so a build naming "
                "it names primers too"
            )

    def _check_marks(self) -> None:
        """Refuse a representation mark that loosens the sourced one, or a share outside 0 to 1.

        A build tightens and never loosens, the same rule `synbio.dmx.depth_check`
        holds a read depth to: the sourced mark is the floor the method stands on.
        """
        for named, value in (
            ("representation_seen", self.representation_seen),
            ("linkage_fidelity", self.linkage_fidelity),
        ):
            if value is not None and not 0.0 < value <= 1.0:
                raise ValueError(f"{named} is {value}, and a build states a share of 1 or less")
        sourced = REPRESENTATION_MARKS
        if self.representation_seen is not None and self.representation_seen < sourced.seen:
            raise ValueError(
                f"representation_seen is {self.representation_seen}, and a build may only "
                f"raise the {sourced.seen} share Joung sets, not lower it"
            )
        if self.representation_skew is not None and self.representation_skew > sourced.skew:
            raise ValueError(
                f"representation_skew is {self.representation_skew}, and a build may only "
                f"lower the skew ratio of {sourced.skew} Joung allows, not raise it"
            )
        if self.representation_skew is not None and self.representation_skew < 1.0:
            raise ValueError(
                f"representation_skew is {self.representation_skew}, and the evenest library "
                "a count can describe has a ratio of 1"
            )
        if self.reads_per_member is not None and self.reads_per_member < sourced.reads_per_member:
            raise ValueError(
                f"reads_per_member is {self.reads_per_member}, and a build may only raise the "
                f"{sourced.reads_per_member} reads a member Joung judges at, not lower it"
            )

    @property
    def marks(self) -> RepresentationMarks:
        """What this build holds its representation reads to: the sourced marks, as tightened."""
        return RepresentationMarks(
            seen=(
                REPRESENTATION_MARKS.seen
                if self.representation_seen is None
                else self.representation_seen
            ),
            skew=(
                REPRESENTATION_MARKS.skew
                if self.representation_skew is None
                else self.representation_skew
            ),
            reads_per_member=(
                REPRESENTATION_MARKS.reads_per_member
                if self.reads_per_member is None
                else self.reads_per_member
            ),
        )

    def _check_barcode_frame(self) -> None:
        """Check a barcode and the scar joining it to the last make whole codons together."""
        scar = len(self.scheme.cloning_scar)
        unit = self.barcode.length + scar
        if unit % 3:
            refuse(
                "barcode-frame",
                f"a barcode of {self.barcode.length} bases and a cloning scar of {scar} make "
                f"{unit}, which is not a whole number of codons",
            )

barcode_block_length property

barcode_block_length: int

How long the finished barcode block is: one barcode a position, joined by the scar.

marks property

marks: RepresentationMarks

What this build holds its representation reads to: the sourced marks, as tightened.

position_count property

position_count: int

How many positions one product joins.

reserved property

reserved: tuple[str, ...]

Every enzyme a block is kept clear of: the method's, then this build's own.

A build adds and never replaces, so an enzyme the method reserves stays reserved whatever a build says.

reserved_enzymes property

reserved_enzymes: tuple[Enzyme, ...]

Those enzymes, in that order.

retained_length property

retained_length: int

What the finished product keeps past its last part: stuffer and barcode block.

__post_init__

__post_init__() -> None

Normalise the lists and paths, then check everything a designer will read.

Source code in src/synbio/igga/project.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def __post_init__(self) -> None:
    """Normalise the lists and paths, then check everything a designer will read."""
    object.__setattr__(self, "positions", tuple(self.positions))
    object.__setattr__(self, "reserved_extra", tuple(self.reserved_extra))
    object.__setattr__(self, "parts", Path(self.parts))
    object.__setattr__(self, "vector", Path(self.vector))
    if self.working_vector is not None:
        object.__setattr__(self, "working_vector", Path(self.working_vector))
    if self.carrier is not None:
        object.__setattr__(self, "carrier", Path(self.carrier))
    object.__setattr__(self, "bands", dict(self.bands))
    self._check_positions()
    self._check_numbers()
    self._check_validation()
    self._check_plates()
    self._check_marks()
    self._check_barcode_frame()
    if self.host not in codon_tables():
        raise KeyError(
            f"no shipped codon usage table is called {self.host!r}: {', '.join(codon_tables())}"
        )
    for named in self.reserved_extra:
        get_enzyme(named)

synbio.igga.check_library

check_library(
    build: Build,
    *,
    destination: SequenceRecord,
    blocks: Mapping[str, Sequence[SequenceRecord]],
    products: Sequence[SequenceRecord],
    barcodes: Mapping[str, Sequence[str]],
    working: SequenceRecord | None = None,
    cargo: Enzyme | None = None,
    profile: LigaseProfile | None = None,
) -> Verdict

Judge a whole build: its vector, every tube, every cargo, every barcode set and the product.

Nothing here is read off a plan. The molecules are finished records, the barcodes are the table that decodes the sequencing, and the reactions are composed from the chain's own order, so a design an agent wrote is judged exactly as one plan_igga wrote is.

The rounds run in the DMX vector, so the destination is where a build accepts one and check_dmx_vector judges it here. A destination no primer that reads a well binds carries no DMX backbone -- a minimal stand-in -- and has no blunt site any primer could reach, so nothing judges it.

Parameters:

Name Type Description Default
build Build

What the build chose.

required
destination SequenceRecord

The vector the first round opens, judged as a rebuilt DMX vector where one is given.

required
blocks Mapping[str, Sequence[SequenceRecord]]

The synthesised blocks of each position, keyed by position name.

required
products Sequence[SequenceRecord]

One record a round, in the order the rounds ran. The last is the library product.

required
barcodes Mapping[str, Sequence[str]]

Each position's barcodes, keyed by position name.

required
working SequenceRecord | None

The working vector the library is moved into and the enzyme that admits it, where the build names one. Both or neither: the final assembly is judged only where there is a vector to judge it in.

None
cargo SequenceRecord | None

The working vector the library is moved into and the enzyme that admits it, where the build names one. Both or neither: the final assembly is judged only where there is a vector to judge it in.

None
profile LigaseProfile | None

A ligase's own matrix the user holds, read by mbio.ligase.read_profile. Every ligation then carries how often that ligase joins its overhangs, beside the fidelity score, which this does not move. Nothing is judged on it without one.

None

Raises:

Type Description
ValueError

If there is not one product, one part list and one barcode list a position, or if one of working and cargo is given without the other.

Source code in src/synbio/igga/gate.py
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
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
def check_library(
    build: Build,
    *,
    destination: SequenceRecord,
    blocks: Mapping[str, Sequence[SequenceRecord]],
    products: Sequence[SequenceRecord],
    barcodes: Mapping[str, Sequence[str]],
    working: SequenceRecord | None = None,
    cargo: Enzyme | None = None,
    profile: LigaseProfile | None = None,
) -> Verdict:
    """Judge a whole build: its vector, every tube, every cargo, every barcode set and the product.

    Nothing here is read off a plan. The molecules are finished records, the barcodes are the
    table that decodes the sequencing, and the reactions are composed from the chain's own order,
    so a design an agent wrote is judged exactly as one `plan_igga` wrote is.

    The rounds run in the DMX vector, so the destination is where a build accepts one and
    `check_dmx_vector` judges it here. A destination no primer that reads a well binds carries no
    DMX backbone -- a minimal stand-in -- and has no blunt site any primer could reach, so
    nothing judges it.

    Parameters
    ----------
    build
        What the build chose.
    destination
        The vector the first round opens, judged as a rebuilt DMX vector where one is given.
    blocks
        The synthesised blocks of each position, keyed by position name.
    products
        One record a round, in the order the rounds ran. The last is the library product.
    barcodes
        Each position's barcodes, keyed by position name.
    working, cargo
        The working vector the library is moved into and the enzyme that admits it, where the
        build names one. Both or neither: the final assembly is judged only where there is a
        vector to judge it in.
    profile
        A ligase's own matrix the user holds, read by `mbio.ligase.read_profile`. Every
        ligation then carries how often that ligase joins its overhangs, beside the fidelity
        score, which this does not move. Nothing is judged on it without one.

    Raises
    ------
    ValueError
        If there is not one product, one part list and one barcode list a position, or if one of
        `working` and `cargo` is given without the other.
    """
    if (working is None) != (cargo is None):
        raise ValueError(
            "a final assembly is judged on a working vector and the enzyme that admits cargo to "
            "it: name both, or neither"
        )
    made: list[Judgement] = []
    if _read_by_a_well_primer(destination, WELL_PRIMERS):
        made.extend(check_dmx_vector(destination, build=build))
    reactions = library_reactions(build, destination=destination, blocks=blocks, products=products)
    if working is not None and cargo is not None:
        reactions += final_assembly_reactions(
            build, library=products[-1], working=working, cargo=cargo
        )
    for reaction in reactions:
        made.extend(check_reaction(reaction, build=build, profile=profile))
    last = build.positions[-1]
    for position in build.positions:
        if position not in barcodes:
            raise ValueError(f"no barcode list was given for position {position!r}")
        made.extend(check_barcode_set(barcodes[position], build=build, where=position))
        for index, block in enumerate(blocks[position]):
            made.extend(
                check_cargo(
                    _cargo(block, build),
                    build=build,
                    where=f"the {position} cargo {index + 1}",
                    ends_chain=position == last,
                )
            )
    made.extend(check_product(products[-1], build=build, barcodes=barcodes))
    return Verdict(made)

Read wells back

DMX reads back designs the lab already holds. One design sits in one well, and that well is marked, sequenced and called on its own. The 96 barcode sequences do not ship, so read_kit reads a copy you hold; see the DMX barcode kit for the file it reads.

synbio.dmx.plan_dmx

plan_dmx(
    build: Build | str | PathLike[str],
) -> ReadBackPlan

Plan the read-back build asks for: which designs, on which route, and what that takes.

Parameters:

Name Type Description Default
build Build | str | PathLike[str]

What this run chooses, or a path to the JSON holding it; synbio.dmx.build.read_build reads one. It names the designs sheet by path.

required

Returns:

Type Description
ReadBackPlan

The plan, whose write puts the run's data and its page in one directory.

Raises:

Type Description
ValueError

If the build or its designs sheet cannot be read, or its floor reads no design back.

Examples:

>>> plan_dmx("build.json").validation.wells
8
Source code in src/synbio/dmx/plan.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
def plan_dmx(build: Build | str | os.PathLike[str]) -> ReadBackPlan:
    """Plan the read-back `build` asks for: which designs, on which route, and what that takes.

    Parameters
    ----------
    build
        What this run chooses, or a path to the JSON holding it;
        `synbio.dmx.build.read_build` reads one. It names the designs sheet by path.

    Returns
    -------
    ReadBackPlan
        The plan, whose `write` puts the run's data and its page in one directory.

    Raises
    ------
    ValueError
        If the build or its designs sheet cannot be read, or its floor reads no design back.

    Examples
    --------
    >>> plan_dmx("build.json").validation.wells  # doctest: +SKIP
    8
    """
    one = build if isinstance(build, Build) else read_build(build)
    designs = read_designs(one.designs)
    sized = validation(
        one.marking_route,
        designs,
        one.validate_from,
        selection=one.selection,
        index_plate=one.index_plate,
    )
    if sized is None:
        raise ValueError(
            f"validate_from is {one.validate_from}, and no design on {os.fspath(one.designs)} is "
            f"built from that many fragments: {counted(len(designs), 'design')} read, "
            "none of them back"
        )
    return ReadBackPlan(one, designs, sized)

synbio.dmx.ReadBackPlan dataclass

One planned read-back: which designs are read, on which route, and what that takes.

Parameters:

Name Type Description Default
build Build

What this run chose: its designs, its archive, its route and its floor.

required
designs tuple[Design, ...]

Every design the sheet named, before the floor chose among them.

required
validation Validation

What reading the chosen designs back takes: the wells, the plates and the depth.

required
Source code in src/synbio/dmx/plan.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
 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
178
179
180
181
182
183
184
185
@dataclass(frozen=True, slots=True)
class ReadBackPlan:
    """One planned read-back: which designs are read, on which route, and what that takes.

    Parameters
    ----------
    build
        What this run chose: its designs, its archive, its route and its floor.
    designs
        Every design the sheet named, before the floor chose among them.
    validation
        What reading the chosen designs back takes: the wells, the plates and the depth.
    """

    build: Build
    designs: tuple[Design, ...]
    validation: Validation

    @property
    def archive(self) -> Item:
        """The plate of clonal stock each design is spotted from, which this run only reads."""
        return Item(
            self.build.archive,
            f"one clonal stock a design, {len(self.designs)} of them, sealed and frozen",
            storage="-80 °C glycerol stock",
        )

    def protocol(self) -> Protocol:
        """Return the page someone works through, its three sections labelled."""
        one = self.validation
        stock = marking_stock(one)
        return citing(
            Protocol(
                f"Design read-back: {one.route.name}",
                summary=(
                    f"Take {counted(len(one.designs), 'archived design')} to {one.wells} clonal "
                    "wells, "
                    "mark each well so sequencing says which well it came from, and call a pass "
                    "or a fail per well."
                ),
                overview=self._overview(),
                highlights=self._highlights(),
                consumes=(self.archive, *((stock,) if stock else ())),
                produces=(PICKED_PLATE, WELL_CALLS),
                materials=validation_materials(one),
                equipment=validation_equipment(one),
                plates=one.plates,
                steps=validation_steps(one),
                sources=dict(SOURCES),
            )
        )

    def chain(self) -> Project:
        """Return the run as a project of one, carrying why it is shaped as it is."""
        return replace(
            as_project(self.protocol()),
            title=self.build.name,
            background=self._background(),
        )

    def write(self, directory: str | os.PathLike[str]) -> Files:
        """Write the run's data and its page into `directory`.

        The directory is made when it is not there, the files are named by
        `mbio.protocol.render`, and a second run over the same inputs writes the same
        bytes. A run of one protocol is one page, so nothing here is a folder.
        """
        written = write_run_files(self.chain(), Path(directory))
        return Files(written.data, written.page)

    def _overview(self) -> dict[str, str]:
        """Return the facts a reader scans before starting."""
        one = self.validation
        return {
            "Designs": f"{len(one.designs)} of {len(self.designs)} on the sheet",
            "Route": one.route.name,
            "Wells": f"{one.wells} over {counted(len(one.picked), 'picked plate')}",
            "Selection": selected_on(one.selection),
        }

    def _highlights(self) -> tuple[str, ...]:
        """Return what those facts mean, a sentence each."""
        return (
            "A design is read back as a name and a count of the pieces it was built from, never "
            "as bases, so any clonal stock this lab holds can be read back here.",
            f"The bench is sized from the {counted(len(self.validation.designs), 'design')} read "
            "and not "
            "from the sheet, so a design the floor leaves out costs no well, no plate and no "
            "reagent.",
        )

    def _background(self) -> tuple[Topic, ...]:
        """Return why the run is shaped as it is, a topic at a time."""
        one = self.validation
        floor = (
            "every design on the sheet, which a floor of zero does"
            if one.floor == 0
            else f"every design built from {counted(one.floor, 'fragment')} or more"
        )
        pooled = counted(len(pooled_plates(one)), "plate")
        apart = counted(one.route.plate_axis, "plate")
        return (
            Topic(
                "Which designs are read back",
                (
                    f"This run reads {floor}: {len(one.designs)} of {len(self.designs)}. The "
                    "rest stay polyclonal and are never read one design at a time.",
                    f"Every design is spotted from {self.build.archive}, which this run reads a "
                    "copy of and never changes. A design whose spot grows nothing has no clone "
                    "to read, and is re-transformed before anyone re-synthesises it.",
                ),
            ),
            Topic(
                "How a well is told from the rest",
                (
                    f"The {one.route.name} route marks each well: {one.route.marking}. The other "
                    "route marks the same wells a different way, and the picking, the pass rule "
                    "and the reformat are the same either way.",
                    "A well's marks are worked out from where the well is rather than looked up, "
                    "so a demultiplexer can check an address instead of trusting a file. One "
                    f"axis of the address is the plate: this run pools {pooled}, and the marks "
                    f"tell {apart} apart on one flow cell.",
                    f"A well is called above {one.route.wanted_reads} reads. The other route's "
                    "floor is not a stricter or a looser setting of the same scale, so neither "
                    "number carries over to the other route.",
                ),
            ),
        )

designs instance-attribute

designs: tuple[Design, ...]

protocol

protocol() -> Protocol

Return the page someone works through, its three sections labelled.

Source code in src/synbio/dmx/plan.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
def protocol(self) -> Protocol:
    """Return the page someone works through, its three sections labelled."""
    one = self.validation
    stock = marking_stock(one)
    return citing(
        Protocol(
            f"Design read-back: {one.route.name}",
            summary=(
                f"Take {counted(len(one.designs), 'archived design')} to {one.wells} clonal "
                "wells, "
                "mark each well so sequencing says which well it came from, and call a pass "
                "or a fail per well."
            ),
            overview=self._overview(),
            highlights=self._highlights(),
            consumes=(self.archive, *((stock,) if stock else ())),
            produces=(PICKED_PLATE, WELL_CALLS),
            materials=validation_materials(one),
            equipment=validation_equipment(one),
            plates=one.plates,
            steps=validation_steps(one),
            sources=dict(SOURCES),
        )
    )

write

write(directory: str | PathLike[str]) -> Files

Write the run's data and its page into directory.

The directory is made when it is not there, the files are named by mbio.protocol.render, and a second run over the same inputs writes the same bytes. A run of one protocol is one page, so nothing here is a folder.

Source code in src/synbio/dmx/plan.py
118
119
120
121
122
123
124
125
126
def write(self, directory: str | os.PathLike[str]) -> Files:
    """Write the run's data and its page into `directory`.

    The directory is made when it is not there, the files are named by
    `mbio.protocol.render`, and a second run over the same inputs writes the same
    bytes. A run of one protocol is one page, so nothing here is a folder.
    """
    written = write_run_files(self.chain(), Path(directory))
    return Files(written.data, written.page)

synbio.dmx.read_kit

read_kit(path: str | PathLike[str] | None = None) -> Kit

Read the 96 barcode sequences from a copy the user holds.

The package ships none. path names the file, or KIT_ENV does.

Raises:

Type Description
ValueError

If no file is named, or the file is not one of these, saying what one is.

Examples:

>>> read_kit("dmx-barcodes.tsv").barcodes[0].group
1
Source code in src/synbio/dmx/kit.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
def read_kit(path: str | os.PathLike[str] | None = None) -> Kit:
    """Read the 96 barcode sequences from a copy the user holds.

    The package ships none. `path` names the file, or `KIT_ENV` does.

    Raises
    ------
    ValueError
        If no file is named, or the file is not one of these, saying what one is.

    Examples
    --------
    >>> read_kit("dmx-barcodes.tsv").barcodes[0].group  # doctest: +SKIP
    1
    """
    named = path if path is not None else os.environ.get(KIT_ENV)
    if not named:
        raise ValueError(
            f"the DMX barcode sequences are not shipped: name the file, or set {KIT_ENV}. "
            f"It is {KIT_EXPECTED}"
        )
    one = Path(named)
    try:
        barcodes = _kit_rows(one.read_text(encoding="utf-8-sig").splitlines())
    except (KeyError, TypeError, ValueError) as error:
        raise ValueError(
            f"{one.name} is not the DMX barcode kit ({error}); expected {KIT_EXPECTED}"
        ) from error
    return Kit(one, barcodes)

synbio.dmx.Kit dataclass

The 96 barcodes a user holds, grouped as the kit groups them.

Parameters:

Name Type Description Default
path Path

The file they were read from, so a report can say which copy was used.

required
barcodes tuple[KitBarcode, ...]

Every barcode, in group and then index order.

required
Source code in src/synbio/dmx/kit.py
 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 Kit:
    """The 96 barcodes a user holds, grouped as the kit groups them.

    Parameters
    ----------
    path
        The file they were read from, so a report can say which copy was used.
    barcodes
        Every barcode, in group and then index order.
    """

    path: Path
    barcodes: tuple[KitBarcode, ...]

    def group(self, group: int) -> tuple[KitBarcode, ...]:
        """Return one group's barcodes, in index order.

        Raises
        ------
        ValueError
            If the kit has no such group.
        """
        if not 1 <= group <= GROUPS:
            raise ValueError(f"the kit has groups 1 to {GROUPS}, and {group} is not one of them")
        return tuple(one for one in self.barcodes if one.group == group)

    def at(self, group: int, index: int) -> KitBarcode:
        """Return the barcode at one group and index, both counting from one.

        Raises
        ------
        ValueError
            If the kit holds no barcode there.
        """
        found = [one for one in self.group(group) if one.index == index]
        if not found:
            raise ValueError(f"the kit holds no barcode {index} of group {group}")
        return found[0]

at

at(group: int, index: int) -> KitBarcode

Return the barcode at one group and index, both counting from one.

Raises:

Type Description
ValueError

If the kit holds no barcode there.

Source code in src/synbio/dmx/kit.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def at(self, group: int, index: int) -> KitBarcode:
    """Return the barcode at one group and index, both counting from one.

    Raises
    ------
    ValueError
        If the kit holds no barcode there.
    """
    found = [one for one in self.group(group) if one.index == index]
    if not found:
        raise ValueError(f"the kit holds no barcode {index} of group {group}")
    return found[0]

group

group(group: int) -> tuple[KitBarcode, ...]

Return one group's barcodes, in index order.

Raises:

Type Description
ValueError

If the kit has no such group.

Source code in src/synbio/dmx/kit.py
84
85
86
87
88
89
90
91
92
93
94
def group(self, group: int) -> tuple[KitBarcode, ...]:
    """Return one group's barcodes, in index order.

    Raises
    ------
    ValueError
        If the kit has no such group.
    """
    if not 1 <= group <= GROUPS:
        raise ValueError(f"the kit has groups 1 to {GROUPS}, and {group} is not one of them")
    return tuple(one for one in self.barcodes if one.group == group)