v2 CN/LOH (WIP): cn build-model and cn call

Cyto model (.simurg-cyto: GC 1 kb/100 kb/1 Mb, PFB from the cluster file, optional
sex-aware batch reference from normal GTCs; IN-051, CYTO-038); GC-wave correction
(CYTO-003); smoothing (CYTO-004); HMM with constitutional, gain-LOH, mosaic and haploid
states, forward-backward QUAL and Viterbi segments (CYTO-005/012); event statistics,
effective size, post-filters, per-type thresholds, promotion/LowFraction, built-in
centromere mask from UCSC cytoBand (CYTO-006..021); sex resolution (CYTO-024), ploidy;
CNV VCF with bgzip+tabix (CYTO-028..031), segments parquet, cn_sample_summary.tsv and
the cn_call_manifest.json sidecar.
This commit is contained in:
kemal committed 2026-09-29 01:13:51 +01:00
1 parent 5164124438
commit edb59e4ee2
8 files changed
+1452

No files matched your search

+779
View File
@@ -0,0 +1,779 @@
"""``simurg cn build-model`` / ``simurg cn call`` — genome-wide CNV/LOH/mosaic calling (spec CYTO-001 … CYTO-040).
I/O and orchestration only; the numerics live in ``simurg.cn``.
"""
from __future__ import annotations
import hashlib
import io
import json
import math
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
import numpy.typing as npt
import pyarrow as pa
import pyarrow.parquet as pq
from simurg import __version__
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
from simurg.formats.bpm import Manifest, read_bpm
from simurg.formats.fasta import ContigMap, Genome
MODEL_SCHEMA = 1
F64 = npt.NDArray[np.float64]
def probe_set_hash(manifest: Manifest) -> str:
h = hashlib.sha256()
for name, chrom, pos in zip(manifest.names, manifest.chroms, manifest.map_infos.tolist(), strict=True):
h.update(f"{name}\t{chrom}\t{pos}\n".encode())
return h.hexdigest()
@dataclass
class CytoModel:
header: dict[str, Any]
names: list[str]
gc: npt.NDArray[np.float32] # (n, 3): 1 kb, 100 kb, 1 Mb
pfb: npt.NDArray[np.float32]
ref_lrr: npt.NDArray[np.float32] | None = None # batch reference: per-probe median LRR
ref_lrr_f: npt.NDArray[np.float32] | None = None # same, X/Y from female reference samples
ref_lrr_m: npt.NDArray[np.float32] | None = None # same, X/Y from male reference samples
ref_het: npt.NDArray[np.float32] | None = None # per-probe median heterozygous BAF
def run_build_model(
bpm: Path,
fasta: Path,
out: Path,
rep: Reporter,
egt: Path | None = None,
contig_alias: str = "auto",
reference_gtcs: Path | None = None,
max_reference: int = 100,
) -> int:
"""CYTO-038: GC covariates (and PFB when a cluster file is given) per manifest probe → ``.simurg-cyto``."""
from simurg.cn.model import GC_WINDOWS, gc_fractions, population_bfreq
man = read_bpm(bpm)
genome = Genome(fasta)
cmap = ContigMap([e.name for e in genome.entries], contig_alias)
chroms = np.array(man.chroms)
pos = man.map_infos.astype(np.int64)
gc = np.full((man.num_loci, len(GC_WINDOWS)), np.nan, dtype=np.float32)
for chrom in sorted(set(man.chroms)):
contig = cmap.get(chrom)
if contig is None:
continue
sel = np.flatnonzero((chroms == chrom) & (pos > 0))
if len(sel):
gc[sel] = gc_fractions(genome._contig_array(contig), pos[sel])
rep.info(f"GC windows for chromosome {chrom}: {len(sel)} probes")
pfb = population_bfreq(None, man.num_loci)
egt_name = ""
if egt is not None:
from simurg.formats.egt import read_egt
from simurg.genotype.clusters import align_clusters
clusters = align_clusters(read_egt(egt), man, allow_missing=True)
pfb = population_bfreq(np.where(clusters.present[:, None], clusters.n, 0), man.num_loci)
egt_name = Path(egt).name
ref_cols: dict[str, Any] = {}
n_ref = 0
if reference_gtcs is not None:
from simurg.cn.model import reference_stats
from simurg.formats.gtc import read_gtc
paths = sorted(reference_gtcs.rglob("*.gtc"))
if len(paths) > max_reference:
idx = np.random.default_rng(42).choice(len(paths), max_reference, replace=False)
paths = [paths[i] for i in sorted(idx)]
if len(paths) < 24:
rep.warn("W_QC_TOO_FEW", f"batch reference from only {len(paths)} GTCs (plan: >= 24)")
lrr = np.empty((len(paths), man.num_loci), dtype=np.float32)
baf = np.empty((len(paths), man.num_loci), dtype=np.float32)
sexes = []
for i, p in enumerate(paths):
g = read_gtc(p)
if len(g.logr_ratios) != man.num_loci:
raise SimurgError("E_MODEL_MANIFEST_MISMATCH", f"{p} has {len(g.logr_ratios)} loci, the manifest "
f"{man.num_loci}") # fmt: skip
lrr[i], baf[i] = g.logr_ratios, g.b_allele_freqs
sexes.append(g.gender.decode(errors="replace")[:1].upper() if g.gender else "U")
ref_lrr, ref_het, n_het = reference_stats(lrr, baf)
# X and Y from same-sex reference samples only (CYTO-005 baselines differ by sex)
sx = np.isin(chroms, ["X", "XY", "Y"])
ref_by_sex = {}
for sex in ("F", "M"):
rows = np.array([s_ == sex for s_ in sexes])
col = ref_lrr.copy()
if rows.sum() >= 3:
col[sx] = reference_stats(lrr[rows][:, sx], baf[rows][:, sx])[0]
ref_by_sex[sex] = col
n_f, n_m = sexes.count("F"), sexes.count("M")
rep.info(f"batch reference sexes: {n_f} F, {n_m} M, {len(sexes) - n_f - n_m} U")
ref_cols = {"ref_lrr": ref_lrr, "ref_lrr_f": ref_by_sex["F"], "ref_lrr_m": ref_by_sex["M"],
"ref_het_baf": ref_het, "ref_n_het": n_het} # fmt: skip
n_ref = len(paths)
rep.info(f"batch reference from {n_ref} GTC(s)")
header = {
"schema": "simurg.cyto_model",
"schema_version": MODEL_SCHEMA,
"software": f"SimurgArray {__version__}",
"manifest": man.manifest_name,
"probe_set_sha256": probe_set_hash(man),
"n_probes": man.num_loci,
"genome_build": genome.build,
"fasta_sha256": genome.sha256,
"gc_windows_bp": list(GC_WINDOWS),
"pfb_source": f"cluster file {egt_name}" if egt_name else "none (0.5)",
"reference_samples": n_ref,
}
table = pa.table({
"name": man.names,
"gc_1kb": gc[:, 0], "gc_100kb": gc[:, 1], "gc_1mb": gc[:, 2],
"pfb": pfb,
**ref_cols,
}) # fmt: skip
buf = io.BytesIO()
pq.write_table(table, buf, compression="zstd")
out.parent.mkdir(parents=True, exist_ok=True)
tmp = out.with_suffix(out.suffix + ".part")
with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_STORED) as z:
z.writestr("header.json", json.dumps(header, indent=1))
z.writestr("probes.parquet", buf.getvalue())
tmp.replace(out)
rep.info(f"cyto model for {man.num_loci} probes written to {out}")
return ExitCode.OK
def read_model(path: Path, manifest: Manifest) -> CytoModel:
"""IN-051: check schema and probe set."""
with zipfile.ZipFile(path) as z:
header = json.loads(z.read("header.json"))
table = pq.read_table(io.BytesIO(z.read("probes.parquet")))
if int(header.get("schema_version", 0)) < MODEL_SCHEMA:
raise SimurgError("E_MODEL_VERSION", f"{path}: cyto model schema {header.get('schema_version')} is too old",
hint="rebuild it with simurg cn build-model") # fmt: skip
if header.get("probe_set_sha256") != probe_set_hash(manifest):
raise SimurgError("E_MODEL_MANIFEST_MISMATCH", f"{path} was built for manifest {header.get('manifest')!r}, "
f"not {manifest.manifest_name!r} (probe set differs)") # fmt: skip
gc = np.column_stack([table.column(c).to_numpy(zero_copy_only=False) for c in ("gc_1kb", "gc_100kb", "gc_1mb")])
def col(c: str) -> npt.NDArray[np.float32] | None:
return table.column(c).to_numpy(zero_copy_only=False).astype(np.float32) if c in table.column_names else None
pfb = col("pfb")
assert pfb is not None
return CytoModel(header, table.column("name").to_pylist(), gc.astype(np.float32), pfb, ref_lrr=col("ref_lrr"),
ref_lrr_f=col("ref_lrr_f"), ref_lrr_m=col("ref_lrr_m"), ref_het=col("ref_het_baf")) # fmt: skip
# ================================================================================================ cn call
PAR = { # 1-based inclusive pseudo-autosomal regions on X
"GRCh38": ((10_001, 2_781_479), (155_701_383, 156_030_895)),
"GRCh37": ((60_001, 2_699_520), (154_931_044, 155_260_560)),
}
CALL_CHROMS = [str(i) for i in range(1, 23)] + ["X", "Y"]
@dataclass
class CnCallOptions:
out: Path
bpm: Path
model: Path
gtcs: Path | None = None
sheet: Path | None = None
summary: Path | None = None
fasta: Path | None = None
thresholds: Any = None # simurg.cn.events.Thresholds
smoothing: int = 5
cn_cap: int = 4
gc_correct: bool = True
unknown_sex_policy: str = "skip-sex-chromosomes"
gt_mode: str = "vcf"
svlen_mode: str = "signed"
mask_bed: Path | None = None
bgzip: bool = True
threads: int = 0
force: bool = False
argv: list[str] = field(default_factory=list)
def _builtin_file(name: str) -> bytes:
from importlib import resources
return resources.files("simurg.cn").joinpath("data", name).read_bytes()
def read_mask(path: Path | None, build: str) -> dict[str, list[tuple[int, int]]]:
"""CYTO-017: BED (0-based half-open) → per-chromosome intervals as 1-based inclusive."""
text = path.read_text() if path is not None else _builtin_file(f"mask_{build}.bed").decode()
out: dict[str, list[tuple[int, int]]] = {}
for line in text.splitlines():
if not line.strip() or line.startswith(("#", "track", "browser")):
continue
c, s, e = line.split("\t")[:3]
c = c[3:] if c.lower().startswith("chr") else c
out.setdefault(c, []).append((int(s) + 1, int(e)))
return out
def moving_median(x: F64, k: int) -> F64:
"""CYTO-004: median over 2k+1 consecutive values, truncated (never wrapped) at the ends; NaN ignored."""
if k <= 0 or len(x) == 0:
return x.copy()
from numpy.lib.stride_tricks import sliding_window_view
pad = np.concatenate([np.full(k, np.nan), x, np.full(k, np.nan)])
with np.errstate(all="ignore"):
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
med = np.nanmedian(sliding_window_view(pad, 2 * k + 1), axis=1)
return np.where(np.isfinite(x), med, np.nan)
@dataclass
class SampleInput:
sample_id: str
sample_name: str
gtc: str
sex: str # M | F | U
logr_dev: float
sex_source: str
def _robust_sd(x: F64, floor: float) -> float:
x = x[np.isfinite(x)]
if not len(x):
return floor
return max(floor, float(1.4826 * np.median(np.abs(x - np.median(x)))))
def _resolve_sex(si: SampleInput, chroms: npt.NDArray[np.str_], lrr: F64, geno: npt.NDArray[np.uint8],
policy: str) -> tuple[str, str, list[str]]: # fmt: skip
"""CYTO-024."""
if si.sex in ("M", "F"):
return si.sex, si.sex_source, []
x = chroms == "X"
called = geno[x] > 0
het = float((geno[x] == 2).sum() / called.sum()) if called.any() else math.nan
med = float(np.nanmedian(lrr[x])) if x.any() else math.nan
if math.isfinite(het) and math.isfinite(med):
if het < 0.03 and med < -0.15:
return "M", "resolved", []
if het > 0.08 and med > -0.1:
return "F", "resolved", []
if policy == "female":
return "F", "assumed", ["sex_assumed"]
if policy == "male":
return "M", "assumed", ["sex_assumed"]
if policy == "error":
raise SimurgError("E_CN_SEX_UNRESOLVED", f"{si.sample_id}: sex could not be resolved")
return "U", "unresolved", ["sex_unresolved"]
def call_sample(si: SampleInput, ctx: dict[str, Any], opts: CnCallOptions) -> dict[str, Any]:
"""One sample: preprocessing, per-region HMM, events, VCF + segments. Returns the summary row + events."""
from simurg.cn import hmm
from simurg.cn.events import (
annotate_stats,
apply_thresholds,
effective_size,
extract,
median_spacing,
post_filter,
set_filters,
)
from simurg.cn.gcwave import gc_correct
from simurg.formats.gtc import read_gtc
man_chroms: npt.NDArray[np.str_] = ctx["chroms"]
pos_all: npt.NDArray[np.int64] = ctx["pos"]
model: CytoModel = ctx["model"]
th = opts.thresholds
g = read_gtc(si.gtc)
lrr = g.logr_ratios.astype(np.float64)
baf = g.b_allele_freqs.astype(np.float64)
geno = g.genotypes
lrr[~np.isfinite(lrr)] = np.nan
baf[~np.isfinite(baf)] = np.nan
sex, sex_source, flags = _resolve_sex(si, man_chroms, lrr - float(np.nanmedian(lrr[ctx["auto"]])), geno,
opts.unknown_sex_policy) # fmt: skip
ref = {"F": model.ref_lrr_f, "M": model.ref_lrr_m}.get(sex) if model.ref_lrr_f is not None else None
if ref is None:
ref = model.ref_lrr
if ref is not None:
lrr = lrr - ref
if model.ref_het is not None:
het = (baf > 0.1) & (baf < 0.9)
baf = np.where(het, np.clip(baf - (model.ref_het - 0.5), 0.0, 1.0), baf)
auto = ctx["auto"]
gcfit = None
if opts.gc_correct:
lrr, gcfit = gc_correct(lrr, model.gc, auto)
lrr = lrr - float(np.nanmedian(lrr[auto]))
logr_dev = si.logr_dev if math.isfinite(si.logr_dev) else float(np.nanstd(lrr[auto]))
noisy = logr_dev > 0.2
whole_chrom_only = logr_dev > 0.3
if noisy:
flags.append("high_noise")
if whole_chrom_only:
flags.append("whole_chromosome_only")
pfb = model.pfb.astype(np.float64)
lrr_s = np.full_like(lrr, np.nan)
for sel in ctx["by_chrom"].values():
lrr_s[sel] = moving_median(lrr[sel], opts.smoothing)
# emission SD from the unsmoothed LRR: smoothed values are autocorrelated, their own SD would overstate evidence
seg_sd = _robust_sd(lrr[auto], 0.05)
b_het = baf[auto & (baf > 0.3) & (baf < 0.7)]
baf_sd = _robust_sd(b_het, 0.02)
dip, hap = hmm.diploid_states(), hmm.haploid_states()
counts: dict[str, int] = {}
events: list[Any] = []
segments: list[dict[str, Any]] = []
ploidy_num = 0.0
ploidy_den = 0
par = PAR.get(ctx["build"], PAR["GRCh38"])
for chrom in CALL_CHROMS:
if chrom in ("X", "Y") and sex == "U":
continue
if chrom == "Y" and sex == "F":
continue
sel = ctx["by_chrom"].get(chrom)
if sel is None or len(sel) == 0:
continue
pos = pos_all[sel]
in_par = np.zeros(len(sel), dtype=np.bool_)
if chrom == "X":
for a, b in par:
in_par |= (pos >= a) & (pos <= b)
regions = [(np.ones(len(sel), dtype=np.bool_), False)]
if chrom == "X" and sex == "M":
regions = [(in_par, False), (~in_par, True)]
elif chrom == "Y":
regions = [(np.ones(len(sel), dtype=np.bool_), True)]
m_sp = median_spacing(pos)
for rmask, haploid in regions:
if rmask.sum() < 2:
continue
idx = sel[rmask]
p, x_raw, b, gt = pos_all[idx], lrr[idx], baf[idx], geno[idx]
x_seg = lrr_s[idx]
states = hap if haploid else dip
prior = np.array([s.prior for s in states])
prior /= prior.sum()
offset = float(np.nanmedian(x_raw)) if haploid else 0.0
if not math.isfinite(offset):
offset = 0.0
prm = hmm.Params(seg_sd, baf_sd, lrr_offset=offset)
e = hmm.emissions(states, x_seg, np.full(len(b), np.nan) if haploid else b, pfb[idx], prm)
sw = hmm.switch_probs(p)
path = hmm.viterbi(e, sw, prior)
post = hmm.forward_backward(e, sw, prior)
evs = extract(chrom, path, states, p)
for ev in evs:
annotate_stats(ev, p, x_raw, b, gt, post, states, m_sp, ctx["gamma"], haploid)
evs = post_filter(evs, [x_raw[ev.i0 : ev.i1] for ev in evs], th, counts)
for s_, e_ in _runs(path):
st = states[int(path[s_])]
segments.append({"chrom": chrom, "start": int(p[s_]), "end": int(p[e_ - 1]), "probes": e_ - s_,
"state": st.name, "call": st.call, "cn": st.cn, "haploid": haploid,
"posterior": float(post[s_:e_, int(path[s_])].mean())}) # fmt: skip
base_cn = 1 if haploid else 2
eff = effective_size(p, m_sp)
if whole_chrom_only:
evs = [ev for ev in evs if ev.effective_size >= 0.9 * eff]
ploidy_num += base_cn * eff + sum((ev.cn - base_cn) * ev.effective_size for ev in evs)
ploidy_den += eff
events.extend((ev, haploid) for ev in evs)
kept = apply_thresholds([ev for ev, _ in events], th, counts)
kept = _mask_events(kept, ctx["mask"], counts)
set_filters(kept, th, noisy)
hap_of = {id(ev): h for ev, h in events}
row = {
"sample_id": si.sample_id, "sample_name": si.sample_name, "status": "OK", "logr_dev": logr_dev,
"overall_ploidy": ploidy_num / max(ploidy_den, 1), "gc_correct": bool(gcfit and gcfit.applied),
"gcwf": gcfit.gcwf if gcfit else math.nan, "sex_used": sex, "sex_source": sex_source,
"n_del": sum(e.call == "DEL" for e in kept), "n_dup": sum(e.call == "DUP" for e in kept),
"n_loh": sum(e.call == "LOH" for e in kept), "n_gainloh": sum(e.call == "GAINLOH" for e in kept),
"n_mosaic": sum(e.mosaic for e in kept), "n_filtered": sum(counts.values()),
"qc_flags": ";".join(flags),
} # fmt: skip
per_chr: dict[str, int] = {}
for ev in kept:
if ev.call in ("DEL", "DUP", "LOH"):
per_chr[ev.chrom] = per_chr.get(ev.chrom, 0) + 1
excess = [c for c, n in per_chr.items() if n > 10]
if excess:
row["qc_flags"] = ";".join([*flags, "excess_events:" + ",".join(excess)])
row["lrr_sd_segmentation"] = seg_sd
row["baf_sd"] = baf_sd
return {"row": row, "events": kept, "haploid": hap_of, "segments": segments, "counts": counts}
def _runs(path: npt.NDArray[np.int32]) -> list[tuple[int, int]]:
if len(path) == 0:
return []
br = np.flatnonzero(np.diff(path) != 0) + 1
return list(zip(np.r_[0, br].tolist(), np.r_[br, len(path)].tolist(), strict=True))
def _mask_events(events: list[Any], mask: dict[str, list[tuple[int, int]]], counts: dict[str, int]) -> list[Any]:
"""CYTO-017: trim to unmasked sequence; drop events ≥ 50 % masked."""
out = []
for ev in events:
covered = 0
for a, b in mask.get(ev.chrom, []):
lo, hi = max(a, ev.start), min(b, ev.end)
if lo <= hi:
covered += hi - lo + 1
if a <= ev.start <= b:
ev.start = b + 1
ev.flags.append("centromere_trimmed")
if a <= ev.end <= b:
ev.end = a - 1
ev.flags.append("centromere_trimmed")
if covered >= 0.5 * (ev.end - ev.start + 1 + covered) or ev.end < ev.start:
counts["masked"] = counts.get("masked", 0) + 1
continue
out.append(ev)
return out
# ------------------------------------------------------------------------------------------------ VCF (CYTO-028..031)
def _gt(ev: Any, haploid: bool, mode: str) -> str:
if mode == "compat":
return "1/1"
if haploid:
return "1"
if ev.mosaic:
return "0/1"
if ev.call in ("LOH", "GAINLOH"):
return "1/1"
if ev.call == "DEL":
return "1/1" if ev.cn == 0 else "0/1"
return "./1" if ev.cn >= 4 else "0/1"
def _fmt(v: float | None, nd: int) -> str:
return "." if v is None or not math.isfinite(v) else f"{v:.{nd}f}"
def vcf_text(
sample_name: str,
events: list[Any],
haploid: dict[int, bool],
contigs: list[tuple[str, int]],
header: dict[str, str],
opts: CnCallOptions,
ref_base: Any,
) -> tuple[str, list[tuple[str, int, int, str]]]:
"""Header and records; also (chrom, beg0, end, line) for indexing."""
th = opts.thresholds
extra = {
"smoothing": opts.smoothing,
"max_mosaic_fraction": th.max_mosaic_fraction,
"min_mosaic_fraction": f"{th.min_mosaic_fraction:.2f}",
"filter_qual": th.filter_qual,
"size_basis": "effective",
"cn_cap": opts.cn_cap,
"gt_mode": opts.gt_mode,
"svlen_mode": opts.svlen_mode,
}
h = [
"##fileformat=VCFv4.3",
f"##source=SimurgArray {__version__} cn",
f"##reference=file://{header['reference']}",
f"##simurg_genome_build={header['build']}",
f"##simurg_manifest={header['manifest']}",
f"##simurg_cyto_model={header['model']}",
"##simurg_thresholds=" + th.as_header(extra),
f"##simurg_sex_used={header['sex']};source={header['sex_source']}",
f"##OverallPloidy={float(header['ploidy']):.3f}",
f"##GCCorrect={'true' if header['gc'] == 'True' else 'false'}",
*(f"##contig=<ID={c},length={n}>" for c, n in contigs),
'##ALT=<ID=DEL,Description="Copy number loss">',
'##ALT=<ID=DUP,Description="Copy number gain">',
'##ALT=<ID=LOH,Description="Absence/loss of heterozygosity (AOH/LOH/ROH)">',
'##FILTER=<ID=PASS,Description="All filters passed">',
f'##FILTER=<ID=Q{th.filter_qual},Description="Quality below {th.filter_qual}">',
'##FILTER=<ID=SampleQuality,Description="Sample flagged as potentially low quality: LRR SD above 0.2">',
f'##FILTER=<ID=LowFraction,Description="Mosaic event with estimated mosaic fraction below '
f'{th.min_mosaic_fraction:.2f}">',
'##INFO=<ID=END,Number=1,Type=Integer,Description="Last base of the event (1-based, inclusive)">',
'##INFO=<ID=SVTYPE,Number=1,Type=String,Description="DEL, DUP or LOH">',
'##INFO=<ID=SVLEN,Number=1,Type=Integer,Description="Event length in bp; negative for deletions">',
'##INFO=<ID=EFFLEN,Number=1,Type=Integer,Description="Effective length in bp (gaps above 150x median probe '
'spacing replaced by the median spacing)">',
'##INFO=<ID=PROBE,Number=1,Type=Integer,Description="Number of probes in the event">',
'##INFO=<ID=MOSAIC,Number=0,Type=Flag,Description="Mosaic event">',
'##INFO=<ID=HIGHFRACTION,Number=0,Type=Flag,Description="Mosaic event promoted to constitutional because its '
'mosaic fraction reached the maximum">',
'##INFO=<ID=LOHTYPE,Number=1,Type=String,Description="AOH (constitutional, copy-neutral or gain), CNLOH '
'(mosaic copy-neutral), GAINLOH (mosaic copy-gain)">',
'##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype (see SimurgArray docs, CYTO-031)">',
f'##FORMAT=<ID=CN,Number=1,Type=Integer,Description="Copy number; the maximum value {opts.cn_cap} means '
f'{opts.cn_cap} or more">',
'##FORMAT=<ID=NR,Number=1,Type=Float,Description="Aggregated normalized intensity (median 2^LRR; about CN/2)">',
'##FORMAT=<ID=LRD,Number=1,Type=Float,Description="Standard deviation of LRR within the event">',
'##FORMAT=<ID=MF,Number=1,Type=Float,Description="Estimated mosaic fraction; missing if not mosaic or not '
'estimable">',
f"#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t{sample_name}",
]
order = {c: i for i, (c, _) in enumerate(contigs)}
recs = []
for ev in sorted(events, key=lambda e: (order.get(e.chrom, 999), e.start, e.end)):
s_, e_ = ev.start, ev.end
pos = s_ - 1 if s_ > 1 else 1
ref = ref_base(ev.chrom, pos)
length = e_ - s_ + 1
svlen = -length if ev.svtype == "DEL" and opts.svlen_mode == "signed" else length
info = [f"END={e_}", f"SVTYPE={ev.svtype}", f"SVLEN={svlen}", f"EFFLEN={ev.effective_size}",
f"PROBE={ev.probes}"] # fmt: skip
if ev.mosaic:
info.append("MOSAIC")
if ev.high_fraction:
info.append("HIGHFRACTION")
lt = {"LOH": "AOH", "GAINLOH": "AOH", "MLOH": "CNLOH"}.get(ev.call)
if ev.call == "GAINLOH" and ev.mosaic:
lt = "GAINLOH"
if lt:
info.append(f"LOHTYPE={lt}")
cn = min(ev.cn, opts.cn_cap)
mf = ev.mosaic_fraction if ev.mosaic or ev.high_fraction else None
line = (f"{ev.chrom}\t{pos}\t{ev.id_prefix}:{ev.chrom}:{s_}-{e_}\t{ref}\t<{ev.svtype}>\t{int(ev.quality)}\t"
f"{';'.join(ev.filters)}\t{';'.join(info)}\tGT:CN:NR:LRD:MF\t"
f"{_gt(ev, haploid.get(id(ev), False), opts.gt_mode)}:{cn}:{_fmt(ev.nr, 4)}:{_fmt(ev.lrd, 4)}:"
f"{_fmt(mf, 3)}") # fmt: skip
recs.append((ev.chrom, pos - 1, e_, line))
return "\n".join(h) + "\n", recs
def write_vcf(path: Path, header: str, recs: list[tuple[str, int, int, str]], bgzip: bool) -> None:
from simurg.core.atomic import atomic_open
from simurg.formats.bgzf import BgzfWriter, TabixBuilder, write_tbi
tbx = TabixBuilder()
with atomic_open(path, "wb") as raw:
if bgzip:
w = BgzfWriter(raw) # type: ignore[arg-type]
w.write_record(header.encode())
for chrom, beg, end, line in recs:
vs, ve = w.write_record((line + "\n").encode())
tbx.add(chrom, beg, end, vs, ve)
w.close()
else:
raw.write((header + "".join(r[3] + "\n" for r in recs)).encode()) # type: ignore[arg-type]
if bgzip:
write_tbi(Path(str(path) + ".tbi"), tbx)
# ------------------------------------------------------------------------------------------------ batch driver
_W: dict[str, Any] = {}
def _init_worker(path: str) -> None:
import pickle
with open(path, "rb") as fh:
_setup(pickle.load(fh))
def _setup(state: dict[str, Any]) -> None:
_W.update(state)
fasta = state["opts"].fasta
if fasta is not None:
genome = Genome(fasta)
_W["genome"] = genome
_W["cmap"] = ContigMap([e.name for e in genome.entries], "auto")
def _work(si: SampleInput) -> dict[str, Any]:
ctx, opts = _W["ctx"], _W["opts"]
try:
res = call_sample(si, ctx, opts)
except Exception as e: # CYTO-040: one sample never stops the batch
code = e.code if isinstance(e, SimurgError) else "E_CN_FAILED"
return {"row": {"sample_id": si.sample_id, "sample_name": si.sample_name, "status": "FAILED",
"qc_flags": f"{code}: {e}"}, "events": [], "error": code} # fmt: skip
row = res["row"]
genome = _W.get("genome")
def ref_base(chrom: str, pos: int) -> str:
if genome is None:
return "N"
contig = _W["cmap"].get(chrom)
return genome.fetch(contig, pos - 1, pos) or "N" if contig else "N"
header = {**_W["vcf_header"], "sex": row["sex_used"], "sex_source": row["sex_source"],
"ploidy": str(row["overall_ploidy"]), "gc": str(row["gc_correct"])} # fmt: skip
htxt, recs = vcf_text(si.sample_name, res["events"], res["haploid"], ctx["contigs"], header, opts, ref_base)
out = opts.out / f"{si.sample_id}.cyto.cnv.vcf{'.gz' if opts.bgzip else ''}"
write_vcf(out, htxt, recs, opts.bgzip)
seg = pa.Table.from_pylist(res["segments"]) if res["segments"] else pa.table({"chrom": pa.array([], pa.string())})
pq.write_table(seg, opts.out / f"{si.sample_id}.cn_segments.parquet", compression="zstd")
evs = [
{"id": f"{ev.id_prefix}:{ev.chrom}:{ev.start}-{ev.end}", "chrom": ev.chrom, "start": ev.start, "end": ev.end,
"call_type": ev.svtype, "call": ev.call, "loh_type": ev.loh_type, "mosaic": ev.mosaic,
"mosaic_fraction": ev.mosaic_fraction, "high_fraction": ev.high_fraction, "copy_number": ev.cn,
"quality": round(ev.quality, 1), "filters": ev.filters, "size": ev.size, "effective_size": ev.effective_size,
"probe_count": ev.probes, "percent_het": ev.percent_het, "lrr_median": ev.lrr_median, "lrr_dev": ev.lrd,
"baf_dev": ev.baf_dev, "nr": ev.nr, "flags": ev.flags}
for ev in res["events"]
] # fmt: skip
return {"row": row, "events": evs, "counts": res["counts"]}
def _read_summary(path: Path | None) -> dict[str, dict[str, str]]:
if path is None or not path.exists():
return {}
lines = path.read_text(encoding="utf-8").splitlines()
head = lines[0].split("\t")
return {r[0]: dict(zip(head, r, strict=False)) for r in (ln.split("\t") for ln in lines[1:] if ln)}
def run_cn_call(opts: CnCallOptions, rep: Reporter) -> int:
"""``simurg cn call`` (CLI-028)."""
import multiprocessing as mp
import os
import pickle
import tempfile
from concurrent.futures import ProcessPoolExecutor
from simurg.core.atomic import atomic_write_text, check_outputs_absent
from simurg.core.provenance import RunManifest
from simurg.formats.discovery import discover_gtcs
from simurg.pipeline.qc import write_tsv
rm = RunManifest(command="cn call", argv=opts.argv)
rep.run_id = rm.run_id
if opts.gtcs is None:
raise SimurgError("E_NO_INPUT", "give --gtcs (folder of GTC files)")
gtcs = discover_gtcs(opts.gtcs)
summary_path = opts.summary or (opts.gtcs / "sample_summary.tsv")
summary = _read_summary(summary_path)
ext = ".cyto.cnv.vcf.gz" if opts.bgzip else ".cyto.cnv.vcf"
check_outputs_absent(
[opts.out / f"{k}{ext}" for k in gtcs] + [opts.out / "cn_sample_summary.tsv"], force=opts.force
)
th = opts.thresholds
for k in list(th.probes):
if th.probes[k] < 10:
rep.warn("W_CN_PROBE_FLOOR", f"--min-{k}-probes {th.probes[k]} raised to 10")
th.probes[k] = 10
man = read_bpm(opts.bpm)
model = read_model(opts.model, man)
build = str(model.header.get("genome_build") or "GRCh38")
chroms = np.array(["X" if c == "XY" else (c[3:] if c.lower().startswith("chr") else c) for c in man.chroms])
pos = man.map_infos.astype(np.int64)
by_chrom = {}
for c in CALL_CHROMS:
sel = np.flatnonzero((chroms == c) & (pos > 0))
by_chrom[c] = sel[np.argsort(pos[sel], kind="stable")]
auto = np.isin(chroms, CALL_CHROMS[:22]) & (pos > 0)
genome = None
cmap = None
lengths: dict[str, int] = {}
if opts.fasta is not None:
genome = Genome(opts.fasta)
cmap = ContigMap([e.name for e in genome.entries], "auto")
for c in CALL_CHROMS:
contig = cmap.get(c)
if contig:
lengths[c] = genome.contig_length(contig)
else:
rep.warn("W_CN_NO_FASTA", "no --fasta: VCF REF padding bases are written as N")
contigs = [(c, lengths.get(c, int(pos[by_chrom[c]].max()) if len(by_chrom[c]) else 0)) for c in CALL_CHROMS
if len(by_chrom[c])] # fmt: skip
ctx = {"chroms": chroms, "pos": pos, "model": model, "auto": auto, "by_chrom": by_chrom, "build": build,
"mask": read_mask(opts.mask_bed, build), "gamma": 0.5, "contigs": contigs} # fmt: skip
model_sha = hashlib.sha256(opts.model.read_bytes()).hexdigest()
man_sha = hashlib.sha256(opts.bpm.read_bytes()).hexdigest()
schema = model.header.get("schema_version")
vcf_header = {
"reference": str(opts.fasta.resolve()) if opts.fasta else "none",
"build": build,
"manifest": f"{man.manifest_name};sha256={man_sha}",
"model": f"{opts.model.name};sha256={model_sha};schema={schema}",
}
samples = []
for k, path in gtcs.items():
r = summary.get(k, {})
sex = r.get("sex_est", "U") or "U"
src = "summary" if sex in ("M", "F") else "none"
if sex not in ("M", "F") and r.get("sex_reported") in ("M", "F"):
sex, src = r["sex_reported"], "sheet"
try:
ld = float(r.get("logr_dev", "nan"))
except ValueError:
ld = math.nan
samples.append(SampleInput(k, r.get("sample_name", k) or k, str(path), sex, ld, src))
opts.out.mkdir(parents=True, exist_ok=True)
state = {"ctx": ctx, "opts": opts, "vcf_header": vcf_header}
workers = max(1, min(opts.threads or (os.cpu_count() or 1), len(samples)))
rep.info(f"CN calling of {len(samples)} sample(s) with {workers} worker(s)")
if workers == 1:
_setup(state)
try:
results = [_work(s) for s in samples]
finally:
_W.clear()
else:
fd, sp = tempfile.mkstemp(prefix=".simurg-workers-", suffix=".pkl", dir=opts.out)
try:
with os.fdopen(fd, "wb") as fh:
pickle.dump(state, fh, protocol=5)
with ProcessPoolExecutor(workers, mp_context=mp.get_context("spawn"), initializer=_init_worker,
initargs=(sp,)) as ex: # fmt: skip
results = list(ex.map(_work, samples))
finally:
Path(sp).unlink(missing_ok=True)
cols = ["sample_id", "sample_name", "status", "logr_dev", "overall_ploidy", "gc_correct", "gcwf", "sex_used",
"sex_source", "n_del", "n_dup", "n_loh", "n_gainloh", "n_mosaic", "n_filtered", "qc_flags"] # fmt: skip
rows = []
for s, res in zip(samples, results, strict=True):
row = {**res["row"], "sample_folder": str(Path(s.gtc).parent)}
if row["status"] != "OK":
rep.error(res.get("error", "E_CN_FAILED"), f"{s.sample_id}: {row['qc_flags']}", sample_id=s.sample_id)
elif row["sex_source"] == "resolved":
rep.info(f"Sample {s.sample_id} sex updated from U to {row['sex_used']}", "I_CN_SEX_RESOLVED")
rows.append(row)
write_tsv(opts.out / "cn_sample_summary.tsv", cols, rows)
th_dict = {f"min_{k}_probes": th.probes[k] for k in th.probes} | {
f"min_{k}_size_bp": th.size_bp[k] for k in th.size_bp
}
sidecar = {
"schema": "simurg.cn_call/1", "software": f"SimurgArray {__version__}", "run_id": rm.run_id,
"genome_build": build, "model": vcf_header["model"], "manifest": vcf_header["manifest"],
"summary": str(summary_path) if summary else None,
"thresholds": th_dict | {"max_mosaic_fraction": th.max_mosaic_fraction,
"min_mosaic_fraction": th.min_mosaic_fraction, "filter_qual": th.filter_qual,
"smoothing": opts.smoothing, "cn_cap": opts.cn_cap, "size_basis": "effective"},
"samples": [{**r["row"], "events": r["events"]} for r in results],
} # fmt: skip
atomic_write_text(opts.out / "cn_call_manifest.json", json.dumps(sidecar, indent=1, default=_json_default))
ok = sum(r["row"]["status"] == "OK" for r in results)
rm.summary = {"samples": len(results), "ok": ok}
rm.status = "completed"
rm.write(opts.out / "run_manifest.json")
rep.info(f"done: {ok}/{len(results)} samples; {sum(len(r['events']) for r in results)} events")
if ok == len(results):
return ExitCode.OK
return ExitCode.PARTIAL if ok else ExitCode.ALL_FAILED
def _json_default(v: Any) -> Any:
if isinstance(v, np.generic):
return v.item()
if isinstance(v, float) and not math.isfinite(v):
return None
raise TypeError(type(v))