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 0a421dffc9
commit 5a20e7131c
40 files changed
+4742

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import gzip
import numpy as np
import pytest
from hypothesis import given
from hypothesis import strategies as st
from simurg.core.errors import FormatError
from simurg.formats.binutil import BinaryReader, BinaryWriter, load_bytes
@given(st.text(max_size=400))
def test_string_roundtrip(s: str) -> None:
w = BinaryWriter()
w.string(s)
r = BinaryReader(w.getvalue(), "E_GTC_CORRUPT")
assert r.string() == s
assert r.remaining() == 0
@pytest.mark.parametrize("n", [0, 1, 127, 128, 16383, 16384, 300_000])
def test_varint_boundaries(n: int) -> None:
w = BinaryWriter()
w.string(b"x" * n)
r = BinaryReader(w.getvalue(), "E_GTC_CORRUPT")
assert r.varint_length() == n
def test_invalid_utf8_is_replaced_not_raised() -> None: # IN-033, EDGE-002
w = BinaryWriter()
w.string(b"ok\xff\xfe")
assert BinaryReader(w.getvalue(), "E_GTC_CORRUPT").string() == "ok��"
def test_string_past_eof_is_format_error() -> None:
buf = bytes([50]) + b"short"
with pytest.raises(FormatError) as e:
BinaryReader(buf, "E_GTC_CORRUPT", "x.gtc").string()
assert e.value.code == "E_GTC_CORRUPT"
def test_bad_varint_is_format_error() -> None:
with pytest.raises(FormatError):
BinaryReader(b"\xff\xff\xff\xff\xff\xff", "E_BPM_FORMAT").varint_length()
def test_array_bounds_and_seek() -> None:
r = BinaryReader(np.arange(4, dtype="<i4").tobytes(), "E_IDAT_FORMAT")
assert r.array("<i4", 4).tolist() == [0, 1, 2, 3]
r.seek(0)
with pytest.raises(FormatError):
r.array("<i4", 5)
with pytest.raises(FormatError):
r.seek(100)
def test_load_bytes_gzip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "a.bin.gz"
p.write_bytes(gzip.compress(b"hello"))
assert bytes(load_bytes(p)) == b"hello"
q = tmp_path / "b.bin"
q.write_bytes(b"world")
assert bytes(load_bytes(q)) == b"world"
e = tmp_path / "empty.bin"
e.write_bytes(b"")
assert bytes(load_bytes(e)) == b""
+139
View File
@@ -0,0 +1,139 @@
"""BPM and EGT readers on synthetic files (spec IN-040, IN-044; plan §6.2 normalization-bin rule)."""
from __future__ import annotations
import numpy as np
import pytest
from simurg.core.errors import FormatError
from simurg.formats.bpm import infer_assay_type, parse_control_config, read_bpm
from simurg.formats.egt import read_egt
from simurg.synth.files import SynthLocus, encode_bpm, encode_egt
def _loci() -> list[SynthLocus]:
return [
SynthLocus("rs1", "1", 100, "[A/G]", 11, 0, 0, norm_id=1),
SynthLocus("rs2", "2", 200, "[A/T]", 12, 13, 1, norm_id=1, ilmn_strand="BOT", ref_strand="-"),
SynthLocus("rs3", "X", 300, "[C/G]", 14, 15, 2, norm_id=99),
SynthLocus("rs4", "Y", 400, "[T/C]", 16, 0, 0, norm_id=2, intensity_only=1),
SynthLocus("rs5", "MT", 500, "[A/C]", 17, 0, 0, norm_id=99),
]
def test_bpm_roundtrip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(_loci(), control_config="0000000001,Staining,Red,DNP (High)\n"))
m = read_bpm(p)
assert m.names == ["rs1", "rs2", "rs3", "rs4", "rs5"]
assert m.chroms == ["1", "2", "X", "Y", "MT"]
assert m.map_infos.tolist() == [100, 200, 300, 400, 500]
assert m.address_a.tolist() == [11, 12, 14, 16, 17]
assert m.address_b.tolist() == [0, 13, 15, 0, 0]
assert m.assay_types.tolist() == [0, 1, 2, 0, 0]
assert m.intensity_only.tolist() == [False, False, False, True, False]
assert m.ref_strands == ["+", "-", "+", "+", "+"]
assert m.ilmn_strands[1] == "BOT"
assert m.controls == [{"address": "0000000001", "category": "Staining", "color": "Red", "name": "DNP (High)"}]
assert m.header_lines == ["Illumina, Inc."]
def test_norm_bins_mod256_and_rank(tmp_path) -> None: # type: ignore[no-untyped-def]
# rs3: 99 + 100*2 = 299 -> 43 (byte wrap) ; rs2: 1 + 100 = 101
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(_loci()))
m = read_bpm(p)
assert m.norm_bin_ids().tolist() == [1, 101, 43, 2, 99]
# sorted distinct: [1, 2, 43, 99, 101] -> ranks
assert m.normalization_lookups().tolist() == [0, 4, 2, 1, 3]
def test_entries_mapped_by_name(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(_loci(), entry_order=[4, 3, 2, 1, 0]))
m = read_bpm(p)
assert m.map_infos.tolist() == [100, 200, 300, 400, 500]
@pytest.mark.parametrize("entry_version", [4, 6, 7, 8])
def test_entry_versions(tmp_path, entry_version) -> None: # type: ignore[no-untyped-def]
loci = [
SynthLocus("rs1", source_seq="ACGT[A/G]TTGA", probe_a="ACGTA"),
SynthLocus("rs2", address_b=5, assay_type=1, source_seq="AAAT[A/T]AAAC", probe_a="ACGTA", probe_b="ACGTT"),
]
p = tmp_path / "m.bpm"
p.write_bytes(encode_bpm(loci, entry_version=entry_version))
m = read_bpm(p)
assert m.assay_types.tolist() == [0, 1]
assert (m.base_fractions is not None) == (entry_version >= 7)
assert m.has_ref_strand == (entry_version >= 8)
def test_bpm_errors(tmp_path) -> None: # type: ignore[no-untyped-def]
good = encode_bpm(_loci())
cases = {
"magic": b"XPM" + good[3:],
"truncated": good[: len(good) // 2],
"bad_assay": encode_bpm([SynthLocus("rs1", address_b=0, assay_type=1)]),
"bad_norm": encode_bpm([SynthLocus("rs1", norm_id=150)]),
}
for label, data in cases.items():
p = tmp_path / f"{label}.bpm"
p.write_bytes(data)
with pytest.raises(FormatError) as e:
read_bpm(p)
assert e.value.code == "E_BPM_FORMAT", label
def test_long_manifest_name(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-006
name = "X" * 200 + "_ünicode"
p = tmp_path / "long name (1).bpm"
p.write_bytes(encode_bpm(_loci(), manifest_name=name))
assert read_bpm(p).manifest_name == name
def test_infer_assay_type() -> None:
assert infer_assay_type("ACGT", "", "AC[A/G]TT") == 0
assert infer_assay_type("ACGA", "ACGT", "AAA[A/T]TTT") == 1
assert infer_assay_type("ACGA", "ACGT", "CCC[C/G]GGG") == 2
assert infer_assay_type("", "", "") == 255
def test_control_config_parse() -> None:
txt = "0027630314:0027630314,Staining,Red,DNP (High)\n\nbad line\n1,Hyb,Green,Hyb (High), extra\n"
assert parse_control_config(txt)[1]["name"] == "Hyb (High),extra"
assert len(parse_control_config(txt)) == 2
@pytest.mark.parametrize("dbv", [9, 8, 7, 6, 5, 4])
def test_egt_versions(tmp_path, dbv) -> None: # type: ignore[no-untyped-def]
names = ["rs1", "rs2"]
tm = [[0.05, 0.5, 0.95], [0.1, 0.45, 0.9]]
rm = [[1.0, 1.1, 0.9], [0.8, 0.9, 1.0]]
p = tmp_path / "c.egt"
p.write_bytes(encode_egt(names, theta_mean=tm, r_mean=rm, intensity_threshold=[0.2, 0.0], data_block_version=dbv))
e = read_egt(p)
assert e.names == names
np.testing.assert_allclose(e.theta_mean, tm, rtol=1e-6)
np.testing.assert_allclose(e.r_mean, rm, rtol=1e-6)
assert e.n.tolist() == [[100, 100, 100]] * 2
if dbv >= 7:
assert e.intensity_threshold.tolist()[0] == pytest.approx(0.2)
else:
assert np.isnan(e.intensity_threshold).all()
np.testing.assert_allclose(e.mean_r(), np.mean(rm, axis=1), rtol=1e-6)
def test_egt_count_mismatch(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "c.egt"
p.write_bytes(encode_egt(["rs1"], theta_mean=[[0.1, 0.5, 0.9]], r_mean=[[1, 1, 1]], corrupt_counts=True))
with pytest.raises(FormatError) as e:
read_egt(p)
assert e.value.code == "E_EGT_LOAD"
def test_egt_bad_version(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "c.egt"
p.write_bytes(b"\x04\x00\x00\x00" + b"\x00" * 50)
with pytest.raises(FormatError):
read_egt(p)
+95
View File
@@ -0,0 +1,95 @@
"""CLI behaviour (CLI-007, CLI-014, CLI-020) and the message catalogue (CLI-016)."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
from typer.testing import CliRunner
from simurg.cli.main import app
from simurg.core.errors import CATALOGUE, render_markdown
from simurg.synth.files import SynthIdat, SynthLocus, encode_bpm, encode_egt, encode_idat
ROOT = Path(__file__).resolve().parents[2]
runner = CliRunner()
def _files(tmp_path: Path) -> dict[str, Path]:
idat = tmp_path / "200000000001_R01C01_Grn.idat"
idat.write_bytes(
encode_idat(SynthIdat(address_ids=np.arange(1, 6, dtype=np.int32), mean=np.arange(5, dtype=np.uint16)))
)
bpm = tmp_path / "m.bpm"
bpm.write_bytes(encode_bpm([SynthLocus("rs1"), SynthLocus("rs2", address_a=2)]))
egt = tmp_path / "c.egt"
egt.write_bytes(encode_egt(["rs1"], theta_mean=[[0.1, 0.5, 0.9]], r_mean=[[1, 1, 1]]))
return {"idat": idat, "bpm": bpm, "egt": egt}
def test_version() -> None:
r = runner.invoke(app, ["--version"])
assert r.exit_code == 0 and r.stdout.startswith("SimurgArray ")
def test_inspect_json(tmp_path) -> None: # type: ignore[no-untyped-def]
f = _files(tmp_path)
r = runner.invoke(app, ["inspect", "--json", str(f["idat"]), str(f["bpm"]), str(f["egt"])])
assert r.exit_code == 0, r.output
out = json.loads(r.stdout)
assert [o["kind"] for o in out] == ["idat", "bpm", "egt"]
assert out[1]["num_loci"] == 2 and out[2]["num_records"] == 1
def test_inspect_missing_file_exit2() -> None:
r = runner.invoke(app, ["inspect", "/nonexistent/x.idat"])
assert r.exit_code == 2
assert "E_FILE_NOT_FOUND" in r.output
def test_inspect_partial_failure_exit3(tmp_path) -> None: # type: ignore[no-untyped-def]
f = _files(tmp_path)
bad = tmp_path / "bad.gtc"
bad.write_bytes(b"gtc\x05" + b"\x00" * 3)
r = runner.invoke(app, ["inspect", "--log-json", str(f["idat"]), str(bad)])
assert r.exit_code == 3
line = next(ln for ln in r.output.splitlines() if ln.startswith('{"schema"'))
rec = json.loads(line)
assert rec["schema"] == "simurg.log/1" and rec["code"] == "E_GTC_CORRUPT" and rec["level"] == "error"
def test_unknown_option_is_usage_error(tmp_path) -> None: # type: ignore[no-untyped-def]
r = runner.invoke(app, ["inspect", "--no-such-option", "x"])
assert r.exit_code == 2
def test_catalogue_integrity() -> None:
for code, d in CATALOGUE.items():
assert code == d.code
assert code[:2] in ("E_", "W_", "I_")
assert d.level in ("error", "warning", "info")
def test_messages_doc_is_current() -> None:
doc = ROOT / "docs" / "messages.md"
assert doc.read_text(encoding="utf-8") == render_markdown(), "regenerate: python -m simurg.core.errors"
def test_locale_independent_output(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-001
f = _files(tmp_path)
outs = set()
for loc in ("C.UTF-8", "tr_TR.UTF-8", "de_DE.UTF-8"):
env = {"LC_ALL": loc, "PATH": "/usr/bin:/bin"}
r = subprocess.run(
[sys.executable, "-m", "simurg.cli.main", "inspect", "--json", str(f["bpm"])],
capture_output=True,
text=True,
env=env,
cwd=ROOT,
)
assert r.returncode == 0, r.stderr
outs.add(r.stdout)
assert len(outs) == 1
+120
View File
@@ -0,0 +1,120 @@
"""IDAT discovery (IN-020 … IN-022, EDGE-005) and atomic writes / overwrite policy (CLI-010, CLI-011)."""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import pytest
from simurg.core.atomic import atomic_open, atomic_write_bytes, check_outputs_absent
from simurg.core.errors import SimurgError
from simurg.formats import discovery as disc
from simurg.formats.samplesheet import parse_samplesheet
from simurg.synth.files import SynthIdat, encode_idat
def write_pair(
folder: Path,
barcode: str,
pos: str,
scan: str = "12/27/2023 5:52:21 PM",
channels: tuple[str, ...] = ("Grn", "Red"),
) -> None:
folder.mkdir(parents=True, exist_ok=True)
s = SynthIdat(
address_ids=np.arange(1, 11, dtype=np.int32),
mean=np.arange(10, dtype=np.uint16),
barcode=barcode,
position=pos,
run_info=[(scan, "Scan", "", "iScan", "1")],
)
for ch in channels:
(folder / f"{barcode}_{pos}_{ch}.idat").write_bytes(encode_idat(s))
def test_pairs_and_unpaired(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "a", "200000000001", "R01C01")
write_pair(tmp_path / "b" / "deep", "200000000001", "R02C01", channels=("Grn",))
d = disc.discover_idats(tmp_path)
assert sorted(d.pairs) == ["200000000001_R01C01", "200000000001_R02C01"]
assert d.pairs["200000000001_R01C01"].complete
assert not d.pairs["200000000001_R02C01"].complete
assert [c for c, _ in d.warnings] == ["W_IDAT_UNPAIRED"]
def test_duplicate_pairs_error_lists_all_paths(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "run1", "200000000001", "R01C01")
write_pair(tmp_path / "run2", "200000000001", "R01C01")
with pytest.raises(SimurgError) as e:
disc.discover_idats(tmp_path)
assert e.value.code == "E_DUP_IDAT"
assert "run1" in e.value.message and "run2" in e.value.message
def test_duplicate_first_and_newest_deterministic(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "z_old", "200000000001", "R01C01", scan="1/1/2020 1:00:00 PM")
write_pair(tmp_path / "a_new", "200000000001", "R01C01", scan="1/1/2024 1:00:00 PM")
first = disc.discover_idats(tmp_path, on_duplicate="first").pairs["200000000001_R01C01"]
newest = disc.discover_idats(tmp_path, on_duplicate="newest").pairs["200000000001_R01C01"]
assert first.folder.name == "a_new" # bytewise smallest path
assert newest.folder.name == "a_new"
# shuffled directory listing must not change anything
real_scandir = os.scandir
def shuffled(path): # type: ignore[no-untyped-def]
return list(reversed(list(real_scandir(path))))
monkeypatch.setattr(os, "scandir", shuffled)
assert disc.discover_idats(tmp_path, on_duplicate="first").pairs["200000000001_R01C01"].folder.name == "a_new"
def test_symlink_loop(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "a", "200000000001", "R01C01")
(tmp_path / "a" / "loop").symlink_to(tmp_path, target_is_directory=True)
assert list(disc.discover_idats(tmp_path).pairs) == ["200000000001_R01C01"]
def test_resolve_with_sheet(tmp_path) -> None: # type: ignore[no-untyped-def]
write_pair(tmp_path / "idats", "200000000001", "R01C01")
write_pair(tmp_path / "idats", "200000000001", "R03C01")
sh = tmp_path / "s.csv"
sh.write_text(
"[Header]\nRootFolder,idats\n[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n"
"200000000001,R01C01,alpha\n200000000001,R02C01,beta\n"
)
sheet = parse_samplesheet(sh)
folder, notes = disc.resolve_input_folder(None, sheet)
assert folder == tmp_path / "idats" and notes == []
samples, notes = disc.resolve_samples(disc.discover_idats(folder), sheet)
assert [(s.sample_name, s.status) for s in samples] == [("alpha", "READY"), ("beta", "MISSING_INPUT")]
assert notes[0][0] == "I_UNLISTED_IDATS"
_, notes = disc.resolve_input_folder(tmp_path / "other", sheet)
assert notes[0][0] == "W_SHEET_ROOTFOLDER_OVERRIDDEN"
def test_input_rules() -> None:
with pytest.raises(SimurgError) as e:
disc.resolve_input_folder(None, None)
assert e.value.code == "E_NO_INPUT"
def test_atomic_write_and_failure(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "out" / "x.bin"
atomic_write_bytes(p, b"abc")
assert p.read_bytes() == b"abc"
with pytest.raises(RuntimeError), atomic_open(tmp_path / "y.bin") as fh:
fh.write(b"partial") # type: ignore[arg-type]
raise RuntimeError("boom")
assert not (tmp_path / "y.bin").exists()
assert not (tmp_path / "y.bin.part").exists()
def test_overwrite_policy(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "x"
p.write_text("1")
with pytest.raises(SimurgError) as e:
check_outputs_absent([p, tmp_path / "missing"], force=False)
assert e.value.code == "E_OUTPUT_EXISTS"
check_outputs_absent([p], force=True)
+152
View File
@@ -0,0 +1,152 @@
"""GTC codec: synthetic round-trips, property tests, corruption handling (spec IN-032, IN-033, EDGE-002)."""
from __future__ import annotations
import numpy as np
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from simurg.core.errors import FormatError
from simurg.formats.gtc import (
CANONICAL_ORDER_V3,
CANONICAL_ORDER_V5,
XFORM_DTYPE,
Gtc,
ScannerData,
encode_gtc,
read_gtc,
write_gtc,
)
def make_gtc(n: int = 20, version: int = 5, seed: int = 0) -> Gtc:
rng = np.random.default_rng(seed)
xf = np.zeros(3, XFORM_DTYPE)
xf["version"] = 1
xf["offset_x"] = rng.normal(size=3)
xf["theta"] = rng.normal(size=3)
g = Gtc(
version=version,
num_snps=n,
sample_name="NA12878 ünicode",
cluster_file="c.egt",
snp_manifest="m.bpm",
imaging_date="12/27/2023 5:53:34 PM",
autocall_date="01/01/26 12:00 PM",
autocall_version="simurg-0.1",
normalization_transforms=xf,
controls_x=rng.integers(0, 60000, 8).astype(np.uint16),
controls_y=rng.integers(0, 60000, 8).astype(np.uint16),
raw_x=rng.integers(0, 60000, n).astype(np.uint16),
raw_y=rng.integers(0, 60000, n).astype(np.uint16),
genotypes=rng.integers(0, 4, n).astype(np.uint8),
base_calls=np.frombuffer(b"AG" * n, np.uint8).reshape(n, 2).copy(),
genotype_scores=rng.random(n).astype(np.float32),
scanner_data=ScannerData("N103", b"\x01\x00\x00\x00", b"\x02\x00\x00\x00", "4.2.1", "user"),
call_rate=0.99,
gender=b"F",
logr_dev=0.12,
gc10=0.4,
b_allele_freqs=rng.random(n).astype(np.float32),
logr_ratios=rng.normal(size=n).astype(np.float32),
percentiles_x=np.array([1, 2, 3], np.uint16),
percentiles_y=np.array([4, 5, 6], np.uint16),
slide_identifier="200000000001",
)
g.sample_data["p50gc"] = 0.7
g.sample_data["num_calls"] = n - 2
g.sample_data["num_no_calls"] = 2
return g
@pytest.mark.parametrize("version", [3, 5])
def test_roundtrip_canonical(tmp_path, version) -> None: # type: ignore[no-untyped-def]
g = make_gtc(version=version)
p = tmp_path / "s.gtc"
write_gtc(g, p)
h = read_gtc(p)
assert h.toc_order == (CANONICAL_ORDER_V5 if version == 5 else CANONICAL_ORDER_V3)
assert h.sample_name == g.sample_name
assert np.array_equal(h.raw_x, g.raw_x) and np.array_equal(h.genotypes, g.genotypes)
assert np.array_equal(h.genotype_scores, g.genotype_scores)
assert h.scanner_data == g.scanner_data
assert h.gc50 == pytest.approx(0.7) and h.num_calls == 18
assert h.gender_char == "F"
assert h.base_call_strings()[0] == "AG"
assert encode_gtc(h) == p.read_bytes() # byte-identical round-trip
if version == 5:
assert np.array_equal(h.logr_ratios, g.logr_ratios)
def test_noncanonical_layout_preserved(tmp_path) -> None: # type: ignore[no-untyped-def]
g = make_gtc()
g.toc_order = list(reversed(CANONICAL_ORDER_V5))
g.data_order = [t for t in CANONICAL_ORDER_V5 if t > 3][::-1]
data = encode_gtc(g)
p = tmp_path / "s.gtc"
p.write_bytes(data)
h = read_gtc(p)
assert h.toc_order == g.toc_order and h.data_order == g.data_order
assert encode_gtc(h) == data
@settings(max_examples=25, deadline=None)
@given(n=st.integers(0, 200), seed=st.integers(0, 10**6), name=st.text(max_size=300))
def test_property_roundtrip(tmp_path_factory, n, seed, name) -> None: # type: ignore[no-untyped-def]
g = make_gtc(n=n, seed=seed)
g.sample_name = name
data = encode_gtc(g)
p = tmp_path_factory.mktemp("g") / "x.gtc"
p.write_bytes(data)
h = read_gtc(p)
assert h.sample_name == name and h.num_snps == n
assert encode_gtc(h) == data
def test_truncated_and_bad_magic(tmp_path) -> None: # type: ignore[no-untyped-def]
data = encode_gtc(make_gtc())
for label, blob in {
"trunc": data[: len(data) // 3],
"magic": b"xtc" + data[3:],
"tiny": data[:5],
"version": data[:3] + b"\x09" + data[4:],
}.items():
p = tmp_path / f"{label}.gtc"
p.write_bytes(blob)
with pytest.raises(FormatError) as e:
read_gtc(p)
assert e.value.code == "E_GTC_CORRUPT", label
def test_bad_string_length_is_clean_error(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-002
g = make_gtc()
data = bytearray(encode_gtc(g))
header = 8 + 6 * len(CANONICAL_ORDER_V5)
data[header] = 0xFF # sample-name length prefix now runs past EOF
data[header + 1] = 0xFF
data[header + 2] = 0xFF
data[header + 3] = 0x7F
p = tmp_path / "s.gtc"
p.write_bytes(bytes(data))
with pytest.raises(FormatError) as e:
read_gtc(p)
assert e.value.code == "E_GTC_CORRUPT"
def test_incomplete_write_detection(tmp_path) -> None: # type: ignore[no-untyped-def]
g = make_gtc()
g.toc_order = [t for t in CANONICAL_ORDER_V5 if t != 1016]
g.data_order = [t for t in g.toc_order if t > 3]
p = tmp_path / "s.gtc"
p.write_bytes(encode_gtc(g))
with pytest.raises(FormatError):
read_gtc(p)
assert read_gtc(p, check_write_complete=False).num_snps == 20
def test_length_mismatch_rejected_on_write() -> None:
g = make_gtc()
g.raw_x = g.raw_x[:-1]
with pytest.raises(ValueError):
encode_gtc(g)
+128
View File
@@ -0,0 +1,128 @@
"""IDAT reader: synthetic files + malformed-input matrix (spec IN-023, EDGE-004, EDGE-033)."""
from __future__ import annotations
import gzip
import struct
import numpy as np
import pytest
from simurg.core.errors import FormatError
from simurg.formats.idat import check_pair, parse_idat_filename, parse_run_time, read_idat
from simurg.synth.files import SynthIdat, encode_idat
def _synth(n: int = 50, **kw) -> SynthIdat: # type: ignore[no-untyped-def]
rng = np.random.default_rng(1)
ids = np.sort(rng.choice(10_000_000, n, replace=False)).astype(np.int32)
return SynthIdat(
address_ids=ids,
mean=rng.integers(0, 60000, n).astype(np.uint16),
n_beads=rng.integers(0, 30, n).astype(np.uint8),
**kw,
)
def test_roundtrip(tmp_path) -> None: # type: ignore[no-untyped-def]
s = _synth(sample_id="NA12878", plate="P1", well="A01")
p = tmp_path / "200000000001_R01C01_Grn.idat"
p.write_bytes(encode_idat(s))
i = read_idat(p)
assert i.n_addresses == 50
assert np.array_equal(i.address_ids, s.address_ids)
assert np.array_equal(i.mean, s.mean)
assert np.array_equal(i.n_beads, s.n_beads)
assert (i.barcode, i.position, i.sample_id, i.plate, i.well) == ("200000000001", "R01C01", "NA12878", "P1", "A01")
assert i.scan_time_iso == "2023-12-27T17:52:21"
assert i.scanner_software == "4.2.1.729"
assert i.mid_block_itemsize == 4
def test_midblock_uint8_inferred(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "x.idat"
p.write_bytes(encode_idat(_synth(n=40, mid_block_itemsize=1)))
assert read_idat(p).mid_block_itemsize == 1
def test_gzip(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "200000000001_R01C01_Red.idat.gz"
p.write_bytes(gzip.compress(encode_idat(_synth())))
assert read_idat(p).n_addresses == 50
def test_metadata_only(tmp_path) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "x.idat"
p.write_bytes(encode_idat(_synth()))
i = read_idat(p, load_arrays=False)
assert i.n_addresses == 50 and len(i.mean) == 0 and i.barcode == "200000000001"
@pytest.mark.parametrize(
"mutate,code",
[
(lambda b: b"XDAT" + b[4:], "E_IDAT_FORMAT"), # wrong magic
(lambda b: b[:4] + struct.pack("<q", 4) + b[12:], "E_IDAT_FORMAT"), # unsupported version
(lambda b: b[:4] + struct.pack("<q", 1 | (7 << 32)) + b[12:], "E_IDAT_ENCRYPTED"), # encrypted v1
(lambda b: b[: len(b) // 2], "E_IDAT_FORMAT"), # truncated
(lambda b: b[:18] + struct.pack("<q", 10**9) + b[26:], "E_IDAT_FORMAT"), # TOC offset outside file
(lambda b: b[:8], "E_IDAT_FORMAT"), # tiny
],
)
def test_malformed(tmp_path, mutate, code) -> None: # type: ignore[no-untyped-def]
p = tmp_path / "bad.idat"
p.write_bytes(mutate(encode_idat(_synth())))
with pytest.raises(FormatError) as e:
read_idat(p)
assert e.value.code == code
def test_pair_mismatch(tmp_path) -> None: # type: ignore[no-untyped-def]
a, b = tmp_path / "a.idat", tmp_path / "b.idat"
a.write_bytes(encode_idat(_synth(n=50)))
b.write_bytes(encode_idat(_synth(n=49)))
with pytest.raises(FormatError) as e:
check_pair(read_idat(a), read_idat(b))
assert e.value.code == "E_IDAT_PAIR_MISMATCH"
@pytest.mark.parametrize(
"name,expected",
[
("207895980019_R03C01_Grn.idat", ("207895980019", "R03C01", "Grn")),
("207895980019_r03c01_red.idat.gz", ("207895980019", "R03C01", "Red")),
("5723646052_R02C02_GRN.IDAT", ("5723646052", "R02C02", "Grn")),
("4019585376_B_Red.idat", None),
("notes.txt", None),
],
)
def test_filename(name, expected) -> None: # type: ignore[no-untyped-def]
assert parse_idat_filename(name) == expected
@pytest.mark.parametrize(
"raw,iso",
[
("12/27/2023 5:53:34 PM", "2023-12-27T17:53:34"),
("1/2/2020 12:00:01 AM", "2020-01-02T00:00:01"),
("2020-01-02 13:14:15", "2020-01-02T13:14:15"),
("", ""),
("garbage", ""),
],
)
def test_run_time(raw, iso) -> None: # type: ignore[no-untyped-def]
assert parse_run_time(raw) == iso
def test_duplicate_addresses_rejected(tmp_path) -> None: # type: ignore[no-untyped-def]
from simurg.formats.idat import has_duplicates
assert not has_duplicates(np.array([1, 5, 9], np.int32))
assert not has_duplicates(np.array([9, 1, 5], np.int32))
assert has_duplicates(np.array([9, 1, 9], np.int32))
s = _synth(n=5)
s.address_ids = np.array([5, 1, 5, 7, 8], np.int32)
p = tmp_path / "dup.idat"
p.write_bytes(encode_idat(s))
with pytest.raises(FormatError):
read_idat(p)
+160
View File
@@ -0,0 +1,160 @@
"""Sample-sheet matrix — spec EDGE-007 (IN-001 … IN-014)."""
from __future__ import annotations
from pathlib import Path
import pytest
from simurg.core.errors import SimurgError
from simurg.formats.samplesheet import output_stem, parse_samplesheet
HEAD = "[Header]\nInvestigator Name,X\n[Manifests]\nA,foo.bpm\n[Data]\n"
def sheet(tmp_path: Path, body: str, name: str = "s.csv", raw: bytes | None = None) -> Path:
p = tmp_path / name
if raw is not None:
p.write_bytes(raw)
else:
p.write_text(body, encoding="utf-8")
return p
def code_of(tmp_path: Path, body: str) -> str:
with pytest.raises(SimurgError) as e:
parse_samplesheet(sheet(tmp_path, body))
return e.value.code
def test_basic_sections_and_passthrough(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(
sheet(
tmp_path,
HEAD + "Sample_ID,SentrixBarcode_A,SentrixPosition_A,Gender,Tissue\na,203790110013,r04c01,Female,blood\n",
)
)
assert s.rows[0].sample_id == "203790110013_R04C01"
assert s.rows[0].reported_sex == "F"
assert s.passthrough_columns() == ["Sample_ID", "Gender", "Tissue"]
assert s.header == {"Investigator Name": "X"}
def test_sectionless(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(sheet(tmp_path, "SentrixBarcode_A,SentrixPosition_A\n203790110013,R01C01\n"))
assert len(s.rows) == 1
def test_bom_crlf_quotes(tmp_path) -> None: # type: ignore[no-untyped-def]
raw = ('[Data]\r\nSentrixBarcode_A,SentrixPosition_A,Note\r\n203790110013,R01C01,"a, b"\r\n').encode()
s = parse_samplesheet(sheet(tmp_path, "", raw=raw))
assert s.rows[0].values["Note"] == "a, b"
def test_trailing_empty_columns(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(sheet(tmp_path, "[Data]\nSentrixBarcode_A,SentrixPosition_A,,\n203790110013,R01C01,,\n"))
assert s.columns == ["SentrixBarcode_A", "SentrixPosition_A"]
assert [c for c, _ in s.warnings] == ["W_SHEET_EMPTY_COLUMN"]
assert "" not in s.rows[0].values
def test_manifests_section_with_values_ignored(tmp_path) -> None: # type: ignore[no-untyped-def]
body = (
"[Header],,\nDate,1,\n[Manifests],GSA.bpm,\nA,,\n[Data],,\nSentrixBarcode_A,SentrixPosition_A,x\n"
"203790110013,R01C01,1\n"
)
s = parse_samplesheet(sheet(tmp_path, body))
assert s.header == {"Date": "1"} and len(s.rows) == 1
@pytest.mark.parametrize(
"body,code",
[
("[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name,sample_name\n1,2,3,4\n", "E_SHEET_DUP_COLUMN"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A,Sex Estimate\n203790110013,R01C01,M\n", "E_SHEET_RESERVED"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A,call_rate\n203790110013,R01C01,1\n", "E_SHEET_RESERVED"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A,x\n203790110013,R01C01," + "a" * 501 + "\n", "E_SHEET_TOO_LONG"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A\n2.04753E+11,R01C01\n", "E_SHEET_BARCODE_FORMAT"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A\n203790110013,R1C1\n", "E_SHEET_BARCODE_FORMAT"),
("[Data]\nSentrixBarcode_A;SentrixPosition_A\n203790110013;R01C01\n", "E_SHEET_DELIMITER"),
('{"samples": []}', "E_SHEET_DELIMITER"),
("[Data]\nSample_ID,Foo\n1,2\n", "E_SHEET_NO_KEY_COLUMNS"),
("[Data]\nSentrixBarcode_A,SentrixPosition_A\n", "E_SHEET_EMPTY"),
("[Header]\nA,B\n", "E_SHEET_EMPTY"),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n203790110013,R01C01,a\n203790110013,R02C01,\n",
"E_SHEET_NAME_EMPTY",
),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n203790110013,R01C01,203790110013_R02C01\n"
"203790110013,R02C01,b\n",
"E_SHEET_NAME_COLLISION",
),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name\n203790110013,R01C01,a\n203790110013,R02C01,a\n",
"E_SHEET_NAME_COLLISION",
),
(
"[Data]\nSentrixBarcode_A,SentrixPosition_A\n203790110013,R01C01\n203790110013,r01c01\n",
"E_SHEET_DUP_SAMPLE",
),
],
)
def test_error_matrix(tmp_path, body, code) -> None: # type: ignore[no-untyped-def]
assert code_of(tmp_path, body) == code
def test_barcode_decimal_repaired(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(sheet(tmp_path, "[Data]\nSentrixBarcode_A,SentrixPosition_A\n204753010023.0,R01C01\n"))
assert s.rows[0].barcode == "204753010023"
assert s.warnings[0][0] == "W_SHEET_BARCODE_REPAIRED"
def test_scientific_notation_hint(tmp_path) -> None: # type: ignore[no-untyped-def]
with pytest.raises(SimurgError) as e:
parse_samplesheet(sheet(tmp_path, '[Data]\nSentrixBarcode_A,SentrixPosition_A\n"2,04753E+11",R01C01\n'))
assert e.value.hint and "0 decimals" in e.value.hint
def test_alias_keys_and_rootfolder(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(
sheet(tmp_path, "[Header]\nRootFolder,idats\n[Data]\nbeadChipName,sampleSectionName\n203790110013,R01C01\n")
)
assert s.barcode_column == "beadChipName"
assert s.root_folder == tmp_path / "idats"
def test_sex_columns(tmp_path) -> None: # type: ignore[no-untyped-def]
s = parse_samplesheet(
sheet(
tmp_path,
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Gender,Sex\n"
"203790110013,R01C01,Male,F\n203790110013,R02C01,,\n",
)
)
assert [r.reported_sex for r in s.rows] == ["F", "U"]
assert any(c == "W_SHEET_SEX_CONFLICT" for c, _ in s.warnings)
def test_turkish_case_folding_is_locale_independent(tmp_path) -> None: # type: ignore[no-untyped-def] # EDGE-001
s = parse_samplesheet(sheet(tmp_path, "[Data]\nSENTRIXBARCODE_A,SentrixPosition_A,İl\n203790110013,R01C01,x\n"))
assert s.barcode_column == "SENTRIXBARCODE_A"
def test_ten_thousand_rows_fast(tmp_path) -> None: # type: ignore[no-untyped-def] # PERF-007 (parse part)
import time
lines = ["[Data]", "SentrixBarcode_A,SentrixPosition_A,Sample_Name"]
for i in range(10_000):
lines.append(f"{200000000000 + i // 24},R{(i % 24) + 1:02d}C01,s{i}")
p = sheet(tmp_path, "\n".join(lines) + "\n")
t = time.perf_counter()
s = parse_samplesheet(p)
assert len(s.rows) == 10_000
assert time.perf_counter() - t < 2.0
def test_output_stem() -> None:
assert output_stem("NA 12878/ü") == "NA_12878__"
assert output_stem("..hidden") == "hidden"
assert len(output_stem("x" * 500)) == 200