codex/dna-antiparallel-base-pairs.py

dna-antiparallel-base-pairs.py
"""Draw canonical DNA pair counts; write dna-antiparallel-base-pairs.png in CWD.

Run from _media: python ../dna-antiparallel-base-pairs.py
Tested with Python 3.14.4, Matplotlib 3.10.7 and NumPy 2.3.5.
Original unrolled schematic: boxes are bases, not atomic structures.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch


def main():
    sequence = "ACGTAC"
    partner = sequence.translate(str.maketrans("ACGT", "TGCA"))
    counts = [2 if base in "AT" else 3 for base in sequence]
    assert partner == "TGCATG" and partner[::-1] == "GTACGT"
    assert counts == [2, 3, 3, 2, 2, 3] and sum(counts) == 15
    colors = {"A": "#f4cb72", "T": "#a9dce8", "G": "#c8b9e7", "C": "#add8b0"}
    ink = "#31485c"
    plt.rcParams.update({"font.size": 12, "figure.facecolor": "white"})
    fig, ax = plt.subplots(figsize=(8.8, 5.0))
    fig.subplots_adjust(left=0.03, right=0.97, top=0.88, bottom=0.04)
    ax.set(xlim=(-1.1, 6.0), ylim=(-0.9, 4.1))
    ax.axis("off")
    fig.suptitle("DNA: antiparallel strands and canonical base pairs", fontsize=16)
    for y, left, right in [(3.0, "5′", "3′"), (0.5, "3′", "5′")]:
        ax.plot([-0.35, 5.35], [y, y], color=ink, lw=3)
        ax.text(-0.75, y, left, ha="center", va="center", fontsize=18)
        ax.text(5.75, y, right, ha="center", va="center", fontsize=18)
        for i in range(6):
            ax.text(i, y, "S", ha="center", va="center", fontsize=11,
                    bbox={"boxstyle": "square,pad=0.22", "fc": "white", "ec": ink})
            if i < 5:
                ax.text(i + 0.5, y, "P", ha="center", va="center", fontsize=10,
                        bbox={"boxstyle": "circle,pad=0.18", "fc": "white", "ec": ink})
    for i, (top, bottom, count) in enumerate(zip(sequence, partner, counts)):
        ax.plot([i, i], [2.63, 3.0], color=ink, lw=2)
        ax.plot([i, i], [0.5, 0.87], color=ink, lw=2)
        for y, base in [(2.35, top), (1.15, bottom)]:
            ax.add_patch(FancyBboxPatch((i - 0.28, y - 0.27), 0.56, 0.54,
                                      boxstyle="round,pad=0.02", facecolor=colors[base],
                                      edgecolor=ink, zorder=3))
            ax.text(i, y, base, ha="center", va="center", fontsize=18, zorder=4)
        for j in range(count):
            x = i + (j - (count - 1) / 2) * 0.13
            ax.plot([x, x], [1.46, 2.04], color=ink, lw=1.5, ls=":")
        ax.text(i, 1.75, str(count), ha="center", va="center", fontsize=10,
                bbox={"fc": "white", "ec": "none", "pad": 0.5}, zorder=5)
    for start, end, y in [(-0.25, 5.25, 3.55), (5.25, -0.25, 0.05)]:
        ax.annotate("", (end, y), (start, y),
                    arrowprops={"arrowstyle": "->", "lw": 1.8, "color": "#176b92"})
    ax.text(2.5, 3.73, "Arrows show each strand’s 5′ → 3′ direction", ha="center", fontsize=11)
    ax.text(2.5, -0.34, "3 A–T pairs × 2 + 3 G–C pairs × 3 = 15 hydrogen bonds",
            ha="center", fontsize=12)
    ax.text(2.5, -0.72, "S: sugar   P: phosphate   Dotted lines: hydrogen bonds\n"
            "Unrolled schematic; connector positions do not show atomic geometry",
            ha="center", va="center", fontsize=10, color=ink)
    fig.savefig(Path(Path(__file__).stem + ".png"), dpi=120,
                facecolor="white", transparent=False)
    plt.close(fig)
    print(f"5′–{sequence}–3′ / 3′–{partner}–5′; counts: {counts}; total: {sum(counts)}")


if __name__ == "__main__":
    main()