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. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
140 lines
5.7 KiB
Python
140 lines
5.7 KiB
Python
"""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)
|