P1: file parsers, sample sheet, discovery, CLI inspect

Readers for IDAT v3, BPM (locus entries v4-v8), CSV manifest, EGT
(data blocks 5-9) and GTC v3-v5 (plus a layout-preserving GTC writer);
sample-sheet validation (IN-001..IN-014), IDAT discovery and pairing
(IN-020..IN-022), message catalogue and exit codes (CLI-014/016),
JSON Lines logging (CLI-007/008), atomic writes (CLI-011), run manifest,
and `simurg inspect`.

Validation (docs/validation/P1.md): GTC, BPM and EGT readers agree
exactly with Illumina BeadArrayFiles on GSA v4 / GDA v1 files; GTCs
round-trip byte-identically; BPM and CSV manifests are consistent.
This commit is contained in:
kemal committed 2026-09-27 16:56:39 +01:00
1 parent 705138d70b
commit e518ea03d5
40 files changed
+4742

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
"""Writers for small synthetic IDAT, BPM and EGT files (plan §16.4).
They produce structurally valid files for tests and synthetic data sets. Layouts mirror the readers in
:mod:`simurg.formats`; they are not meant to reproduce vendor files byte-for-byte.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
import numpy as np
import numpy.typing as npt
from simurg.formats import idat as idatfmt
from simurg.formats.binutil import BinaryWriter
@dataclass
class SynthIdat:
address_ids: npt.NDArray[np.int32]
mean: npt.NDArray[np.uint16]
sd: npt.NDArray[np.uint16] | None = None
n_beads: npt.NDArray[np.uint8] | None = None
barcode: str = "200000000001"
position: str = "R01C01"
chip_type: str = "BeadChip 24x1x4"
sample_id: str = ""
plate: str = ""
well: str = ""
manifest: str = ""
run_info: list[tuple[str, str, str, str, str]] = field(
default_factory=lambda: [
("12/27/2023 5:52:21 PM", "Scan", "ScannerID=N0", "iScan Control Software", "4.2.1.729")
]
)
mid_block_itemsize: int = 4
version: int = 3
def encode_idat(s: SynthIdat) -> bytes:
n = len(s.address_ids)
sd = s.sd if s.sd is not None else np.full(n, 100, np.uint16)
nb = s.n_beads if s.n_beads is not None else np.full(n, 12, np.uint8)
sections: list[tuple[int, bytes]] = []
def sec(code: int, *parts: tuple[str, object]) -> None:
w = BinaryWriter()
for kind, val in parts:
if kind == "i32":
w.i32(int(val)) # type: ignore[call-overload]
elif kind == "str":
w.string(str(val))
elif kind == "raw":
w.write(val) # type: ignore[arg-type]
else:
w.array(val, kind) # type: ignore[arg-type]
sections.append((code, w.getvalue()))
mid_dtype = "<i4" if s.mid_block_itemsize == 4 else "u1"
sec(idatfmt.N_SNPS, ("i32", n))
sec(idatfmt.ILLUMINA_ID, ("<i4", s.address_ids))
sec(idatfmt.SD, ("<u2", sd))
sec(idatfmt.MEAN, ("<u2", s.mean))
sec(idatfmt.NBEADS, ("u1", nb))
sec(idatfmt.MID_BLOCK, ("i32", n), (mid_dtype, s.address_ids))
run: list[tuple[str, object]] = [("i32", len(s.run_info))]
run += [("str", part) for entry in s.run_info for part in entry]
sec(idatfmt.RUN_INFO, *run)
sec(idatfmt.RED_GREEN, ("raw", b"\x00\x00\x00\x00"))
for code, val in (
(idatfmt.MANIFEST, s.manifest),
(idatfmt.BARCODE, s.barcode),
(idatfmt.CHIP_TYPE, s.chip_type),
(idatfmt.POSITION, s.position),
(idatfmt.BEADSET, ""),
(idatfmt.SAMPLE_ID, s.sample_id),
(idatfmt.DESCRIPTION, ""),
(idatfmt.PLATE, s.plate),
(idatfmt.WELL, s.well),
):
sec(code, ("str", val))
sec(idatfmt.SAMPLE_COUNT, ("i32", 1))
header = 4 + 8 + 4 + 10 * len(sections)
out = BinaryWriter()
out.write(b"IDAT")
out.write(int(s.version).to_bytes(8, "little", signed=True))
out.i32(len(sections))
off = header
for code, data in sections:
out.u16(code)
out.write(off.to_bytes(8, "little", signed=True))
off += len(data)
for _, data in sections:
out.write(data)
return out.getvalue()
@dataclass
class SynthLocus:
name: str
chrom: str = "1"
pos: int = 1000
snp: str = "[A/G]"
address_a: int = 1
address_b: int = 0
assay_type: int = 0
norm_id: int = 0
ilmn_strand: str = "TOP"
ref_strand: str = "+"
source_strand: str = "TOP"
intensity_only: int = 0
exp_clusters: int = 3
source_seq: str = ""
probe_a: str = ""
probe_b: str = ""
ilmn_id: str | None = None
def encode_bpm(
loci: Sequence[SynthLocus],
*,
manifest_name: str = "SYNTH-1v1_A1",
entry_version: int = 8,
bpm_version: int = 4,
control_config: str = "",
trailer: Sequence[str] = ("Illumina, Inc.",),
entry_order: Sequence[int] | None = None,
) -> bytes:
w = BinaryWriter()
w.write(b"BPM")
w.u8(1)
w.i32(bpm_version)
w.string(manifest_name)
w.string(control_config)
n = len(loci)
w.i32(n)
w.array(np.arange(1, n + 1), "<i4")
for lc in loci:
w.string(lc.name)
w.array([lc.norm_id for lc in loci], "u1")
order = list(entry_order) if entry_order is not None else list(range(n))
for i in order:
lc = loci[i]
w.i32(entry_version)
w.string(lc.ilmn_id or f"{lc.name}-0_T_F_1")
w.string(lc.name)
for _ in range(3):
w.string("")
w.i32(i + 1)
w.string("")
w.string(lc.ilmn_strand)
w.string(lc.snp)
w.string(lc.chrom)
w.string("diploid")
w.string("Homo sapiens")
w.string(str(lc.pos))
w.string("") # TopGenomicSeq (v4 only)
w.string("") # customer strand
w.i32(lc.address_a)
w.i32(lc.address_b)
w.string(lc.probe_a if entry_version == 4 else "")
w.string(lc.probe_b if entry_version == 4 else "")
w.string("38")
w.string("synthetic")
w.string("0")
w.string(lc.source_strand)
w.string(lc.source_seq if entry_version == 4 else "")
if entry_version >= 6:
w.u8(0)
w.write(int(lc.exp_clusters).to_bytes(1, "little", signed=True))
w.u8(lc.intensity_only)
w.u8(lc.assay_type)
if entry_version >= 7:
for f in (0.25, 0.25, 0.25, 0.25):
w.f32(f)
if entry_version >= 8:
w.string(lc.ref_strand)
w.i32(len(trailer))
for line in trailer:
w.string(line)
return w.getvalue()
def encode_egt(
names: Sequence[str],
*,
theta_mean: npt.ArrayLike,
r_mean: npt.ArrayLike,
theta_dev: npt.ArrayLike | None = None,
r_dev: npt.ArrayLike | None = None,
n: npt.ArrayLike | None = None,
gentrain: npt.ArrayLike | None = None,
intensity_threshold: npt.ArrayLike | None = None,
data_block_version: int = 9,
manifest_name: str = "SYNTH-1v1_A1",
corrupt_counts: bool = False,
) -> bytes:
k = len(names)
tm = np.asarray(theta_mean, np.float32).reshape(k, 3)
rm = np.asarray(r_mean, np.float32).reshape(k, 3)
td = np.asarray(theta_dev if theta_dev is not None else np.full((k, 3), 0.03), np.float32).reshape(k, 3)
rd = np.asarray(r_dev if r_dev is not None else np.full((k, 3), 0.1), np.float32).reshape(k, 3)
nn = np.asarray(n if n is not None else np.full((k, 3), 100), np.int32).reshape(k, 3)
gt = np.asarray(gentrain if gentrain is not None else np.full(k, 0.8), np.float32)
thr = np.asarray(intensity_threshold if intensity_threshold is not None else np.zeros(k), np.float32)
w = BinaryWriter()
w.i32(3)
for s in ("< 6.1.3.0", "7.0.0", "7.0.0", "1.2.0", "1/1/2026 12:00:00 PM"):
w.string(s)
w.u8(1)
w.string(manifest_name)
w.i32(data_block_version)
w.string("")
w.i32(k)
for i in range(k):
w.array(nn[i], "<i4")
w.array(rd[i], "<f4")
w.array(rm[i], "<f4")
w.array(td[i], "<f4")
w.array(tm[i], "<f4")
if data_block_version >= 7:
w.f32(float(thr[i]))
w.array(np.full(14, np.nan), "<f4")
elif data_block_version >= 6:
w.array(np.zeros(15), "<f4")
elif data_block_version < 5:
w.array(np.zeros(2), "<f4")
for i in range(k):
w.f32(0.5)
w.f32(float(gt[i]))
w.f32(float(gt[i]))
w.u8(0)
for _ in range(k):
w.string("aa_ab_bb")
for nm in names:
w.string(nm)
w.array(np.arange(1, k + 1), "<i4")
counts = nn.copy()
if corrupt_counts:
counts[0, 0] += 1
for i in range(k):
w.array(counts[i], "<i4")
if data_block_version == 9:
w.array(np.zeros(k), "<f4")
return w.getvalue()