File size: 7,716 Bytes
ff34739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
"""Acquire the known-good control: subsample the 10x pbmc_1k_v3 FASTQs to ~40k read pairs,
and download the 3M-february-2018 cell-barcode whitelist (with a computed md5).

Deterministic subsample: the same seeded ``seqkit sample`` mask is applied to R1 and R2 (they
share record order), so pairs stay locked; ``seqkit head`` trims to an exact count.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import subprocess
import tarfile
from pathlib import Path

from seqcolyte.io.external import require_tool, tool_version

_REPO = Path(__file__).resolve().parents[1]
TARBALL_URL = "https://cf.10xgenomics.com/samples/cell-exp/3.0.0/pbmc_1k_v3/pbmc_1k_v3_fastqs.tar"
TARBALL_SIZE = 5_549_312_000
WHITELIST_URL = "https://raw.githubusercontent.com/f0t1h/3M-february-2018/master/3M-february-2018.txt.gz"
WHITELIST_SIZE = 18_350_152
WHITELIST_COUNT = 6_794_880


def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
    return subprocess.run(cmd, check=True, **kw)


def _md5(path: Path) -> str:
    h = hashlib.md5()
    with open(path, "rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def ensure_tarball(tarball: Path, *, url: str = TARBALL_URL, expected_size: int = TARBALL_SIZE) -> None:
    if not tarball.exists():
        print(f"[get_data] downloading {url} -> {tarball}")
        tarball.parent.mkdir(parents=True, exist_ok=True)
        _run(["wget", "-c", "-q", "-O", str(tarball), url])
    size = tarball.stat().st_size
    if size != expected_size:
        raise RuntimeError(f"tarball size {size} != expected {expected_size}; re-download")
    print(f"[get_data] tarball OK ({size} bytes)")


def _concat_members(tarball: Path, members: list[str], out: Path) -> None:
    with open(out, "wb") as fh:
        for m in members:
            _run(["tar", "-xOf", str(tarball), m], stdout=fh)


def extract_reads(tarball: Path, workdir: Path) -> tuple[Path, Path]:
    """Concatenate the per-lane R1s and R2s (in lane order) into two gzipped FASTQs."""
    with tarfile.open(tarball) as tf:
        names = tf.getnames()
    r1 = sorted(n for n in names if "_R1_" in n)
    r2 = sorted(n for n in names if "_R2_" in n)
    if not r1 or not r2:
        raise RuntimeError(f"no R1/R2 members found in {tarball}")
    full_r1, full_r2 = workdir / "full_R1.fastq.gz", workdir / "full_R2.fastq.gz"
    print(f"[get_data] concatenating {len(r1)} R1 + {len(r2)} R2 lane files")
    _concat_members(tarball, r1, full_r1)
    _concat_members(tarball, r2, full_r2)
    return full_r1, full_r2


def _num_seqs(seqkit: str, path: Path) -> int:
    out = _run([seqkit, "stats", "-T", str(path)], capture_output=True, text=True).stdout
    header, row = out.splitlines()[0].split("\t"), out.splitlines()[1].split("\t")
    return int(row[header.index("num_seqs")].replace(",", ""))


def _subsample_one(seqkit: str, src: Path, dst: Path, prop: float, n: int, seed: int) -> None:
    # Two steps (no pipe): order-preserving Bernoulli sample with a fixed seed, then trim to
    # exactly n. Same seed + same record order on R1 and R2 keeps pairs locked. A pipe would
    # SIGPIPE `seqkit sample` when `seqkit head` closes early, so materialize a temp file.
    tmp = dst.with_suffix(dst.suffix + ".sampled.tmp.gz")
    try:
        _run([seqkit, "sample", "-s", str(seed), "-p", f"{prop:.8f}", str(src), "-o", str(tmp)])
        _run([seqkit, "head", "-n", str(n), str(tmp), "-o", str(dst)])
    finally:
        tmp.unlink(missing_ok=True)


def subsample(full_r1: Path, full_r2: Path, out_r1: Path, out_r2: Path, *, n: int, seed: int) -> int:
    seqkit = require_tool("seqkit")
    total = _num_seqs(seqkit, full_r1)
    prop = min(1.0, (n * 2.0) / total) if total else 1.0
    print(f"[get_data] {total} pairs total; sampling p={prop:.6f} then head -n {n} (seed={seed})")
    out_r1.parent.mkdir(parents=True, exist_ok=True)
    _subsample_one(seqkit, full_r1, out_r1, prop, n, seed)
    _subsample_one(seqkit, full_r2, out_r2, prop, n, seed)
    kept = _num_seqs(seqkit, out_r1)
    kept2 = _num_seqs(seqkit, out_r2)
    if kept != kept2:
        raise RuntimeError(f"R1/R2 subsample counts differ: {kept} != {kept2}")
    print(f"[get_data] wrote {kept} pairs -> {out_r1}, {out_r2}")
    return kept


def cmd_data(args: argparse.Namespace) -> int:
    tarball = Path(args.tarball)
    ensure_tarball(tarball, url=args.url, expected_size=args.expected_size)
    workdir = Path(args.workdir)
    workdir.mkdir(parents=True, exist_ok=True)
    full_r1, full_r2 = extract_reads(tarball, workdir)
    try:
        subsample(full_r1, full_r2, Path(args.out_r1), Path(args.out_r2), n=args.n, seed=args.seed)
    finally:
        for f in (full_r1, full_r2):
            f.unlink(missing_ok=True)
    if not args.keep_tarball:
        tarball.unlink(missing_ok=True)
        print(f"[get_data] removed {tarball}")
    return 0


def cmd_whitelist(args: argparse.Namespace) -> int:
    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    if not out.exists():
        print(f"[get_data] downloading whitelist -> {out}")
        _run(["wget", "-c", "-q", "-O", str(out), args.url])
    size = out.stat().st_size
    if size != WHITELIST_SIZE:
        raise RuntimeError(f"whitelist size {size} != expected {WHITELIST_SIZE}")
    md5 = _md5(out)
    provenance = {
        "name": "3M-february-2018",
        "path": str(out.relative_to(_REPO)) if out.is_absolute() else str(out),
        "source_url": args.url,
        "size_bytes_gz": size,
        "count": WHITELIST_COUNT,
        "md5": md5,
        "md5_provenance": "computed_local_no_official_checksum",
        "retrieved_date": args.date,
        "tool_versions": {"seqkit": tool_version("seqkit")},
    }
    Path(args.provenance_out).write_text(json.dumps(provenance, indent=2) + "\n")
    print(f"[get_data] whitelist OK ({size} bytes, md5={md5}) — provenance -> {args.provenance_out}")
    print("[get_data] note: no official 10x checksum exists; md5 recorded in the sidecar, not the committed spec")
    return 0


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(prog="get_data", description="Acquire the pbmc_1k_v3 control + whitelist")
    sub = ap.add_subparsers(dest="cmd", required=True)

    d = sub.add_parser("data", help="download + subsample pbmc_1k_v3 to ~N pairs")
    d.add_argument("--tarball", default=str(_REPO / "data/raw/pbmc_1k_v3_fastqs.tar"))
    d.add_argument("--url", default=TARBALL_URL)
    d.add_argument("--expected-size", type=int, default=TARBALL_SIZE, dest="expected_size")
    d.add_argument("--workdir", default=str(_REPO / "data/raw"))
    d.add_argument("--out-r1", default=str(_REPO / "data/raw/pbmc_1k_v3_sub_R1.fastq.gz"), dest="out_r1")
    d.add_argument("--out-r2", default=str(_REPO / "data/raw/pbmc_1k_v3_sub_R2.fastq.gz"), dest="out_r2")
    d.add_argument("--n", type=int, default=40000)
    d.add_argument("--seed", type=int, default=100)
    d.add_argument("--keep-tarball", action="store_true", dest="keep_tarball")
    d.set_defaults(func=cmd_data)

    w = sub.add_parser("whitelist", help="download 3M-february-2018 whitelist + compute md5")
    w.add_argument("--url", default=WHITELIST_URL)
    w.add_argument("--out", default=str(_REPO / "whitelists/3M-february-2018.txt.gz"))
    w.add_argument("--provenance-out", default=str(_REPO / "whitelists/3M-february-2018.provenance.json"),
                   dest="provenance_out")
    w.add_argument("--date", default="2026-07-07")
    w.set_defaults(func=cmd_whitelist)

    args = ap.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())