Files
SimurgArray/src/simurg/formats/discovery.py
T
kemalandClaude Opus 5.5 4357d3deef
ci / test (3.12) (push) Failing after 11s
ci / test (3.13) (push) Failing after 12s
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.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
2026-09-27 16:56:39 +01:00

197 lines
7.4 KiB
Python

"""IDAT / GTC discovery and sample-set resolution — spec IN-016, IN-020 … IN-023, IN-030, CLI-006.
Directory listing order never influences results: every path list is sorted bytewise (``os.fsencode``).
"""
from __future__ import annotations
import os
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from simurg.core.errors import SimurgError
from simurg.formats.idat import parse_idat_filename, parse_run_time, read_idat
from simurg.formats.samplesheet import SampleSheet
@dataclass
class IdatPair:
barcode: str
position: str
grn: Path | None
red: Path | None
@property
def sample_id(self) -> str:
return f"{self.barcode}_{self.position}"
@property
def complete(self) -> bool:
return self.grn is not None and self.red is not None
@property
def folder(self) -> Path:
p = self.grn or self.red
assert p is not None
return p.parent
@dataclass
class Discovery:
root: Path
pairs: dict[str, IdatPair] # sample_id -> pair (complete or not)
warnings: list[tuple[str, str]] = field(default_factory=list)
def _bytewise(paths: list[Path]) -> list[Path]:
return sorted(paths, key=lambda p: os.fsencode(str(p)))
def walk_files(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
"""Recursive listing following symlinks with cycle detection; sorted bytewise."""
found: list[Path] = []
seen_dirs: set[tuple[int, int]] = set()
stack = [root]
while stack:
d = stack.pop()
try:
st = d.stat()
except OSError:
continue
key = (st.st_dev, st.st_ino)
if key in seen_dirs:
continue
seen_dirs.add(key)
try:
entries = list(os.scandir(d))
except OSError:
continue
for e in entries:
p = Path(e.path)
try:
if e.is_dir(follow_symlinks=True):
stack.append(p)
elif e.is_file(follow_symlinks=True) and e.name.lower().endswith(suffixes):
found.append(p)
except OSError:
continue
return _bytewise(found)
def _scan_time_key(p: Path) -> str:
try:
return parse_run_time(read_idat(p, load_arrays=False).scan_time_raw)
except SimurgError:
return ""
def discover_idats(root: str | Path, *, on_duplicate: str = "error") -> Discovery:
"""Find ``<barcode>_<pos>_{Grn,Red}.idat[.gz]`` under ``root`` and pair them (IN-020 … IN-022)."""
rootp = Path(root)
if not rootp.is_dir():
raise SimurgError("E_FILE_NOT_FOUND", f"IDAT folder not found: {rootp}", path=str(rootp))
by_key: dict[tuple[str, str, str], list[Path]] = defaultdict(list)
for p in walk_files(rootp, (".idat", ".idat.gz")):
parsed = parse_idat_filename(p)
if parsed is None:
continue
by_key[parsed].append(p)
warnings: list[tuple[str, str]] = []
# group channel files by folder so that "the same sample in two folders" is a duplicate pair
per_sample: dict[tuple[str, str], dict[Path, dict[str, Path]]] = defaultdict(lambda: defaultdict(dict))
for (barcode, pos, ch), paths in by_key.items():
for p in paths:
per_sample[(barcode, pos)][p.parent][ch] = p
pairs: dict[str, IdatPair] = {}
duplicates: dict[str, list[Path]] = {}
for (barcode, pos), folders in sorted(per_sample.items()):
candidates = [
IdatPair(barcode, pos, chans.get("Grn"), chans.get("Red"))
for _, chans in sorted(folders.items(), key=lambda kv: os.fsencode(str(kv[0])))
]
sid = f"{barcode}_{pos}"
if len(candidates) > 1:
if on_duplicate == "error":
duplicates[sid] = [p for c in candidates for p in (c.grn, c.red) if p is not None]
continue
if on_duplicate == "newest":
# candidates are sorted bytewise; max() keeps the first (smallest path) among equal scan times
chosen = max(candidates, key=lambda c: _scan_time_key(c.grn or c.red)) # type: ignore[arg-type]
else: # "first": bytewise-smallest path
chosen = candidates[0]
warnings.append(
(
"W_DUP_IDAT_RESOLVED",
f"{sid}: {len(candidates)} IDAT pairs found; using {chosen.folder} ({on_duplicate})",
)
)
else:
chosen = candidates[0]
if not chosen.complete:
have = "Grn" if chosen.grn else "Red"
warnings.append(("W_IDAT_UNPAIRED", f"{sid}: only the {have} IDAT was found in {chosen.folder}"))
pairs[sid] = chosen
if duplicates:
lines = [f"{sid}: " + ", ".join(str(p) for p in ps) for sid, ps in sorted(duplicates.items())]
raise SimurgError(
"E_DUP_IDAT",
f"{len(duplicates)} sample(s) have more than one IDAT pair:\n " + "\n ".join(lines),
hint="remove the extra copies or pass --on-duplicate-idat first|newest",
)
return Discovery(rootp, pairs, warnings)
@dataclass
class ResolvedSample:
sample_id: str
sample_name: str # provisional: sheet Sample_Name or sample_id; IDAT field 406 is applied on read (IN-010)
pair: IdatPair | None
sheet_row: object | None
status: str # "READY" or "MISSING_INPUT"
def resolve_input_folder(
cli_folder: str | Path | None, sheet: SampleSheet | None
) -> tuple[Path, list[tuple[str, str]]]:
"""Apply the input-set rule (CLI-006) and RootFolder precedence (IN-008)."""
notes: list[tuple[str, str]] = []
if cli_folder is None and sheet is None:
raise SimurgError("E_NO_INPUT", "no input: give an IDAT folder, a sample sheet, or both")
if cli_folder is not None:
folder = Path(cli_folder)
if sheet is not None and sheet.root_folder is not None and sheet.root_folder.resolve() != folder.resolve():
notes.append(
("W_SHEET_ROOTFOLDER_OVERRIDDEN", f"CLI folder {folder} overrides sheet RootFolder {sheet.root_folder}")
)
return folder, notes
assert sheet is not None
if sheet.root_folder is None:
raise SimurgError("E_NO_INPUT_FOLDER", f"{sheet.path}: sample sheet has no RootFolder and no folder was given")
return sheet.root_folder, notes
def resolve_samples(disc: Discovery, sheet: SampleSheet | None) -> tuple[list[ResolvedSample], list[tuple[str, str]]]:
"""Match discovered IDAT pairs with sheet rows (IN-010, IN-016). Returns samples in a deterministic order."""
notes: list[tuple[str, str]] = []
out: list[ResolvedSample] = []
if sheet is None:
for sid in sorted(disc.pairs):
found = disc.pairs[sid]
status = "READY" if found.complete else "MISSING_INPUT"
out.append(ResolvedSample(sid, sid, found, None, status))
return out, notes
listed = set()
for row in sheet.rows:
listed.add(row.sample_id)
pair = disc.pairs.get(row.sample_id)
status = "READY" if pair is not None and pair.complete else "MISSING_INPUT"
name = row.sample_name or row.sample_id
out.append(ResolvedSample(row.sample_id, name, pair, row, status))
extra = sorted(set(disc.pairs) - listed)
if extra:
notes.append(("I_UNLISTED_IDATS", f"{len(extra)} IDAT pair(s) not listed in the sample sheet are ignored"))
return out, notes