seqcolyte / sim /engine.py
seqmachines's picture
Deploy Seqcolyte Studio
ff34739 verified
Raw
History Blame Contribute Delete
4.88 kB
"""Simulator orchestrator: assign each pair a label, rewrite R2 for affected pairs (R1 copied
byte-identically), and emit the failure FASTQs, ground-truth labels, and a run manifest."""
from __future__ import annotations
import hashlib
import json
import platform
import shutil
from collections import Counter
from pathlib import Path
import numpy as np
from seqcolyte.io.fastx import FastqRecord, iter_pairs, write_fastx_gz
from seqcolyte.spec.loader import load_spec
from sim import SIM_VERSION
from sim.base import ReadCtx
from sim.config import SimConfig
from sim.labels import write_labels
from sim.registry import get_mode
from sim.rng import read_rng
__all__ = ["run_simulation", "assign_label"]
def assign_label(rng: np.random.Generator, params: dict) -> tuple[str, str | None]:
"""Assign clean / readthrough / pure_dimer using two sequential draws from ``rng``."""
affected_fraction = float(params.get("affected_fraction", 0.30))
dimer_fraction = float(params.get("dimer_fraction", 0.33))
if rng.random() >= affected_fraction:
return "clean", None
if rng.random() < dimer_fraction:
return "pure_dimer", "pure_dimer"
return "readthrough", "readthrough"
def _md5(path: str) -> 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 run_simulation(config: SimConfig) -> dict:
spec = load_spec(config.spec)
mode = get_mode(config.failure_mode)
if not mode.applies_to(spec):
raise ValueError(
f"mode {mode.name!r} (platform {mode.platform!r}) does not apply to spec platform {spec.platform!r}"
)
cb_slice = spec.segment_slice("R1", "cell_barcode")
umi_slice = spec.segment_slice("R1", "umi")
for p in (config.out_r1, config.out_r2, config.out_labels, config.out_manifest):
Path(p).parent.mkdir(parents=True, exist_ok=True)
# R1 is left byte-identical: copy the file rather than round-tripping records.
shutil.copyfile(config.input_r1, config.out_r1)
counts: Counter[str] = Counter()
rows: list[dict] = []
def rewritten_r2():
for i, (r1, r2) in enumerate(iter_pairs(config.input_r1, config.input_r2)):
rng = read_rng(config.seed, i)
label, subtype = assign_label(rng, config.params)
cb = r1.sequence[cb_slice]
umi = r1.sequence[umi_slice]
if label == "clean":
out = r2
fields: dict = {}
construct = "clean"
else:
ctx = ReadCtx(r1=r1, r2=r2, spec=spec, params=config.params, rng=rng,
pair_index=i, subtype=subtype, cb=cb, umi=umi, r2_len=len(r2.sequence))
res = mode.build_r2(ctx)
out = FastqRecord(name=r2.name, comment=r2.comment, sequence=res.sequence, quality=res.quality)
fields = res.fields
construct = res.construct
counts[label] += 1
rows.append({
"read_id": r2.name, "pair_index": i, "label": label,
"failure_mode": config.failure_mode if label != "clean" else "none",
"affected": int(label != "clean"),
"r1_len": len(r1.sequence), "r2_len": len(out.sequence),
"cb": cb, "umi": umi,
"insert_len": fields.get("insert_len", ""),
"polyA_len": fields.get("polyA_len", ""),
"pad_len": fields.get("pad_len", ""),
"truncated": int(fields["truncated"]) if "truncated" in fields else "",
"construct": construct,
})
yield out
n_pairs = write_fastx_gz(config.out_r2, rewritten_r2())
write_labels(config.out_labels, rows)
r1_in_md5, r1_out_md5 = _md5(config.input_r1), _md5(config.out_r1)
r1_identical = r1_in_md5 == r1_out_md5
manifest = {
"name": config.name,
"sim_version": SIM_VERSION,
"spec_id": spec.spec_id,
"failure_mode": config.failure_mode,
"seed": config.seed,
"params": config.params,
"n_pairs": n_pairs,
"label_counts": dict(counts),
"label_fractions": {k: round(v / n_pairs, 4) for k, v in counts.items()} if n_pairs else {},
"inputs": {"r1": config.input_r1, "r2": config.input_r2},
"outputs": {"r1": config.out_r1, "r2": config.out_r2, "labels": config.out_labels},
"r1_input_md5": r1_in_md5,
"r1_output_md5": r1_out_md5,
"r1_byte_identical": r1_identical,
"python": platform.python_version(),
}
Path(config.out_manifest).write_text(json.dumps(manifest, indent=2) + "\n")
if not r1_identical:
raise RuntimeError("R1 output is not byte-identical to input — invariant violated")
return manifest