codex/dna-replication-semi-conservative.py

dna-replication-semi-conservative.py
"""Original DNA ancestry diagram; Python 3.14, repository Matplotlib.

Build: make _media/dna-replication-semi-conservative.png
Writes an opaque white, same-basename PNG in the current directory.
"""

from dataclasses import dataclass
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt


@dataclass(frozen=True)
class Strand:
    label: str
    sequence: str
    born: int


def daughters(duplex, generation, first_label):
    top, bottom = duplex
    complement = str.maketrans("ATGC", "TACG")
    return [(top, Strand(f"N{first_label}", top.sequence.translate(complement), generation)),
            (Strand(f"N{first_label + 1}", bottom.sequence.translate(complement), generation), bottom)]


def lineage():
    original = (Strand("O1", "ATGCC", 0), Strand("O2", "TACGG", 0))
    first = daughters(original, 1, 1)
    second = daughters(first[0], 2, 3) + daughters(first[1], 2, 5)
    stages = [[original], first, second]
    for generation, stage in enumerate(stages):
        assert len(stage) == 2 ** generation
        assert sum(s.born == 0 for d in stage for s in d) == 2
        assert len({s.label for d in stage for s in d}) == 2 ** (generation + 1)
        for top, bottom in stage:
            assert top.sequence == "ATGCC" and bottom.sequence == "TACGG"
        if generation:
            assert all(sum(s.born < generation for s in d) == 1 for d in stage)
    assert sum(any(s.born == 0 for s in d) for d in second) == 2
    return stages


def main():
    stages = lineage()
    colors = {"O1": "#1766ac", "O2": "#7a429a", 1: "#16805d", 2: "#c46a18"}
    plt.rcParams.update({"font.size": 11, "figure.facecolor": "white",
                         "axes.facecolor": "white", "savefig.facecolor": "white"})
    fig, ax = plt.subplots(figsize=(10.2, 6.6))
    fig.subplots_adjust(left=0.035, right=0.985, bottom=0.035, top=0.91)
    ax.set(xlim=(-0.5, 10.25), ylim=(-0.8, 7.8))
    ax.axis("off")
    fig.suptitle("DNA replication: each daughter keeps one parental strand", fontsize=15)
    xs = [0.0, 3.7, 7.4]
    ys = [[4.0], [5.45, 2.65], [6.15, 4.75, 3.35, 1.95]]
    width = 2.0
    for generation in range(2):
        for i, y in enumerate(ys[generation]):
            for target in ys[generation + 1][2 * i:2 * i + 2]:
                ax.annotate("", (xs[generation + 1] - 0.35, target),
                            (xs[generation] + width + 0.35, y),
                            arrowprops={"arrowstyle": "->", "lw": 1.2, "color": "#a2adb5"})
    for generation, stage in enumerate(stages):
        x = xs[generation]
        ax.text(x + width / 2, 7.4, ["Before copying", "After round 1", "After round 2"][generation],
                ha="center", fontsize=13, weight="bold")
        ax.text(x + width / 2, 7.03, f"{len(stage)} duplex{'es' if len(stage) != 1 else ''}", ha="center")
        for i, (top, bottom) in enumerate(stage):
            y = ys[generation][i]
            for j in range(5):
                ax.plot([x + 0.25 + j * 0.375] * 2, [y - 0.25, y + 0.25],
                        color="#d3d9dc", lw=1, zorder=1)
            for offset, strand, left, right in [(0.25, top, "5′", "3′"),
                                                 (-0.25, bottom, "3′", "5′")]:
                color = colors.get(strand.label, colors.get(strand.born))
                ax.plot([x, x + width], [y + offset] * 2, color=color, lw=4, zorder=2)
                ax.text(x + width / 2, y + offset, "  ".join(strand.sequence),
                        ha="center", va="center", family="monospace", fontsize=12,
                        bbox={"facecolor": "white", "edgecolor": "none", "pad": 1.0}, zorder=3)
                ax.text(x - 0.12, y + offset, left, ha="right", va="center", fontsize=10)
                ax.text(x + width + 0.12, y + offset, right, ha="left", va="center", fontsize=10)
                ax.text(x + width / 2, y + offset + (0.30 if offset > 0 else -0.30),
                        strand.label, ha="center", va="center", color=color, fontsize=10)
        original_duplexes = sum(any(s.born == 0 for s in d) for d in stage)
        ax.text(x + width / 2, 0.66, f"{original_duplexes}/{len(stage)} duplexes\ncontain an original strand",
                ha="center", fontsize=10, linespacing=1.4)
    legend = [("#1766ac", "O1: original"), ("#7a429a", "O2: original"),
              ("#16805d", "N1–N2: made in round 1"), ("#c46a18", "N3–N6: made in round 2")]
    for x, (color, label) in zip([-0.1, 2.3, 4.65, 7.7], legend):
        ax.plot([x, x + 0.22], [0.22, 0.22], color=color, lw=4)
        ax.text(x + 0.32, 0.22, label, va="center", fontsize=9)
    ax.text(4.8, -0.56, "After two rounds: 8 strands = 2 original + 2 from round 1 + 4 from round 2.\n"
            "Complete, error-free copying; flattened ancestry schematic, not a replication fork.",
            ha="center", fontsize=10, color="#475569", linespacing=1.5)
    fig.savefig(Path(Path(__file__).stem + ".png"), dpi=140, facecolor="white", transparent=False)
    plt.close(fig)
    print("Duplexes: 1, 2, 4; original strands: 2, 2, 2; round-2 strand ages: 2/2/4.")


if __name__ == "__main__":
    main()