P4 (WIP): GenCall-equivalent calling, BAF/LRR, sex, GTC v5, simurg genotype
- genotype/clusters.py: EGT aligned to manifest order (float32 mean R) - genotype/gencall.py: flair, six-case likelihood, priors/posteriors, fuzzy sub-scores and score map, vectorised with the reference's float/double evaluation order and libm transcendentals - genotype/baf_lrr.py, sex.py (rules v1/v2), sample.py (IDAT -> Gtc) - pipeline/inputs.py shared by ingest and genotype - pipeline/genotype.py + CLI: GTC per sample, sample_summary.tsv/json (OUT-060..063), run manifest - GTCs from IDATs are byte-identical to the oracle for 36 GSA samples x 2 normalization versions (NaN sign canonicalised to the x86 pattern) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
This commit is contained in:
1 parent
0cd7eb8aa0
commit
febdd121ef
12 files changed
+1270
-53
No files matched your search
@@ -12,6 +12,7 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
|
|||||||
| `E_DUP_GTC` | error | Several GTC files resolve to the same sample | IN-030 |
|
| `E_DUP_GTC` | error | Several GTC files resolve to the same sample | IN-030 |
|
||||||
| `E_DUP_IDAT` | error | Several IDAT pairs resolve to the same sample | IN-022 |
|
| `E_DUP_IDAT` | error | Several IDAT pairs resolve to the same sample | IN-022 |
|
||||||
| `E_EGT_LOAD` | error | Cluster (EGT) file cannot be loaded | IN-044 |
|
| `E_EGT_LOAD` | error | Cluster (EGT) file cannot be loaded | IN-044 |
|
||||||
|
| `E_EGT_MANIFEST_MISMATCH` | error | Cluster file lacks loci of the manifest | IN-043 |
|
||||||
| `E_FILE_NOT_FOUND` | error | Input file or folder does not exist | CLI-016 |
|
| `E_FILE_NOT_FOUND` | error | Input file or folder does not exist | CLI-016 |
|
||||||
| `E_FILE_UNREADABLE` | error | Input file cannot be read | CLI-016 |
|
| `E_FILE_UNREADABLE` | error | Input file cannot be read | CLI-016 |
|
||||||
| `E_GTC_CORRUPT` | warning | GTC file failed validation; sample skipped | IN-032 |
|
| `E_GTC_CORRUPT` | warning | GTC file failed validation; sample skipped | IN-032 |
|
||||||
|
|||||||
+63
-1
@@ -1,6 +1,6 @@
|
|||||||
"""``simurg`` command-line interface (spec §2, plan §14).
|
"""``simurg`` command-line interface (spec §2, plan §14).
|
||||||
|
|
||||||
Available: ``version``, ``inspect`` (P1), ``ingest`` (P2), ``normalize`` (P3).
|
Available: ``version``, ``inspect`` (P1), ``ingest`` (P2), ``normalize`` (P3), ``genotype`` (P4).
|
||||||
Later phases add the other commands of CLI-001.
|
Later phases add the other commands of CLI-001.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -274,5 +274,67 @@ def normalize_cmd(
|
|||||||
run_guarded(rep, lambda: run_normalize(opts, rep))
|
run_guarded(rep, lambda: run_normalize(opts, rep))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------------------------------------
|
||||||
|
@app.command("genotype")
|
||||||
|
def genotype_cmd(
|
||||||
|
bpm: Annotated[Path, typer.Option("--bpm", help="BPM manifest (required).")],
|
||||||
|
egt: Annotated[Path, typer.Option("--egt", help="EGT cluster file (required).")],
|
||||||
|
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
|
||||||
|
idats: Annotated[Path | None, typer.Option("--idats", help="Folder searched recursively for IDAT pairs.")] = None,
|
||||||
|
sheet: Annotated[Path | None, typer.Option("--sheet", help="Sample sheet (CSV).")] = None,
|
||||||
|
csv: Annotated[Path | None, typer.Option("--csv", help="CSV manifest, checked against the BPM.")] = None,
|
||||||
|
cutoff: Annotated[float, typer.Option("--cutoff", help="GenCall score below which a call is a no-call.")] = 0.15,
|
||||||
|
norm_version: Annotated[str, typer.Option("--norm-version", help="1.2.0 (current) or 1.1.2")] = "1.2.0",
|
||||||
|
sex_version: Annotated[int, typer.Option("--sex-version", min=1, max=2, help="Sex estimate rule (1 or 2).")] = 2,
|
||||||
|
gtc_version: Annotated[int, typer.Option("--gtc-version", help="GTC format version (3 or 5).")] = 5,
|
||||||
|
autocall_date: Annotated[
|
||||||
|
str | None, typer.Option("--autocall-date", help="Fixed value for GTC field 201 (reproducible output).")
|
||||||
|
] = None,
|
||||||
|
threads: Annotated[int, typer.Option("--threads", min=0, max=1024, help="Worker processes (0 = auto).")] = 0,
|
||||||
|
force: Annotated[bool, typer.Option("--force", help="Replace existing outputs.")] = False,
|
||||||
|
layout: Annotated[str, typer.Option("--layout", help="nested|flat")] = "nested",
|
||||||
|
on_duplicate_idat: Annotated[str, typer.Option("--on-duplicate-idat", help="error|first|newest")] = "error",
|
||||||
|
log_json: Annotated[bool, typer.Option("--log-json", "--json-log", help="JSON Lines log on stderr.")] = False,
|
||||||
|
debug: Annotated[bool, typer.Option("--debug", help="Debug logging with tracebacks.")] = False,
|
||||||
|
quiet: Annotated[bool, typer.Option("--quiet", "--no-progress", help="Suppress info messages.")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""IDAT pairs → GTC files and a sample summary (GenCall-equivalent calling; phase P4)."""
|
||||||
|
from simurg.pipeline.genotype import GenotypeOptions, run_genotype
|
||||||
|
|
||||||
|
if layout not in ("nested", "flat"):
|
||||||
|
raise typer.BadParameter("--layout must be nested or flat")
|
||||||
|
if gtc_version not in (3, 5):
|
||||||
|
raise typer.BadParameter("--gtc-version must be 3 or 5")
|
||||||
|
if norm_version not in ("1.2.0", "1.1.2"):
|
||||||
|
raise typer.BadParameter("--norm-version must be 1.2.0 or 1.1.2")
|
||||||
|
rep = Reporter(
|
||||||
|
command="genotype",
|
||||||
|
level="debug" if debug else "info",
|
||||||
|
json_stderr=log_json,
|
||||||
|
debug=debug,
|
||||||
|
quiet=quiet,
|
||||||
|
log_file=out / "logs" / "genotype.log",
|
||||||
|
)
|
||||||
|
opts = GenotypeOptions(
|
||||||
|
out=out,
|
||||||
|
bpm=bpm,
|
||||||
|
egt=egt,
|
||||||
|
idats=idats,
|
||||||
|
sheet=sheet,
|
||||||
|
csv=csv,
|
||||||
|
threads=threads,
|
||||||
|
force=force,
|
||||||
|
layout=layout,
|
||||||
|
on_duplicate_idat=on_duplicate_idat,
|
||||||
|
cutoff=cutoff,
|
||||||
|
norm_version=norm_version,
|
||||||
|
sex_version=sex_version,
|
||||||
|
gtc_version=gtc_version,
|
||||||
|
autocall_date=autocall_date,
|
||||||
|
argv=sys.argv[1:],
|
||||||
|
)
|
||||||
|
run_guarded(rep, lambda: run_genotype(opts, rep))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
app()
|
app()
|
||||||
@@ -85,6 +85,7 @@ CATALOGUE: dict[str, MessageDef] = dict(
|
|||||||
_m("E_BPM_FORMAT", "error", "BPM manifest is malformed or unsupported", "IN-040"),
|
_m("E_BPM_FORMAT", "error", "BPM manifest is malformed or unsupported", "IN-040"),
|
||||||
_m("E_CSV_MANIFEST_FORMAT", "error", "CSV manifest is malformed", "IN-041"),
|
_m("E_CSV_MANIFEST_FORMAT", "error", "CSV manifest is malformed", "IN-041"),
|
||||||
_m("E_BPM_CSV_MISMATCH", "error", "BPM and CSV manifests describe different loci", "IN-042"),
|
_m("E_BPM_CSV_MISMATCH", "error", "BPM and CSV manifests describe different loci", "IN-042"),
|
||||||
|
_m("E_EGT_MANIFEST_MISMATCH", "error", "Cluster file lacks loci of the manifest", "IN-043"),
|
||||||
_m("E_EGT_LOAD", "error", "Cluster (EGT) file cannot be loaded", "IN-044"),
|
_m("E_EGT_LOAD", "error", "Cluster (EGT) file cannot be loaded", "IN-044"),
|
||||||
_m("W_EGT_NAME", "warning", "Cluster file name is not portable", "IN-045"),
|
_m("W_EGT_NAME", "warning", "Cluster file name is not portable", "IN-045"),
|
||||||
_m("W_MANIFEST_NO_REFSTRAND", "warning", "Manifest lacks RefStrand annotation", "IN-040"),
|
_m("W_MANIFEST_NO_REFSTRAND", "warning", "Manifest lacks RefStrand annotation", "IN-040"),
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""B-allele frequency, log R ratio and LogR deviation — plan §7.6 (Peiffer et al. 2006).
|
||||||
|
|
||||||
|
Exact mode follows gtc2vcf ``get_baf_lrr``/``calculate_baf_lrr`` (MIT): float32 interpolation between the cluster
|
||||||
|
θ/R means, ``log2`` in double of a float32 ratio, NaN where θ is undefined, BAF clamped to [0, 1] with NaN kept.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from simurg.genotype.clusters import ClusterArrays
|
||||||
|
from simurg.genotype.gencall import _libm
|
||||||
|
|
||||||
|
F32 = np.float32
|
||||||
|
F32Arr = npt.NDArray[np.float32]
|
||||||
|
NEG_NAN = np.frombuffer(np.uint32(0xFFC00000).tobytes(), dtype=np.float32)[0] # C's -NAN
|
||||||
|
|
||||||
|
|
||||||
|
def baf_lrr(
|
||||||
|
theta: F32Arr, r: F32Arr, raw_zero: npt.NDArray[np.bool_], c: ClusterArrays, intensity_only: npt.NDArray[np.bool_]
|
||||||
|
) -> tuple[F32Arr, F32Arr]:
|
||||||
|
t = np.asarray(theta, np.float32)
|
||||||
|
rr = np.asarray(r, np.float32)
|
||||||
|
at, bt, ct = c.theta_mean[:, 0], c.theta_mean[:, 1], c.theta_mean[:, 2]
|
||||||
|
ar, br, cr = c.r_mean[:, 0], c.r_mean[:, 1], c.r_mean[:, 2]
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
||||||
|
eq = t == bt
|
||||||
|
lo = t < bt
|
||||||
|
hi = t > bt
|
||||||
|
r_lo = (ar + ((t - at) * (ar - br)) / (at - bt)).astype(np.float32)
|
||||||
|
b_lo = (((t - at) / (bt - at)) * F32(0.5)).astype(np.float32)
|
||||||
|
r_hi = (br + ((t - bt) * (cr - br)) / (ct - bt)).astype(np.float32)
|
||||||
|
b_hi = (F32(0.5) + ((t - bt) / (ct - bt)) * F32(0.5)).astype(np.float32)
|
||||||
|
r_ref = np.where(eq, br, np.where(lo, r_lo, np.where(hi, r_hi, np.nan))).astype(np.float32)
|
||||||
|
baf = np.where(eq, F32(0.5), np.where(lo, b_lo, np.where(hi, b_hi, NEG_NAN))).astype(np.float32)
|
||||||
|
denom = np.where(intensity_only, c.mean_r, r_ref).astype(np.float32)
|
||||||
|
ratio = (rr / denom).astype(np.float32).astype(np.float64)
|
||||||
|
lrr = _libm(math.log2, ratio).astype(np.float32)
|
||||||
|
lrr = np.where(rr != F32(0.0), lrr, -np.finfo(np.float32).max).astype(np.float32)
|
||||||
|
undefined = ~(eq | lo | hi)
|
||||||
|
lrr[undefined] = NEG_NAN
|
||||||
|
baf = np.where(baf < 0.0, F32(0.0), np.where(baf > 1.0, F32(1.0), baf)).astype(np.float32)
|
||||||
|
skip = raw_zero | ~c.present
|
||||||
|
baf[skip] = NEG_NAN
|
||||||
|
lrr[skip] = NEG_NAN
|
||||||
|
# canonical NaN bit pattern (0xFFC00000), as written by the reference on every platform
|
||||||
|
baf[np.isnan(baf)] = NEG_NAN
|
||||||
|
lrr[np.isnan(lrr)] = NEG_NAN
|
||||||
|
return baf, lrr
|
||||||
|
|
||||||
|
|
||||||
|
def chrom_core(chrom: str) -> str:
|
||||||
|
return chrom[3:] if chrom[:3].upper() == "CHR" else chrom
|
||||||
|
|
||||||
|
|
||||||
|
def autosomal_mask(chroms: list[str]) -> npt.NDArray[np.bool_]:
|
||||||
|
"""LogR-dev locus set: first character (after an optional 'chr') not X, Y or M (so '0' and alt contigs count)."""
|
||||||
|
return np.array([(chrom_core(ch)[:1].upper() not in ("X", "Y", "M")) for ch in chroms], dtype=np.bool_)
|
||||||
|
|
||||||
|
|
||||||
|
def logr_dev(
|
||||||
|
lrr: F32Arr, autosomal: npt.NDArray[np.bool_], intensity_only: npt.NDArray[np.bool_], present: npt.NDArray[np.bool_]
|
||||||
|
) -> np.float32:
|
||||||
|
"""Population SD of LRR over autosomal, non-intensity-only, finite values (sequential double sums)."""
|
||||||
|
sel = autosomal & ~intensity_only & present & np.isfinite(lrr)
|
||||||
|
v = lrr[sel].astype(np.float64)
|
||||||
|
count = len(v)
|
||||||
|
if count == 0:
|
||||||
|
return F32(np.nan)
|
||||||
|
s = float(np.cumsum(v)[-1])
|
||||||
|
s2 = float(np.cumsum(v * v)[-1])
|
||||||
|
mean = s / count
|
||||||
|
var = s2 / count - mean * mean
|
||||||
|
return F32(math.sqrt(var)) if var >= 0 else F32(np.nan)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Cluster-file records aligned to manifest (GTC) locus order — input of genotype calling (plan §7.5–§7.6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from simurg.core.errors import SimurgError
|
||||||
|
from simurg.formats.bpm import Manifest
|
||||||
|
from simurg.formats.egt import ClusterFile
|
||||||
|
|
||||||
|
F32Arr = npt.NDArray[np.float32]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClusterArrays:
|
||||||
|
"""Per-locus cluster parameters; (n, 3) arrays are ordered (AA, AB, BB)."""
|
||||||
|
|
||||||
|
present: npt.NDArray[np.bool_] # locus has a record in the cluster file
|
||||||
|
n: npt.NDArray[np.int32]
|
||||||
|
theta_mean: F32Arr
|
||||||
|
theta_dev: F32Arr
|
||||||
|
r_mean: F32Arr
|
||||||
|
r_dev: F32Arr # floored at 0.1 as the caller does (plan §7.5 step 2)
|
||||||
|
intensity_threshold: F32Arr # NaN where the cluster file has none
|
||||||
|
gentrain: F32Arr
|
||||||
|
mean_r: F32Arr # count-weighted mean R in float32 (expected R of intensity-only loci)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_loci(self) -> int:
|
||||||
|
return len(self.present)
|
||||||
|
|
||||||
|
|
||||||
|
def align_clusters(egt: ClusterFile, manifest: Manifest, *, allow_missing: bool = False) -> ClusterArrays:
|
||||||
|
"""Join cluster records to the manifest by locus name (IN-043)."""
|
||||||
|
idx = egt.name_index()
|
||||||
|
rows = np.array([idx.get(nm, -1) for nm in manifest.names], dtype=np.int64)
|
||||||
|
present = rows >= 0
|
||||||
|
if not present.all() and not allow_missing:
|
||||||
|
missing = [manifest.names[i] for i in np.flatnonzero(~present)[:10]]
|
||||||
|
raise SimurgError(
|
||||||
|
"E_EGT_MANIFEST_MISMATCH",
|
||||||
|
f"{int((~present).sum())} manifest loci are missing from cluster file {egt.path}, "
|
||||||
|
f"e.g. {', '.join(missing)}",
|
||||||
|
hint="use the cluster file built for this manifest",
|
||||||
|
)
|
||||||
|
safe = np.where(present, rows, 0)
|
||||||
|
|
||||||
|
def take(a: npt.NDArray, fill: float = np.nan) -> npt.NDArray: # type: ignore[type-arg]
|
||||||
|
out = a[safe].copy()
|
||||||
|
out[~present] = fill
|
||||||
|
return out
|
||||||
|
|
||||||
|
n = take(egt.n, 0).astype(np.int32)
|
||||||
|
r_mean = take(egt.r_mean).astype(np.float32)
|
||||||
|
r_dev = take(egt.r_dev).astype(np.float32)
|
||||||
|
r_dev = np.where(r_dev < np.float32(0.1), np.float32(0.1), r_dev).astype(np.float32)
|
||||||
|
# float32 weighted mean exactly as the reference: ((Naa*Raa + Nab*Rab) + Nbb*Rbb) / (Naa+Nab+Nbb)
|
||||||
|
nf = n.astype(np.float32)
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
num = (nf[:, 0] * r_mean[:, 0] + nf[:, 1] * r_mean[:, 1]) + nf[:, 2] * r_mean[:, 2]
|
||||||
|
mean_r = (num / (n.sum(axis=1)).astype(np.float32)).astype(np.float32)
|
||||||
|
return ClusterArrays(
|
||||||
|
present=present,
|
||||||
|
n=n,
|
||||||
|
theta_mean=take(egt.theta_mean).astype(np.float32),
|
||||||
|
theta_dev=take(egt.theta_dev).astype(np.float32),
|
||||||
|
r_mean=r_mean,
|
||||||
|
r_dev=r_dev,
|
||||||
|
intensity_threshold=take(egt.intensity_threshold).astype(np.float32),
|
||||||
|
gentrain=take(egt.gentrain_score).astype(np.float32),
|
||||||
|
mean_r=mean_r,
|
||||||
|
)
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""GenCall-equivalent genotype calling from a cluster file — plan §7.5.
|
||||||
|
|
||||||
|
Vectorised port of gtc2vcf ``gen_std_flair``/``modilik``/``compute_score_call_prelim``/``gencall_score_map``/
|
||||||
|
``make_calls`` (MIT, © Giulio Genovese), which reproduce the vendor's GenTrain 6.x calling (concepts: Kermani
|
||||||
|
patents, expired; Illumina GenCall technical note). Every expression keeps the reference's float/double types and
|
||||||
|
evaluation order; scalar transcendental functions come from libm through ``math`` where NumPy's SIMD kernels could
|
||||||
|
round differently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from simurg.genotype.clusters import ClusterArrays
|
||||||
|
|
||||||
|
F32 = np.float32
|
||||||
|
F32Arr = npt.NDArray[np.float32]
|
||||||
|
F64Arr = npt.NDArray[np.float64]
|
||||||
|
|
||||||
|
M_2_SQRTPI = 1.12837916709551257390
|
||||||
|
M_SQRT1_2 = 0.707106781186547524401
|
||||||
|
PEAK = 0.5 * M_2_SQRTPI * M_SQRT1_2 # constant sub-expression of the normal pdf (evaluated left to right)
|
||||||
|
TINY_D = float(np.finfo(np.float64).tiny) * float(np.finfo(np.float64).eps) # DBL_MIN * DBL_EPSILON
|
||||||
|
DEFAULT_CUTOFF = 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def _c_call(fn, v: float) -> float: # type: ignore[no-untyped-def]
|
||||||
|
"""Call a ``math`` function with C semantics: domain/range errors give NaN/±inf instead of raising."""
|
||||||
|
if v != v:
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
return float(fn(v))
|
||||||
|
except ValueError:
|
||||||
|
if fn is math.log10 and v == 0.0:
|
||||||
|
return -math.inf
|
||||||
|
return math.nan
|
||||||
|
except OverflowError:
|
||||||
|
return math.inf
|
||||||
|
|
||||||
|
|
||||||
|
def _libm(fn, x: F64Arr) -> F64Arr: # type: ignore[no-untyped-def]
|
||||||
|
"""Element-wise libm call (bit-identical to C)."""
|
||||||
|
return np.fromiter((_c_call(fn, v) for v in x.tolist()), dtype=np.float64, count=len(x))
|
||||||
|
|
||||||
|
|
||||||
|
def _pow(e: float): # type: ignore[no-untyped-def]
|
||||||
|
def f(v: float) -> float:
|
||||||
|
return math.pow(v, e)
|
||||||
|
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def smf(x: F32Arr, a: F32Arr | np.float32, b: F32Arr | np.float32) -> F32Arr:
|
||||||
|
"""MATLAB ``smf`` in float32, including the reference's ``a >= b`` rule."""
|
||||||
|
x = np.asarray(x, np.float32)
|
||||||
|
a = np.broadcast_to(np.asarray(a, np.float32), x.shape)
|
||||||
|
b = np.broadcast_to(np.asarray(b, np.float32), x.shape)
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
||||||
|
mid = ((a + b) / F32(2.0)).astype(np.float32)
|
||||||
|
u = ((x - a) / (b - a)).astype(np.float32)
|
||||||
|
v = ((x - b) / (b - a)).astype(np.float32)
|
||||||
|
rise = (F32(2.0) * (u * u)).astype(np.float32)
|
||||||
|
top = (F32(1.0) - F32(2.0) * (v * v)).astype(np.float32)
|
||||||
|
out = np.where(
|
||||||
|
x <= a, F32(0), np.where((a < x) & (x <= mid), rise, np.where((mid < x) & (x <= b), top, F32(1)))
|
||||||
|
).astype(np.float32)
|
||||||
|
degenerate = a >= b
|
||||||
|
out = np.where(degenerate, np.where(x >= mid, F32(1), F32(0)), out).astype(np.float32)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def zmf(x: F32Arr, a: F32Arr | np.float32, b: F32Arr | np.float32) -> F32Arr:
|
||||||
|
"""MATLAB ``zmf`` in float32 (callers guarantee a < b)."""
|
||||||
|
x = np.asarray(x, np.float32)
|
||||||
|
a = np.broadcast_to(np.asarray(a, np.float32), x.shape)
|
||||||
|
b = np.broadcast_to(np.asarray(b, np.float32), x.shape)
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
||||||
|
mid = ((a + b) / F32(2.0)).astype(np.float32)
|
||||||
|
u = ((x - a) / (b - a)).astype(np.float32)
|
||||||
|
v = ((x - b) / (b - a)).astype(np.float32)
|
||||||
|
fall = (F32(1.0) - F32(2.0) * (u * u)).astype(np.float32)
|
||||||
|
tail = (F32(2.0) * (v * v)).astype(np.float32)
|
||||||
|
return np.where(
|
||||||
|
x <= a, F32(1), np.where((a < x) & (x <= mid), fall, np.where((mid < x) & (x <= b), tail, F32(0)))
|
||||||
|
).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Pdf:
|
||||||
|
"""Normal pdf pieces for one cluster and one dimension: the full density and the flat peak."""
|
||||||
|
|
||||||
|
x: F32Arr
|
||||||
|
mu: F32Arr
|
||||||
|
full: F64Arr
|
||||||
|
peak: F64Arr
|
||||||
|
bad: npt.NDArray[np.bool_]
|
||||||
|
|
||||||
|
def get(self, side: str) -> F64Arr:
|
||||||
|
if side == "left":
|
||||||
|
out = np.where(self.x < self.mu, self.peak, self.full)
|
||||||
|
elif side == "right":
|
||||||
|
out = np.where(self.x > self.mu, self.peak, self.full)
|
||||||
|
else:
|
||||||
|
out = self.full
|
||||||
|
return np.where(self.bad, np.nan, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf(x: F32Arr, mu: F32Arr, sigma: F32Arr) -> _Pdf:
|
||||||
|
"""``exp(-0.5*((x-mu)/sigma)^2) * 0.5 * M_2_SQRTPI * M_SQRT1_2 / sigma`` with float32 z, double arithmetic."""
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
||||||
|
z = ((x - mu) / sigma).astype(np.float32).astype(np.float64)
|
||||||
|
e = _libm(math.exp, -0.5 * (z * z))
|
||||||
|
sd = sigma.astype(np.float64)
|
||||||
|
full = e * 0.5 * M_2_SQRTPI * M_SQRT1_2 / sd
|
||||||
|
peak = PEAK / sd
|
||||||
|
return _Pdf(x, mu, full, peak, sigma <= F32(0))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Calls:
|
||||||
|
genotypes: npt.NDArray[np.uint8] # 0 NC, 1 AA, 2 AB, 3 BB (after the cutoff)
|
||||||
|
scores: F32Arr # GenCall scores (0 where not computable)
|
||||||
|
num_calls: int
|
||||||
|
num_intensity_only: int
|
||||||
|
|
||||||
|
|
||||||
|
def call_genotypes(
|
||||||
|
theta: F32Arr,
|
||||||
|
r: F32Arr,
|
||||||
|
raw_zero: npt.NDArray[np.bool_],
|
||||||
|
c: ClusterArrays,
|
||||||
|
intensity_only: npt.NDArray[np.bool_],
|
||||||
|
cutoff: float = DEFAULT_CUTOFF,
|
||||||
|
) -> Calls:
|
||||||
|
"""Bayesian calls and GenCall scores for one sample (plan §7.5)."""
|
||||||
|
n = len(theta)
|
||||||
|
t = np.asarray(theta, np.float32)
|
||||||
|
rr = np.asarray(r, np.float32)
|
||||||
|
tm, td, rm, rd = c.theta_mean, c.theta_dev, c.r_mean, c.r_dev
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
||||||
|
# --- gen_std_flair ------------------------------------------------------------------------
|
||||||
|
z1 = (F32(0.5) * (tm[:, 1] - tm[:, 0]) / (td[:, 1] + td[:, 0])).astype(np.float32)
|
||||||
|
z2 = (F32(0.5) * (tm[:, 2] - tm[:, 1]) / (td[:, 2] + td[:, 1])).astype(np.float32)
|
||||||
|
mz = np.fmin(z1, z2)
|
||||||
|
third = F32(1.0) / F32(3.0)
|
||||||
|
alpha = np.fmax(third * mz, F32(1.0))
|
||||||
|
beta = np.fmin(third * mz, F32(1.0))
|
||||||
|
eta = (alpha * beta).astype(np.float32)
|
||||||
|
ftd = (td * eta[:, None]).astype(np.float32)
|
||||||
|
ftd = np.where(ftd < F32(0.02), F32(0.02), ftd).astype(np.float32)
|
||||||
|
med = np.fmax(np.fmin(td[:, 0], td[:, 1]), np.fmin(np.fmax(td[:, 0], td[:, 1]), td[:, 2]))
|
||||||
|
min_r = np.where(F32(0.2) < med, med, F32(0.2)).astype(np.float32)
|
||||||
|
min_rk = (rm / F32(7.0)).astype(np.float32)
|
||||||
|
min_rk = np.where(min_rk < min_r[:, None], min_r[:, None], min_rk).astype(np.float32)
|
||||||
|
frd = np.where(rd < min_rk, min_rk, rd).astype(np.float32)
|
||||||
|
|
||||||
|
# --- modilik: six ordered cases, later ones overwrite -----------------------------------------
|
||||||
|
d = [
|
||||||
|
100.0 * np.abs(t - tm[:, k]).astype(np.float64) + np.abs(rr - rm[:, k]).astype(np.float64) for k in range(3)
|
||||||
|
]
|
||||||
|
daa, dab, dbb = d
|
||||||
|
ok_t = ~np.isnan(t)
|
||||||
|
|
||||||
|
pt = [_pdf(t, tm[:, k], ftd[:, k]) for k in range(3)]
|
||||||
|
pr = [_pdf(rr, rm[:, k], frd[:, k]) for k in range(3)]
|
||||||
|
|
||||||
|
def pdf2(k: int, st: str, sr: str) -> F64Arr:
|
||||||
|
return pt[k].get(st) * pr[k].get(sr)
|
||||||
|
|
||||||
|
L = np.zeros((n, 3), dtype=np.float64)
|
||||||
|
covered = np.zeros(n, dtype=np.bool_)
|
||||||
|
cases = [
|
||||||
|
((daa <= dbb) & (dab <= dbb) & (rm[:, 0] <= rm[:, 1]), ((0, "left", "left"), (1, "right", "right"))),
|
||||||
|
((daa <= dab) & (dbb <= dab) & (rm[:, 0] <= rm[:, 2]), ((0, "left", "left"), (2, "right", "right"))),
|
||||||
|
((dab <= daa) & (dbb <= daa) & (rm[:, 1] <= rm[:, 2]), ((1, "left", "left"), (2, "right", "right"))),
|
||||||
|
((daa <= dbb) & (dab <= dbb) & (rm[:, 0] > rm[:, 1]), ((0, "left", "right"), (1, "right", "left"))),
|
||||||
|
((dab <= daa) & (dbb <= daa) & (rm[:, 1] > rm[:, 2]), ((1, "left", "right"), (2, "right", "left"))),
|
||||||
|
((daa <= dab) & (dbb <= dab) & (rm[:, 0] > rm[:, 2]), ((0, "left", "right"), (2, "right", "left"))),
|
||||||
|
]
|
||||||
|
for cond, assigns in cases:
|
||||||
|
m = cond & ok_t
|
||||||
|
if m.any():
|
||||||
|
for k, st, sr in assigns:
|
||||||
|
L[m, k] = pdf2(k, st, sr)[m]
|
||||||
|
covered |= m
|
||||||
|
nc = ~covered
|
||||||
|
if nc.any():
|
||||||
|
for k in range(3):
|
||||||
|
L[nc, k] = pdf2(k, "full", "full")[nc]
|
||||||
|
L = np.where(np.isnan(L), 0.0, L)
|
||||||
|
L[~ok_t] = np.nan
|
||||||
|
|
||||||
|
# --- priors and posteriors -----------------------------------------------------------------
|
||||||
|
ntot = c.n.sum(axis=1)
|
||||||
|
P = (
|
||||||
|
(c.n.astype(np.float32) / ntot[:, None].astype(np.float32)).astype(np.float32).astype(np.float64) + 1.0
|
||||||
|
) / 4.0
|
||||||
|
LP = L * P
|
||||||
|
ev = (LP[:, 0] + LP[:, 1]) + LP[:, 2]
|
||||||
|
ap = LP / ev[:, None]
|
||||||
|
aa, ab, bb = ap[:, 0], ap[:, 1], ap[:, 2]
|
||||||
|
iap = np.where(
|
||||||
|
(aa >= ab) & (aa >= bb), 1, np.where((ab >= aa) & (ab >= bb), 2, np.where((bb >= aa) & (bb >= ab), 3, 0))
|
||||||
|
).astype(np.uint8)
|
||||||
|
mx = np.where(aa > ab, aa, ab)
|
||||||
|
scnd = np.where(aa > ab, ab, aa)
|
||||||
|
b_gt_mx = bb > mx
|
||||||
|
scnd = np.where(b_gt_mx, mx, np.where(bb > scnd, bb, scnd))
|
||||||
|
mx = np.where(b_gt_mx, bb, mx)
|
||||||
|
ap_lod = _libm(math.log10, mx / (TINY_D + scnd))
|
||||||
|
score_ap = smf(ap_lod.astype(np.float32), F32(0.0), F32(2.0)).astype(np.float32)
|
||||||
|
|
||||||
|
# --- sub-scores --------------------------------------------------------------------------
|
||||||
|
score_r1 = smf(rr, F32(0.0), F32(0.1))
|
||||||
|
nclus = (c.n > 0).sum(axis=1)
|
||||||
|
misclust = np.where(
|
||||||
|
(nclus == 1) & (c.n[:, 1] == 0), F32(0.7), np.where(nclus != 3, F32(0.95), F32(1.0))
|
||||||
|
).astype(np.float32)
|
||||||
|
g = np.clip(iap.astype(np.int64) - 1, 0, 2)
|
||||||
|
gm_t, gd_t = tm[np.arange(n), g], ftd[np.arange(n), g]
|
||||||
|
gm_r, gd_r = rm[np.arange(n), g], frd[np.arange(n), g]
|
||||||
|
st_aa = zmf(t, gm_t + F32(2.0) * gd_t, gm_t + F32(6.0) * gd_t)
|
||||||
|
st_ab = zmf(np.abs((t - gm_t) / gd_t).astype(np.float32), F32(2.0), F32(6.0))
|
||||||
|
st_bb = smf(t, gm_t - F32(6.0) * gd_t, gm_t - F32(2.0) * gd_t)
|
||||||
|
score_t = np.where(iap == 1, st_aa, np.where(iap == 2, st_ab, np.where(iap == 3, st_bb, F32(1.0))))
|
||||||
|
r2 = smf(rr, F32(0.0), (gm_r / F32(10.0)).astype(np.float32))
|
||||||
|
r3 = smf(rr, gm_r - F32(6.0) * gd_r, gm_r - F32(2.0) * gd_r)
|
||||||
|
r4 = zmf(rr, gm_r + F32(6.0) * gd_r, gm_r + F32(12.0) * gd_r)
|
||||||
|
called = iap > 0
|
||||||
|
score_r = np.where(called, ((score_r1 * r2) * r3) * r4, F32(0.0)).astype(np.float32)
|
||||||
|
score_t = score_t.astype(np.float32)
|
||||||
|
score_n = np.ones(n, dtype=np.float32)
|
||||||
|
nan_gt = np.isnan(c.gentrain)
|
||||||
|
score_t[nan_gt] = np.nan
|
||||||
|
score_r[nan_gt] = np.nan
|
||||||
|
score_t[~ok_t] = np.nan
|
||||||
|
score_r[~ok_t] = np.nan
|
||||||
|
score_n[~ok_t] = np.nan
|
||||||
|
prelim = (((((score_ap * c.gentrain) * score_t) * score_r) * score_n) * misclust).astype(np.float32)
|
||||||
|
|
||||||
|
# intensity threshold: below it the call is NC with a NaN score
|
||||||
|
below = rr < c.intensity_threshold
|
||||||
|
prelim[below] = np.nan
|
||||||
|
iap[below] = 0
|
||||||
|
|
||||||
|
# --- gencall_score_map -------------------------------------------------------------------
|
||||||
|
x = prelim.astype(np.float64)
|
||||||
|
q = (x - 1.08) / 0.504
|
||||||
|
gbell = 1.0 / (1.0 + _libm(_pow(1.71), q * q))
|
||||||
|
score = (_libm(_pow(0.35), x) * gbell).astype(np.float32)
|
||||||
|
score = np.where(np.isnan(score), F32(0.0), score).astype(np.float32)
|
||||||
|
|
||||||
|
# loci without intensity or without a cluster record
|
||||||
|
no_call = raw_zero | ~c.present
|
||||||
|
score[no_call] = F32(0.0)
|
||||||
|
iap[no_call] = 0
|
||||||
|
geno = np.where(score < F32(cutoff), 0, iap).astype(np.uint8)
|
||||||
|
geno[intensity_only] = 0
|
||||||
|
return Calls(
|
||||||
|
genotypes=geno, scores=score, num_calls=int((geno > 0).sum()), num_intensity_only=int(intensity_only.sum())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def call_rate(calls: Calls, num_snps: int) -> np.float32:
|
||||||
|
tiny = F32(np.finfo(np.float32).tiny) * F32(np.finfo(np.float32).eps)
|
||||||
|
return F32(F32(calls.num_calls) / ((F32(num_snps) - F32(calls.num_intensity_only)) + tiny))
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""One sample from IDAT pair to a complete GTC record — plan §7.1–§7.8, spec OUT-001/OUT-002.
|
||||||
|
|
||||||
|
Order of operations follows the reference AutoCall pipeline (gtc2vcf ``gtc_init``, MIT): raw X/Y → normalization →
|
||||||
|
calls → sex → BAF/LRR/LogR dev → GC10/GC50 → intensity percentiles → controls → sample metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from simurg import PRODUCT_NAME, __version__
|
||||||
|
from simurg.formats.bpm import Manifest
|
||||||
|
from simurg.formats.gtc import XFORM_DTYPE, Gtc, ScannerData
|
||||||
|
from simurg.formats.idat import Idat
|
||||||
|
from simurg.genotype.baf_lrr import autosomal_mask, baf_lrr, logr_dev
|
||||||
|
from simurg.genotype.clusters import ClusterArrays
|
||||||
|
from simurg.genotype.gencall import DEFAULT_CUTOFF, call_genotypes, call_rate
|
||||||
|
from simurg.genotype.sex import SexParams, chromosome_classes, estimate_sex
|
||||||
|
from simurg.signal.normalization.apply import normalize, polar
|
||||||
|
from simurg.signal.normalization.fit import XForm, fit_transforms
|
||||||
|
from simurg.signal.normalization.numerics import percentile
|
||||||
|
from simurg.signal.raw_xy import LocusMapCache, assemble_raw_xy
|
||||||
|
|
||||||
|
F32 = np.float32
|
||||||
|
_COMPLEMENT = bytes.maketrans(b"ACGTDI", b"TGCADI")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GenotypeParams:
|
||||||
|
cutoff: float = DEFAULT_CUTOFF
|
||||||
|
norm_version: str = "1.2.0"
|
||||||
|
sex: SexParams = field(default_factory=SexParams)
|
||||||
|
gtc_version: int = 5
|
||||||
|
autocall_version: str = f"{PRODUCT_NAME} {__version__}"
|
||||||
|
autocall_date: str | None = None # None: run start time (invariant format, OUT-001)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ManifestContext:
|
||||||
|
"""Per-manifest data reused for every sample (computed once)."""
|
||||||
|
|
||||||
|
manifest: Manifest
|
||||||
|
clusters: ClusterArrays
|
||||||
|
norm_bins: npt.NDArray[np.uint8]
|
||||||
|
lookup: npt.NDArray[np.int32]
|
||||||
|
allele_a: npt.NDArray[np.uint8]
|
||||||
|
allele_b: npt.NDArray[np.uint8]
|
||||||
|
autosomal: npt.NDArray[np.bool_]
|
||||||
|
sex_classes: tuple[npt.NDArray[np.bool_], ...]
|
||||||
|
cache: LocusMapCache
|
||||||
|
cluster_file_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def top_alleles(manifest: Manifest) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]:
|
||||||
|
"""TOP-strand A/B base per locus (gtc2vcf ``get_base_call``): SNP column on TOP designs, complemented otherwise.
|
||||||
|
|
||||||
|
Unknown bases complement to NUL, as in the reference's lookup table.
|
||||||
|
"""
|
||||||
|
a = np.zeros(manifest.num_loci, dtype=np.uint8)
|
||||||
|
b = np.zeros(manifest.num_loci, dtype=np.uint8)
|
||||||
|
for i, (snp, strand) in enumerate(zip(manifest.snps, manifest.ilmn_strands, strict=True)):
|
||||||
|
raw = snp.encode("latin-1", errors="replace")
|
||||||
|
ca = raw[1:2] or b"\x00"
|
||||||
|
cb = raw[3:4] or b"\x00"
|
||||||
|
if strand[:1].upper() != "T":
|
||||||
|
ca = ca.translate(_COMPLEMENT) if ca in b"ACGTDI" else b"\x00"
|
||||||
|
cb = cb.translate(_COMPLEMENT) if cb in b"ACGTDI" else b"\x00"
|
||||||
|
a[i], b[i] = ca[0], cb[0]
|
||||||
|
return a, b
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_context(manifest: Manifest, clusters: ClusterArrays, cluster_file_name: str = "") -> ManifestContext:
|
||||||
|
a, b = top_alleles(manifest)
|
||||||
|
return ManifestContext(
|
||||||
|
manifest=manifest,
|
||||||
|
clusters=clusters,
|
||||||
|
norm_bins=manifest.norm_bin_ids(),
|
||||||
|
lookup=manifest.normalization_lookups(),
|
||||||
|
allele_a=a,
|
||||||
|
allele_b=b,
|
||||||
|
autosomal=autosomal_mask(manifest.chroms),
|
||||||
|
sex_classes=chromosome_classes(manifest.chroms),
|
||||||
|
cache=LocusMapCache(manifest),
|
||||||
|
cluster_file_name=cluster_file_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def base_calls(
|
||||||
|
genotypes: npt.NDArray[np.uint8], a: npt.NDArray[np.uint8], b: npt.NDArray[np.uint8]
|
||||||
|
) -> npt.NDArray[np.uint8]:
|
||||||
|
dash = np.uint8(ord("-"))
|
||||||
|
first = np.where(genotypes == 1, a, np.where(genotypes == 2, a, np.where(genotypes == 3, b, dash)))
|
||||||
|
second = np.where(genotypes == 1, a, np.where(genotypes == 2, b, np.where(genotypes == 3, b, dash)))
|
||||||
|
return np.stack([first, second], axis=1).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def invariant_datetime(t: _dt.datetime) -> str:
|
||||||
|
"""``M/d/yyyy h:mm:ss tt`` with ASCII digits (OUT-001), independent of the process locale."""
|
||||||
|
hour = t.hour % 12 or 12
|
||||||
|
return f"{t.month}/{t.day}/{t.year} {hour}:{t.minute:02d}:{t.second:02d} {'AM' if t.hour < 12 else 'PM'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _param(pars: str, key: str) -> str:
|
||||||
|
m = re.search(rf"(?:^|\|){re.escape(key)}=([^|]*)", pars)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def xforms_to_records(xforms: list[XForm]) -> npt.NDArray[np.void]:
|
||||||
|
rec = np.zeros(len(xforms), dtype=XFORM_DTYPE)
|
||||||
|
for i, x in enumerate(xforms):
|
||||||
|
rec[i] = (
|
||||||
|
x.version,
|
||||||
|
x.offset_x,
|
||||||
|
x.offset_y,
|
||||||
|
x.scale_x,
|
||||||
|
x.scale_y,
|
||||||
|
x.shear,
|
||||||
|
x.theta,
|
||||||
|
(x.cvx, x.cvy, x.nn12, x.rr12, x.taa, x.tbb),
|
||||||
|
)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def genotype_sample(
|
||||||
|
grn: Idat, red: Idat, ctx: ManifestContext, params: GenotypeParams, run_started: _dt.datetime | None = None
|
||||||
|
) -> Gtc:
|
||||||
|
m, c = ctx.manifest, ctx.clusters
|
||||||
|
raw = assemble_raw_xy(grn, red, m, cache=ctx.cache)
|
||||||
|
xforms = fit_transforms(raw.raw_x, raw.raw_y, ctx.norm_bins, params.norm_version)
|
||||||
|
nx, ny = normalize(raw.raw_x, raw.raw_y, ctx.lookup, xforms)
|
||||||
|
theta, r = polar(nx, ny)
|
||||||
|
raw_zero = (raw.raw_x == 0) & (raw.raw_y == 0)
|
||||||
|
theta_c = np.where(raw_zero, np.float32(np.nan), theta).astype(np.float32)
|
||||||
|
r_c = np.where(raw_zero, np.float32(np.nan), r).astype(np.float32)
|
||||||
|
|
||||||
|
calls = call_genotypes(theta_c, r_c, raw_zero, c, m.intensity_only, params.cutoff)
|
||||||
|
sex = estimate_sex(calls.genotypes, r, c.gentrain, c.present, ctx.sex_classes, params.sex)
|
||||||
|
baf, lrr = baf_lrr(theta_c, r_c, raw_zero, c, m.intensity_only)
|
||||||
|
ldev = logr_dev(lrr, ctx.autosomal, m.intensity_only, c.present)
|
||||||
|
|
||||||
|
gs = np.sort(calls.scores[(calls.scores > F32(params.cutoff)) & ~m.intensity_only])
|
||||||
|
pct_x = [int(percentile(np.sort(raw.raw_x.astype(np.float32)), p)) for p in (5, 50, 95)]
|
||||||
|
pct_y = [int(percentile(np.sort(raw.raw_y.astype(np.float32)), p)) for p in (5, 50, 95)]
|
||||||
|
|
||||||
|
scan = red.scan_entry()
|
||||||
|
pars = scan.block_pars if scan else ""
|
||||||
|
started = run_started or _dt.datetime.now()
|
||||||
|
g = Gtc(
|
||||||
|
version=params.gtc_version,
|
||||||
|
num_snps=m.num_loci,
|
||||||
|
ploidy=2,
|
||||||
|
ploidy_type=1,
|
||||||
|
sample_name=red.sample_id or Path(grn.path).name.split("_Grn.idat")[0].split("_grn.idat")[0],
|
||||||
|
sample_plate=red.plate,
|
||||||
|
sample_well=red.well,
|
||||||
|
cluster_file=ctx.cluster_file_name,
|
||||||
|
snp_manifest=Path(m.path).name,
|
||||||
|
imaging_date=scan.run_time if scan else "",
|
||||||
|
autocall_date=params.autocall_date if params.autocall_date is not None else invariant_datetime(started),
|
||||||
|
autocall_version=params.autocall_version,
|
||||||
|
normalization_transforms=xforms_to_records(xforms),
|
||||||
|
controls_x=raw.controls_x,
|
||||||
|
controls_y=raw.controls_y,
|
||||||
|
raw_x=raw.raw_x,
|
||||||
|
raw_y=raw.raw_y,
|
||||||
|
genotypes=calls.genotypes,
|
||||||
|
base_calls=base_calls(calls.genotypes, ctx.allele_a, ctx.allele_b),
|
||||||
|
genotype_scores=calls.scores,
|
||||||
|
scanner_data=ScannerData(
|
||||||
|
name=_param(pars, "sherlockID"),
|
||||||
|
pmt_green=int(_param(pars, "PMTGainCY3") or 0).to_bytes(4, "little", signed=True),
|
||||||
|
pmt_red=int(_param(pars, "PMTGainCY5") or 0).to_bytes(4, "little", signed=True),
|
||||||
|
version=scan.code_version if scan else "",
|
||||||
|
user=_param(pars, "Username"),
|
||||||
|
),
|
||||||
|
call_rate=float(call_rate(calls, m.num_loci)),
|
||||||
|
gender=sex.encode(),
|
||||||
|
logr_dev=float(ldev),
|
||||||
|
gc10=float(percentile(gs, 10)),
|
||||||
|
b_allele_freqs=baf,
|
||||||
|
logr_ratios=lrr,
|
||||||
|
percentiles_x=np.array(pct_x, dtype=np.uint16),
|
||||||
|
percentiles_y=np.array(pct_y, dtype=np.uint16),
|
||||||
|
slide_identifier=red.barcode,
|
||||||
|
)
|
||||||
|
g.sample_data["p50gc"] = percentile(gs, 50)
|
||||||
|
g.sample_data["num_calls"] = calls.num_calls
|
||||||
|
g.sample_data["num_no_calls"] = m.num_loci - calls.num_intensity_only - calls.num_calls
|
||||||
|
g.sample_data["num_intensity_only"] = calls.num_intensity_only
|
||||||
|
return g
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Sex estimate from X heterozygosity and X/Y intensities — plan §7.8.
|
||||||
|
|
||||||
|
Port of gtc2vcf ``estimate_gender`` (MIT), which reproduces the vendor's two generations:
|
||||||
|
|
||||||
|
* version 1 (AutoConvert 1.x): male unless the X heterozygosity rate among called X loci exceeds 0.1;
|
||||||
|
* version 2 (AutoConvert 2.0, IAAP, current CLI): median normalized R of Y loci > 0.3 → M; otherwise median R of
|
||||||
|
X loci > 0.9 → F (or F when there are too few X loci), else U. Falls back to version 1 when there are too few Y or
|
||||||
|
autosomal loci.
|
||||||
|
|
||||||
|
Only loci whose cluster record has a non-zero GenTrain score count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from simurg.genotype.baf_lrr import chrom_core
|
||||||
|
from simurg.signal.normalization.numerics import median_sorted
|
||||||
|
|
||||||
|
F32 = np.float32
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SexParams:
|
||||||
|
version: int = 2
|
||||||
|
min_loci: int = 100
|
||||||
|
min_x_loci: int = 20
|
||||||
|
min_y_loci: int = 20
|
||||||
|
call_rate_threshold: float = 0.0
|
||||||
|
y_threshold: float = 0.3
|
||||||
|
x_threshold: float = 0.9
|
||||||
|
x_het_rate_threshold: float = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def chromosome_classes(chroms: list[str]) -> tuple[npt.NDArray[np.bool_], ...]:
|
||||||
|
core = [chrom_core(c) for c in chroms]
|
||||||
|
is_x = np.array([c == "X" for c in core])
|
||||||
|
is_y = np.array([c == "Y" for c in core])
|
||||||
|
is_auto = np.array([c not in ("X", "Y", "XY", "MT") for c in core])
|
||||||
|
return is_x, is_y, is_auto
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_sex(
|
||||||
|
genotypes: npt.NDArray[np.uint8],
|
||||||
|
r_all: npt.NDArray[np.float32],
|
||||||
|
gentrain: npt.NDArray[np.float32],
|
||||||
|
present: npt.NDArray[np.bool_],
|
||||||
|
classes: tuple[npt.NDArray[np.bool_], ...],
|
||||||
|
p: SexParams | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""``r_all``: normalized R of every locus, computed even where raw intensities are zero (as the reference)."""
|
||||||
|
p = p or SexParams()
|
||||||
|
is_x, is_y, is_auto = classes
|
||||||
|
|
||||||
|
def thr(v: float) -> float: # thresholds are C floats promoted to double in the comparisons
|
||||||
|
return float(F32(v))
|
||||||
|
|
||||||
|
use = present & (gentrain != F32(0.0)) # NaN != 0 is True, as in C
|
||||||
|
x = use & is_x
|
||||||
|
y = use & is_y
|
||||||
|
auto = use & is_auto & ~is_x & ~is_y
|
||||||
|
x_count, y_count, auto_count = int(x.sum()), int(y.sum()), int(auto.sum())
|
||||||
|
called = genotypes > 0
|
||||||
|
x_non_missing = int((x & called).sum())
|
||||||
|
x_hets = int((x & (genotypes == 2)).sum())
|
||||||
|
auto_non_missing = int((auto & called).sum())
|
||||||
|
|
||||||
|
if p.version == 1 or y_count < p.min_y_loci or auto_count < p.min_loci:
|
||||||
|
if x_non_missing > p.min_x_loci:
|
||||||
|
rate = float(F32(F32(x_hets) / F32(x_non_missing)))
|
||||||
|
return "M" if not rate > thr(p.x_het_rate_threshold) else "F"
|
||||||
|
return "U"
|
||||||
|
if auto_count > 0 and auto_non_missing / auto_count > thr(p.call_rate_threshold):
|
||||||
|
ry = r_all[y].astype(np.float32)
|
||||||
|
ry = np.where(np.isfinite(ry), ry, F32(0.0)).astype(np.float32)
|
||||||
|
y_med = median_sorted(np.sort(ry))
|
||||||
|
if float(y_med) > thr(p.y_threshold):
|
||||||
|
return "M"
|
||||||
|
if x_count < p.min_x_loci:
|
||||||
|
return "F"
|
||||||
|
rx = r_all[x].astype(np.float32)
|
||||||
|
rx = np.where(np.isfinite(rx), rx, F32(0.0)).astype(np.float32)
|
||||||
|
x_med = median_sorted(np.sort(rx))
|
||||||
|
return "F" if float(x_med) > thr(p.x_threshold) else "U"
|
||||||
|
return "U"
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""``simurg genotype``: IDAT pairs + BPM + EGT → GTC per sample + ``sample_summary.{tsv,json}`` (spec CLI-021,
|
||||||
|
OUT-001, OUT-060 … OUT-063; phase P4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import multiprocessing as mp
|
||||||
|
import os
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from simurg import __version__
|
||||||
|
from simurg.core.atomic import atomic_write_text, check_outputs_absent
|
||||||
|
from simurg.core.errors import ExitCode, SimurgError
|
||||||
|
from simurg.core.log import Reporter
|
||||||
|
from simurg.core.provenance import RunManifest
|
||||||
|
from simurg.formats.bpm import read_bpm
|
||||||
|
from simurg.formats.egt import read_egt
|
||||||
|
from simurg.formats.gtc import write_gtc
|
||||||
|
from simurg.formats.idat import check_pair, read_idat
|
||||||
|
from simurg.genotype.baf_lrr import chrom_core
|
||||||
|
from simurg.genotype.clusters import align_clusters
|
||||||
|
from simurg.genotype.sample import GenotypeParams, ManifestContext, genotype_sample, manifest_context
|
||||||
|
from simurg.genotype.sex import SexParams
|
||||||
|
from simurg.pipeline.inputs import check_paths, resolve_sample_set, sample_dir, sheet_values
|
||||||
|
from simurg.signal.normalization.apply import normalize, polar
|
||||||
|
from simurg.signal.raw_xy import LocusMapCache, check_coverage
|
||||||
|
|
||||||
|
SUMMARY_SCHEMA = "simurg.sample_summary/1"
|
||||||
|
FIXED_COLUMNS = [
|
||||||
|
"sample_id",
|
||||||
|
"sample_name",
|
||||||
|
"sample_folder",
|
||||||
|
"status",
|
||||||
|
"fail_reason",
|
||||||
|
"autosomal_call_rate",
|
||||||
|
"call_rate",
|
||||||
|
"logr_dev",
|
||||||
|
"sex_est",
|
||||||
|
"sex_reported",
|
||||||
|
"sex_check",
|
||||||
|
"gc10",
|
||||||
|
"gc50",
|
||||||
|
"n_calls",
|
||||||
|
"n_nocalls",
|
||||||
|
"n_intensity_only",
|
||||||
|
"p05_x",
|
||||||
|
"p50_x",
|
||||||
|
"p95_x",
|
||||||
|
"p05_y",
|
||||||
|
"p50_y",
|
||||||
|
"p95_y",
|
||||||
|
"het_rate",
|
||||||
|
"contamination",
|
||||||
|
]
|
||||||
|
QC_COLUMNS = ["functional_status", "control_status", "control_flag_codes", "flags"] # filled from phase P5
|
||||||
|
FLOAT_COLUMNS = {"autosomal_call_rate", "call_rate", "logr_dev", "gc10", "gc50", "het_rate", "contamination"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GenotypeOptions:
|
||||||
|
out: Path
|
||||||
|
bpm: Path
|
||||||
|
egt: Path
|
||||||
|
idats: Path | None = None
|
||||||
|
sheet: Path | None = None
|
||||||
|
csv: Path | None = None
|
||||||
|
threads: int = 0
|
||||||
|
force: bool = False
|
||||||
|
layout: str = "nested"
|
||||||
|
on_duplicate_idat: str = "error"
|
||||||
|
cutoff: float = 0.15
|
||||||
|
norm_version: str = "1.2.0"
|
||||||
|
sex_version: int = 2
|
||||||
|
gtc_version: int = 5
|
||||||
|
autocall_date: str | None = None
|
||||||
|
assay_control_probes: tuple[str, ...] = ("TGA_Ctrl_5716",)
|
||||||
|
argv: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Job:
|
||||||
|
sample_id: str
|
||||||
|
sample_name: str
|
||||||
|
grn: str
|
||||||
|
red: str
|
||||||
|
gtc_path: str
|
||||||
|
|
||||||
|
|
||||||
|
_W: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _init(
|
||||||
|
ctx: ManifestContext,
|
||||||
|
params: GenotypeParams,
|
||||||
|
auto_1_22: np.ndarray[Any, np.dtype[np.bool_]],
|
||||||
|
probe_idx: dict[str, int],
|
||||||
|
started: _dt.datetime,
|
||||||
|
) -> None:
|
||||||
|
_W.update(ctx=ctx, params=params, auto=auto_1_22, probes=probe_idx, started=started)
|
||||||
|
|
||||||
|
|
||||||
|
def _process(job: Job) -> dict[str, Any]:
|
||||||
|
ctx: ManifestContext = _W["ctx"]
|
||||||
|
params: GenotypeParams = _W["params"]
|
||||||
|
row: dict[str, Any] = {
|
||||||
|
"sample_id": job.sample_id,
|
||||||
|
"sample_name": job.sample_name,
|
||||||
|
"status": "OK",
|
||||||
|
"fail_reason": "",
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
grn, red = read_idat(job.grn), read_idat(job.red)
|
||||||
|
check_pair(grn, red)
|
||||||
|
_, notes = check_coverage(grn, ctx.manifest)
|
||||||
|
row["notes"].extend(notes)
|
||||||
|
g = genotype_sample(grn, red, ctx, params, _W["started"])
|
||||||
|
write_gtc(g, Path(job.gtc_path))
|
||||||
|
auto = _W["auto"] & ~ctx.manifest.intensity_only
|
||||||
|
n_auto = int(auto.sum())
|
||||||
|
row.update(
|
||||||
|
autosomal_call_rate=float(np.float32(int((g.genotypes[auto] > 0).sum())) / np.float32(n_auto))
|
||||||
|
if n_auto
|
||||||
|
else math.nan,
|
||||||
|
call_rate=float(g.call_rate),
|
||||||
|
logr_dev=float(g.logr_dev),
|
||||||
|
sex_est=g.gender_char,
|
||||||
|
gc10=float(g.gc10),
|
||||||
|
gc50=float(g.gc50),
|
||||||
|
n_calls=g.num_calls,
|
||||||
|
n_nocalls=g.num_no_calls,
|
||||||
|
n_intensity_only=g.num_intensity_only,
|
||||||
|
p05_x=int(g.percentiles_x[0]),
|
||||||
|
p50_x=int(g.percentiles_x[1]),
|
||||||
|
p95_x=int(g.percentiles_x[2]),
|
||||||
|
p05_y=int(g.percentiles_y[0]),
|
||||||
|
p50_y=int(g.percentiles_y[1]),
|
||||||
|
p95_y=int(g.percentiles_y[2]),
|
||||||
|
)
|
||||||
|
if _W["probes"]:
|
||||||
|
idx = np.array(list(_W["probes"].values()), dtype=np.int64)
|
||||||
|
nx, ny = normalize(g.raw_x[idx], g.raw_y[idx], ctx.lookup[idx], g.normalization_transforms)
|
||||||
|
_, r = polar(nx, ny)
|
||||||
|
for (name, _), val in zip(_W["probes"].items(), r.tolist(), strict=True):
|
||||||
|
row[f"{name}_norm_r"] = float(val)
|
||||||
|
except SimurgError as e:
|
||||||
|
row.update(status="FAILED", fail_reason=f"{e.code}: {e.message}", error_code=e.code)
|
||||||
|
except Exception as e:
|
||||||
|
row.update(status="FAILED", fail_reason=f"E_INTERNAL: {type(e).__name__}: {e}", error_code="E_INTERNAL")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(col: str, v: Any) -> str:
|
||||||
|
if v is None or v == "":
|
||||||
|
return ""
|
||||||
|
if isinstance(v, float) or col in FLOAT_COLUMNS or col.endswith("_norm_r"):
|
||||||
|
f = float(v)
|
||||||
|
return "NaN" if math.isnan(f) else f"{f:.8f}"
|
||||||
|
return str(v).replace("\t", " ").replace("\n", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(col: str, v: Any) -> Any:
|
||||||
|
if v == "" or v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, float):
|
||||||
|
return None if math.isnan(v) else v
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def run_genotype(opts: GenotypeOptions, rep: Reporter) -> int:
|
||||||
|
rm = RunManifest(command="genotype", argv=opts.argv)
|
||||||
|
rep.run_id = rm.run_id
|
||||||
|
for k in (
|
||||||
|
"bpm",
|
||||||
|
"egt",
|
||||||
|
"idats",
|
||||||
|
"sheet",
|
||||||
|
"csv",
|
||||||
|
"threads",
|
||||||
|
"cutoff",
|
||||||
|
"norm_version",
|
||||||
|
"sex_version",
|
||||||
|
"gtc_version",
|
||||||
|
"layout",
|
||||||
|
"on_duplicate_idat",
|
||||||
|
):
|
||||||
|
v = getattr(opts, k)
|
||||||
|
rm.set_option(k, str(v) if v is not None else None, "cli")
|
||||||
|
check_paths(
|
||||||
|
[
|
||||||
|
(opts.bpm, "BPM"),
|
||||||
|
(opts.egt, "cluster file"),
|
||||||
|
(opts.sheet, "sample sheet"),
|
||||||
|
(opts.csv, "CSV manifest"),
|
||||||
|
(opts.idats, "IDAT folder"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if not 0.0 <= opts.cutoff <= 1.0:
|
||||||
|
raise SimurgError("E_CONFIG_KEY", f"--cutoff must be within [0, 1], got {opts.cutoff}")
|
||||||
|
|
||||||
|
sset = resolve_sample_set(opts.idats, opts.sheet, opts.on_duplicate_idat, rep, rm)
|
||||||
|
jobs: list[Job] = []
|
||||||
|
rows: dict[str, dict[str, Any]] = {}
|
||||||
|
for s in sset.samples:
|
||||||
|
folder = ""
|
||||||
|
if s.pair is not None:
|
||||||
|
try:
|
||||||
|
folder = str(s.pair.folder.relative_to(sset.folder))
|
||||||
|
except ValueError:
|
||||||
|
folder = str(s.pair.folder)
|
||||||
|
base = {
|
||||||
|
"sample_id": s.sample_id,
|
||||||
|
"sample_name": s.sample_name,
|
||||||
|
"sample_folder": folder,
|
||||||
|
"sex_reported": getattr(s.sheet_row, "reported_sex", "U") if s.sheet_row else "U",
|
||||||
|
**{f"sheet:{k}": v for k, v in sheet_values(s, sset.sheet).items()},
|
||||||
|
}
|
||||||
|
rows[s.sample_id] = base
|
||||||
|
if s.status != "READY" or s.pair is None or not s.pair.complete:
|
||||||
|
base.update(status="MISSING_INPUT", fail_reason="no complete IDAT pair found")
|
||||||
|
continue
|
||||||
|
stem = sset.stems[s.sample_id]
|
||||||
|
gtc = sample_dir(opts.out, stem, opts.layout) / f"{stem}.gtc"
|
||||||
|
jobs.append(Job(s.sample_id, s.sample_name, str(s.pair.grn), str(s.pair.red), str(gtc)))
|
||||||
|
|
||||||
|
summary_tsv, summary_json = opts.out / "sample_summary.tsv", opts.out / "sample_summary.json"
|
||||||
|
check_outputs_absent([summary_tsv, summary_json] + [Path(j.gtc_path) for j in jobs], force=opts.force)
|
||||||
|
|
||||||
|
rep.info(f"reading manifest {opts.bpm} and cluster file {opts.egt}")
|
||||||
|
manifest = read_bpm(opts.bpm)
|
||||||
|
if opts.csv:
|
||||||
|
from simurg.formats.csv_manifest import compare_manifests, read_csv_manifest
|
||||||
|
|
||||||
|
problems = compare_manifests(manifest, read_csv_manifest(opts.csv, load_sequences=False))
|
||||||
|
if problems:
|
||||||
|
raise SimurgError("E_BPM_CSV_MISMATCH", "BPM and CSV manifests differ:\n " + "\n ".join(problems))
|
||||||
|
egt = read_egt(opts.egt)
|
||||||
|
if (
|
||||||
|
egt.manifest_name
|
||||||
|
and manifest.manifest_name
|
||||||
|
and egt.manifest_name.split(".")[0].lower() not in manifest.manifest_name.lower()
|
||||||
|
):
|
||||||
|
rep.warn(
|
||||||
|
"W_EGT_NAME", f"cluster file was built for {egt.manifest_name!r}, manifest is {manifest.manifest_name!r}"
|
||||||
|
)
|
||||||
|
clusters = align_clusters(egt, manifest)
|
||||||
|
ctx = manifest_context(manifest, clusters, cluster_file_name=Path(opts.egt).name)
|
||||||
|
lite = manifest.lite()
|
||||||
|
ctx = replace(ctx, manifest=lite, cache=LocusMapCache(lite)) # workers get no string columns
|
||||||
|
for role, p in (("bpm", opts.bpm), ("egt", opts.egt), ("csv_manifest", opts.csv), ("sample_sheet", opts.sheet)):
|
||||||
|
if p is not None:
|
||||||
|
rm.add_input(role, p)
|
||||||
|
auto_1_22 = np.array([chrom_core(c) in {str(i) for i in range(1, 23)} for c in manifest.chroms])
|
||||||
|
name_idx = manifest.name_index()
|
||||||
|
probes = {p: name_idx[p] for p in opts.assay_control_probes if p in name_idx}
|
||||||
|
params = GenotypeParams(
|
||||||
|
cutoff=opts.cutoff,
|
||||||
|
norm_version=opts.norm_version,
|
||||||
|
sex=SexParams(version=opts.sex_version),
|
||||||
|
gtc_version=opts.gtc_version,
|
||||||
|
autocall_date=opts.autocall_date,
|
||||||
|
)
|
||||||
|
started = _dt.datetime.now()
|
||||||
|
|
||||||
|
workers = max(1, min(opts.threads or (os.cpu_count() or 1), len(jobs) or 1))
|
||||||
|
rep.info(f"genotyping {len(jobs)} sample(s) with {workers} worker(s)")
|
||||||
|
try:
|
||||||
|
if workers == 1:
|
||||||
|
_init(ctx, params, auto_1_22, probes, started)
|
||||||
|
results = [_process(j) for j in jobs]
|
||||||
|
else:
|
||||||
|
with ProcessPoolExecutor(
|
||||||
|
workers,
|
||||||
|
mp_context=mp.get_context("spawn"),
|
||||||
|
initializer=_init,
|
||||||
|
initargs=(ctx, params, auto_1_22, probes, started),
|
||||||
|
) as ex:
|
||||||
|
results = list(ex.map(_process, jobs))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
rm.status = "interrupted"
|
||||||
|
rm.write(opts.out / "run_manifest.json")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_W.clear()
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
for code, msg in r.pop("notes", []):
|
||||||
|
rep.warn(code, msg, sample_id=r["sample_id"])
|
||||||
|
if r["status"] == "FAILED":
|
||||||
|
rep.error(
|
||||||
|
r.get("error_code", "E_INTERNAL"), f"{r['sample_id']}: {r['fail_reason']}", sample_id=r["sample_id"]
|
||||||
|
)
|
||||||
|
rm.failed_samples.append({"sample_id": r["sample_id"], "code": r.get("error_code", "")})
|
||||||
|
r.update(
|
||||||
|
autosomal_call_rate=0.0, call_rate=0.0, logr_dev=math.nan, gc10=math.nan, gc50=math.nan, sex_est="U"
|
||||||
|
) # OUT-061
|
||||||
|
for probe in probes:
|
||||||
|
r[f"{probe}_norm_r"] = math.nan
|
||||||
|
rows[r["sample_id"]].update(r)
|
||||||
|
|
||||||
|
probe_cols = [f"{p}_norm_r" for p in probes]
|
||||||
|
sheet_cols = [f"sheet:{c}" for c in (sset.sheet.passthrough_columns() if sset.sheet else [])]
|
||||||
|
out_rows = []
|
||||||
|
for s in sset.samples:
|
||||||
|
r = rows[s.sample_id]
|
||||||
|
known = {"M", "F"}
|
||||||
|
est, rep_sex = r.get("sex_est", "U"), r.get("sex_reported", "U")
|
||||||
|
r["sex_check"] = ("match" if est == rep_sex else "mismatch") if est in known and rep_sex in known else "unknown"
|
||||||
|
out_rows.append(r)
|
||||||
|
columns = FIXED_COLUMNS + probe_cols + QC_COLUMNS + sheet_cols
|
||||||
|
header = [c.removeprefix("sheet:") for c in columns]
|
||||||
|
lines = ["\t".join(header)] + ["\t".join(_fmt(c, r.get(c, "")) for c in columns) for r in out_rows]
|
||||||
|
atomic_write_text(summary_tsv, "\n".join(lines) + "\n")
|
||||||
|
doc = {
|
||||||
|
"schema": SUMMARY_SCHEMA,
|
||||||
|
"simurg_version": __version__,
|
||||||
|
"columns": header,
|
||||||
|
"samples": [{h: _json_value(c, r.get(c, "")) for c, h in zip(columns, header, strict=True)} for r in out_rows],
|
||||||
|
}
|
||||||
|
atomic_write_text(summary_json, json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
ok = sum(r.get("status") == "OK" for r in out_rows)
|
||||||
|
rm.summary = {
|
||||||
|
"samples": len(out_rows),
|
||||||
|
"succeeded": ok,
|
||||||
|
"failed_or_missing": len(out_rows) - ok,
|
||||||
|
"warnings": rep.warning_counts(),
|
||||||
|
}
|
||||||
|
rm.add_output(summary_tsv, SUMMARY_SCHEMA)
|
||||||
|
rm.schema_versions["*.gtc"] = f"gtc/{opts.gtc_version}"
|
||||||
|
rm.status = "completed"
|
||||||
|
rm.write(opts.out / "run_manifest.json")
|
||||||
|
rep.info(f"done: {ok}/{len(out_rows)} samples genotyped; summary {summary_tsv}")
|
||||||
|
if ok == len(out_rows):
|
||||||
|
return ExitCode.OK
|
||||||
|
return ExitCode.PARTIAL if ok else ExitCode.ALL_FAILED
|
||||||
@@ -36,9 +36,9 @@ from simurg.core.schemas import (
|
|||||||
write_parquet,
|
write_parquet,
|
||||||
)
|
)
|
||||||
from simurg.formats.bpm import Manifest, read_bpm
|
from simurg.formats.bpm import Manifest, read_bpm
|
||||||
from simurg.formats.discovery import ResolvedSample, discover_idats, resolve_input_folder, resolve_samples
|
from simurg.formats.discovery import ResolvedSample
|
||||||
from simurg.formats.idat import check_pair, parse_idat_filename, read_idat
|
from simurg.formats.idat import check_pair, parse_idat_filename, read_idat
|
||||||
from simurg.formats.samplesheet import SampleSheet, output_stem, parse_samplesheet
|
from simurg.pipeline.inputs import check_paths, resolve_sample_set, sample_dir
|
||||||
from simurg.signal.raw_xy import LocusMapCache, assemble_raw_xy, check_coverage
|
from simurg.signal.raw_xy import LocusMapCache, assemble_raw_xy, check_coverage
|
||||||
|
|
||||||
SUMMARY_COLUMNS = [
|
SUMMARY_COLUMNS = [
|
||||||
@@ -93,7 +93,7 @@ def _init_worker(manifest: Manifest, manifest_sha256: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _sample_paths(out: Path, stem: str, layout: str) -> tuple[Path, Path]:
|
def _sample_paths(out: Path, stem: str, layout: str) -> tuple[Path, Path]:
|
||||||
d = out / "samples" / stem if layout == "nested" else out
|
d = sample_dir(out, stem, layout)
|
||||||
return d / f"{stem}.intensity.parquet", d / f"{stem}.controls.parquet"
|
return d / f"{stem}.intensity.parquet", d / f"{stem}.controls.parquet"
|
||||||
|
|
||||||
|
|
||||||
@@ -202,61 +202,18 @@ def run_ingest(opts: IngestOptions, rep: Reporter) -> int:
|
|||||||
v = getattr(opts, k)
|
v = getattr(opts, k)
|
||||||
rm.set_option(k, str(v) if v is not None else None, "cli")
|
rm.set_option(k, str(v) if v is not None else None, "cli")
|
||||||
|
|
||||||
for p, what in (
|
check_paths(
|
||||||
(opts.bpm, "BPM"),
|
[(opts.bpm, "BPM"), (opts.sheet, "sample sheet"), (opts.csv, "CSV manifest"), (opts.idats, "IDAT folder")]
|
||||||
(opts.sheet, "sample sheet"),
|
)
|
||||||
(opts.csv, "CSV manifest"),
|
sset = resolve_sample_set(opts.idats, opts.sheet, opts.on_duplicate_idat, rep, rm)
|
||||||
(opts.idats, "IDAT folder"),
|
samples = sset.samples
|
||||||
):
|
|
||||||
if p is not None and not Path(p).exists():
|
|
||||||
raise SimurgError("E_FILE_NOT_FOUND", f"{what} not found: {p}", path=str(p))
|
|
||||||
|
|
||||||
# --- inputs (IN-001 … IN-022) --------------------------------------------------------------------
|
|
||||||
sheet: SampleSheet | None = parse_samplesheet(opts.sheet) if opts.sheet else None
|
|
||||||
if sheet:
|
|
||||||
rm.sheet_header = sheet.header
|
|
||||||
for code, msg in sheet.warnings:
|
|
||||||
rep.warn(code, msg)
|
|
||||||
folder, notes = resolve_input_folder(opts.idats, sheet)
|
|
||||||
for code, msg in notes:
|
|
||||||
rep.warn(code, msg) if code.startswith("W_") else rep.info(msg, code)
|
|
||||||
disc = discover_idats(folder, on_duplicate=opts.on_duplicate_idat)
|
|
||||||
for code, msg in disc.warnings:
|
|
||||||
rep.warn(code, msg)
|
|
||||||
samples, notes = resolve_samples(disc, sheet)
|
|
||||||
for code, msg in notes:
|
|
||||||
rep.info(msg, code)
|
|
||||||
if not samples:
|
|
||||||
raise SimurgError("E_NO_INPUT", f"no IDAT pairs found under {folder}")
|
|
||||||
|
|
||||||
# --- names and output paths (IN-010, IN-012) -----------------------------------------------------
|
|
||||||
use_idat_names = sheet is None or not sheet.has_sample_name
|
|
||||||
stems: dict[str, str] = {}
|
|
||||||
jobs: list[SampleJob] = []
|
jobs: list[SampleJob] = []
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for s in samples:
|
for s in samples:
|
||||||
if use_idat_names and s.pair is not None and s.pair.grn is not None:
|
|
||||||
# sheet Sample_Name > IDAT sample name (field 406) > sample_id
|
|
||||||
try:
|
|
||||||
idat_name = read_idat(s.pair.grn, load_arrays=False).sample_id.strip()
|
|
||||||
except SimurgError:
|
|
||||||
idat_name = ""
|
|
||||||
if idat_name:
|
|
||||||
s.sample_name = idat_name
|
|
||||||
stem = output_stem(s.sample_name)
|
|
||||||
if stem in stems and stems[stem] != s.sample_id:
|
|
||||||
if not use_idat_names: # sheet names that sanitise to the same stem
|
|
||||||
raise SimurgError(
|
|
||||||
"E_SHEET_NAME_COLLISION",
|
|
||||||
f"samples {stems[stem]} and {s.sample_id} both map to output name {stem!r}",
|
|
||||||
)
|
|
||||||
rep.info(f"{s.sample_id}: IDAT sample name {s.sample_name!r} is not unique; using the sample ID")
|
|
||||||
s.sample_name = s.sample_id
|
|
||||||
stem = output_stem(s.sample_id)
|
|
||||||
stems[stem] = s.sample_id
|
|
||||||
if s.status != "READY" or s.pair is None or not s.pair.complete:
|
if s.status != "READY" or s.pair is None or not s.pair.complete:
|
||||||
rows.append(_missing_row(s))
|
rows.append(_missing_row(s))
|
||||||
continue
|
continue
|
||||||
|
stem = sset.stems[s.sample_id]
|
||||||
ip, cp = _sample_paths(opts.out, stem, opts.layout)
|
ip, cp = _sample_paths(opts.out, stem, opts.layout)
|
||||||
jobs.append(
|
jobs.append(
|
||||||
SampleJob(s.sample_id, s.sample_name, stem, str(s.pair.grn), str(s.pair.red), str(ip), str(cp), opts.resume)
|
SampleJob(s.sample_id, s.sample_name, stem, str(s.pair.grn), str(s.pair.red), str(ip), str(cp), opts.resume)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared sample-set resolution for IDAT-based commands (spec CLI-006, IN-001 … IN-022, IN-010/IN-012)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from simurg.core.errors import SimurgError
|
||||||
|
from simurg.core.log import Reporter
|
||||||
|
from simurg.core.provenance import RunManifest
|
||||||
|
from simurg.formats.discovery import ResolvedSample, discover_idats, resolve_input_folder, resolve_samples
|
||||||
|
from simurg.formats.idat import read_idat
|
||||||
|
from simurg.formats.samplesheet import SampleSheet, output_stem, parse_samplesheet
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SampleSet:
|
||||||
|
folder: Path
|
||||||
|
sheet: SampleSheet | None
|
||||||
|
samples: list[ResolvedSample]
|
||||||
|
stems: dict[str, str] # sample_id -> output stem
|
||||||
|
|
||||||
|
|
||||||
|
def check_paths(pairs: list[tuple[Path | None, str]]) -> None:
|
||||||
|
for p, what in pairs:
|
||||||
|
if p is not None and not Path(p).exists():
|
||||||
|
raise SimurgError("E_FILE_NOT_FOUND", f"{what} not found: {p}", path=str(p))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sample_set(
|
||||||
|
idats: Path | None, sheet_path: Path | None, on_duplicate_idat: str, rep: Reporter, rm: RunManifest
|
||||||
|
) -> SampleSet:
|
||||||
|
sheet = parse_samplesheet(sheet_path) if sheet_path else None
|
||||||
|
if sheet:
|
||||||
|
rm.sheet_header = sheet.header
|
||||||
|
for code, msg in sheet.warnings:
|
||||||
|
rep.warn(code, msg)
|
||||||
|
folder, notes = resolve_input_folder(idats, sheet)
|
||||||
|
for code, msg in notes:
|
||||||
|
rep.warn(code, msg) if code.startswith("W_") else rep.info(msg, code)
|
||||||
|
disc = discover_idats(folder, on_duplicate=on_duplicate_idat)
|
||||||
|
for code, msg in disc.warnings:
|
||||||
|
rep.warn(code, msg)
|
||||||
|
samples, notes = resolve_samples(disc, sheet)
|
||||||
|
for code, msg in notes:
|
||||||
|
rep.info(msg, code)
|
||||||
|
if not samples:
|
||||||
|
raise SimurgError("E_NO_INPUT", f"no IDAT pairs found under {folder}")
|
||||||
|
|
||||||
|
use_idat_names = sheet is None or not sheet.has_sample_name
|
||||||
|
stems: dict[str, str] = {}
|
||||||
|
taken: dict[str, str] = {}
|
||||||
|
for s in samples:
|
||||||
|
if use_idat_names and s.pair is not None and s.pair.grn is not None:
|
||||||
|
# IN-010: sheet Sample_Name > IDAT sample name (field 406) > sample_id
|
||||||
|
try:
|
||||||
|
idat_name = read_idat(s.pair.grn, load_arrays=False).sample_id.strip()
|
||||||
|
except SimurgError:
|
||||||
|
idat_name = ""
|
||||||
|
if idat_name:
|
||||||
|
s.sample_name = idat_name
|
||||||
|
stem = output_stem(s.sample_name)
|
||||||
|
if stem in taken and taken[stem] != s.sample_id:
|
||||||
|
if not use_idat_names: # IN-012: sheet names that sanitise to the same stem
|
||||||
|
raise SimurgError(
|
||||||
|
"E_SHEET_NAME_COLLISION",
|
||||||
|
f"samples {taken[stem]} and {s.sample_id} both map to output name {stem!r}",
|
||||||
|
)
|
||||||
|
rep.info(f"{s.sample_id}: IDAT sample name {s.sample_name!r} is not unique; using the sample ID")
|
||||||
|
s.sample_name = s.sample_id
|
||||||
|
stem = output_stem(s.sample_id)
|
||||||
|
taken[stem] = s.sample_id
|
||||||
|
stems[s.sample_id] = stem
|
||||||
|
return SampleSet(folder=folder, sheet=sheet, samples=samples, stems=stems)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_dir(out: Path, stem: str, layout: str) -> Path:
|
||||||
|
return out / "samples" / stem if layout == "nested" else out
|
||||||
|
|
||||||
|
|
||||||
|
def sheet_values(s: ResolvedSample, sheet: SampleSheet | None) -> dict[str, Any]:
|
||||||
|
"""Pass-through sheet columns for summaries (IN-013)."""
|
||||||
|
if sheet is None or s.sheet_row is None:
|
||||||
|
return {}
|
||||||
|
row = s.sheet_row
|
||||||
|
return {c: row.values.get(c, "") for c in sheet.passthrough_columns()} # type: ignore[attr-defined]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Phase P4 acceptance on P0 data: GTCs produced from IDATs are byte-identical to oracle GTCs (gtc2vcf built without
|
||||||
|
FMA) once the four free-text metadata fields (cluster/manifest file names, AutoCall date and version) are aligned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from tests.conftest import GSA_BPM, GSA_EGT, GSA_IDATS, ORACLE_GTCS, ORACLE_GTCS_GT2, need
|
||||||
|
|
||||||
|
from simurg.formats.bpm import read_bpm
|
||||||
|
from simurg.formats.egt import read_egt
|
||||||
|
from simurg.formats.gtc import encode_gtc, read_gtc
|
||||||
|
from simurg.formats.idat import read_idat
|
||||||
|
from simurg.genotype.clusters import align_clusters
|
||||||
|
from simurg.genotype.sample import GenotypeParams, genotype_sample, manifest_context
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.data
|
||||||
|
FREE_TEXT = ("cluster_file", "snp_manifest", "autocall_date", "autocall_version")
|
||||||
|
NEG_NAN = np.frombuffer(np.uint32(0xFFC00000).tobytes(), dtype=np.float32)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_nans(g): # type: ignore[no-untyped-def]
|
||||||
|
"""NaNs computed by libm carry the platform's default sign (+ on aarch64, − on x86-64); SimurgArray always
|
||||||
|
writes 0xFFC00000 like the vendor's x86 build. Compare everything else byte for byte (known_unknowns K-014)."""
|
||||||
|
for k in ("b_allele_freqs", "logr_ratios", "genotype_scores"):
|
||||||
|
a = getattr(g, k).copy()
|
||||||
|
a[np.isnan(a)] = NEG_NAN
|
||||||
|
setattr(g, k, a)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def ctx(): # type: ignore[no-untyped-def]
|
||||||
|
need(GSA_BPM, GSA_EGT)
|
||||||
|
m = read_bpm(GSA_BPM)
|
||||||
|
return manifest_context(m, align_clusters(read_egt(GSA_EGT), m))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("folder", "norm"), [(ORACLE_GTCS, "1.2.0"), (ORACLE_GTCS_GT2, "1.1.2")], ids=["1.2.0", "1.1.2"]
|
||||||
|
)
|
||||||
|
def test_gtc_byte_identical(ctx, folder, norm) -> None: # type: ignore[no-untyped-def]
|
||||||
|
need(folder, GSA_IDATS)
|
||||||
|
gtcs = sorted(folder.glob("*.gtc"))
|
||||||
|
assert len(gtcs) >= 18
|
||||||
|
for f in gtcs:
|
||||||
|
sid = f.stem
|
||||||
|
chip = sid.split("_")[0]
|
||||||
|
ref = read_gtc(f)
|
||||||
|
g = genotype_sample(
|
||||||
|
read_idat(GSA_IDATS / chip / f"{sid}_Grn.idat"),
|
||||||
|
read_idat(GSA_IDATS / chip / f"{sid}_Red.idat"),
|
||||||
|
ctx,
|
||||||
|
GenotypeParams(norm_version=norm),
|
||||||
|
)
|
||||||
|
for k in FREE_TEXT:
|
||||||
|
setattr(g, k, getattr(ref, k))
|
||||||
|
assert encode_gtc(g) == encode_gtc(canonical_nans(ref)), sid
|
||||||
Reference in new issue
Block a user