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:
1 parent
705138d70b
commit
e518ea03d5
40 files changed
+4742
No files matched your search
@@ -0,0 +1,8 @@
|
||||
"""SimurgArray — open research toolkit for Illumina Infinium genotyping BeadChip data.
|
||||
|
||||
Specification: SIMURGARRAY_MASTER_PLAN.md (algorithms, roadmap) and
|
||||
SIMURGARRAY_FUNCTIONAL_SPEC.md (externally visible behaviour, item IDs such as CLI-021).
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0.dev0"
|
||||
PRODUCT_NAME = "SimurgArray"
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray cli package."""
|
||||
@@ -0,0 +1,207 @@
|
||||
"""``simurg`` command-line interface (spec §2, plan §14).
|
||||
|
||||
Phase P1 provides ``simurg version`` and ``simurg inspect``. Later phases add the other commands of CLI-001.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, TypeVar
|
||||
|
||||
import numpy as np
|
||||
import typer
|
||||
|
||||
from simurg import PRODUCT_NAME, __version__
|
||||
from simurg.core.errors import ExitCode, SimurgError
|
||||
from simurg.core.log import Reporter
|
||||
from simurg.core.provenance import git_revision
|
||||
|
||||
app = typer.Typer(
|
||||
name="simurg",
|
||||
help=f"{PRODUCT_NAME}: analysis toolkit for Illumina Infinium genotyping BeadChip data.",
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
pretty_exceptions_enable=False,
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
)
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def version_string() -> str:
|
||||
rev = git_revision()
|
||||
return f"{PRODUCT_NAME} {__version__}" + (f"+{rev}" if rev else "")
|
||||
|
||||
|
||||
def _version_cb(value: bool) -> None:
|
||||
if value:
|
||||
typer.echo(version_string())
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
version: Annotated[
|
||||
bool, typer.Option("--version", callback=_version_cb, is_eager=True, help="Print the version and exit.")
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Top-level options."""
|
||||
|
||||
|
||||
def run_guarded(reporter: Reporter, fn: Callable[[], int]) -> None:
|
||||
"""Run a command body mapping errors to catalogue codes and exit codes (CLI-014)."""
|
||||
reporter.open()
|
||||
code = ExitCode.OK
|
||||
try:
|
||||
code = ExitCode(fn())
|
||||
except SimurgError as e:
|
||||
reporter.error(e.code, e.message + (f"\n hint: {e.hint}" if e.hint else ""), exc=e, file=e.path)
|
||||
code = e.exit_code
|
||||
except KeyboardInterrupt:
|
||||
reporter.error("E_INTERNAL", "interrupted")
|
||||
code = ExitCode.SIGINT
|
||||
except Exception as e:
|
||||
reporter.error(
|
||||
"E_INTERNAL", f"internal error: {type(e).__name__}: {e}. Rerun with --debug and report it.", exc=e
|
||||
)
|
||||
code = ExitCode.INTERNAL
|
||||
finally:
|
||||
reporter.close()
|
||||
raise typer.Exit(int(code))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
@app.command("version")
|
||||
def version_cmd() -> None:
|
||||
"""Print the version."""
|
||||
typer.echo(version_string())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------------------
|
||||
def _jsonable(o: Any) -> Any:
|
||||
if isinstance(o, np.generic):
|
||||
return o.item()
|
||||
if isinstance(o, np.ndarray):
|
||||
return o.tolist()
|
||||
if isinstance(o, Path):
|
||||
return str(o)
|
||||
if isinstance(o, bytes):
|
||||
return o.hex()
|
||||
if isinstance(o, float) and o != o: # NaN
|
||||
return None
|
||||
return str(o)
|
||||
|
||||
|
||||
def inspect_file(path: Path) -> dict[str, Any]:
|
||||
from simurg.formats.detect import detect_kind
|
||||
|
||||
kind = detect_kind(path)
|
||||
info: dict[str, Any] = {"file": str(path), "kind": kind}
|
||||
if kind == "idat":
|
||||
from simurg.formats.idat import read_idat
|
||||
|
||||
md = read_idat(path).metadata()
|
||||
md.pop("path", None)
|
||||
info.update(md)
|
||||
elif kind == "bpm":
|
||||
from simurg.formats.bpm import read_bpm
|
||||
|
||||
info.update(read_bpm(path).summary())
|
||||
elif kind == "csv_manifest":
|
||||
from simurg.formats.csv_manifest import assay_format, read_csv_manifest
|
||||
|
||||
m = read_csv_manifest(path, load_sequences=False)
|
||||
s = m.summary()
|
||||
s.pop("normalization_bins", None)
|
||||
s.pop("infinium_i_at", None)
|
||||
s.pop("infinium_i_gc", None)
|
||||
s["heading"] = m.heading
|
||||
s["assay_format"] = assay_format(m)
|
||||
info.update(s)
|
||||
elif kind == "egt":
|
||||
from simurg.formats.egt import read_egt
|
||||
|
||||
info.update(read_egt(path).summary())
|
||||
elif kind == "gtc":
|
||||
from simurg.formats.gtc import read_gtc, validate_gtc
|
||||
|
||||
g = read_gtc(path, check_write_complete=False)
|
||||
info.update(g.summary())
|
||||
info["problems"] = validate_gtc(g)
|
||||
elif kind == "samplesheet":
|
||||
from simurg.formats.samplesheet import parse_samplesheet
|
||||
|
||||
sh = parse_samplesheet(path)
|
||||
info.update(
|
||||
{
|
||||
"rows": len(sh.rows),
|
||||
"columns": sh.columns,
|
||||
"key_columns": [sh.barcode_column, sh.position_column],
|
||||
"root_folder": str(sh.root_folder) if sh.root_folder else None,
|
||||
"header": sh.header,
|
||||
"has_sample_name": sh.has_sample_name,
|
||||
"warnings": [f"{c}: {m}" for c, m in sh.warnings],
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise SimurgError("E_FILE_UNREADABLE", f"{path}: unrecognised file type", path=str(path))
|
||||
info.pop("path", None)
|
||||
return info
|
||||
|
||||
|
||||
def _print_human(info: dict[str, Any]) -> None:
|
||||
typer.echo(f"== {info['file']} ({info['kind']})")
|
||||
for k, v in info.items():
|
||||
if k in ("file", "kind"):
|
||||
continue
|
||||
if isinstance(v, list) and len(v) > 12:
|
||||
v = [*v[:12], f"… ({len(v)} total)"]
|
||||
if isinstance(v, (dict, list)):
|
||||
v = json.dumps(v, default=_jsonable, ensure_ascii=False)
|
||||
typer.echo(f" {k}: {v}")
|
||||
|
||||
|
||||
@app.command("inspect")
|
||||
def inspect_cmd(
|
||||
files: Annotated[list[Path], typer.Argument(help="IDAT, BPM, CSV manifest, EGT, GTC or sample-sheet files.")],
|
||||
as_json: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")] = False,
|
||||
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,
|
||||
log_level: Annotated[str, typer.Option("--log-level", help="debug|info|warning|error")] = "info",
|
||||
quiet: Annotated[bool, typer.Option("--quiet", "--no-progress", help="Suppress info messages.")] = False,
|
||||
) -> None:
|
||||
"""Print the header / table of contents of array files (spec CLI-020)."""
|
||||
rep = Reporter(
|
||||
command="inspect", level="debug" if debug else log_level, json_stderr=log_json, debug=debug, quiet=quiet
|
||||
)
|
||||
|
||||
def body() -> int:
|
||||
results = []
|
||||
failures = 0
|
||||
for f in files:
|
||||
if not f.exists():
|
||||
raise SimurgError("E_FILE_NOT_FOUND", f"{f}: no such file", path=str(f))
|
||||
try:
|
||||
info = inspect_file(f)
|
||||
except SimurgError as e:
|
||||
rep.error(e.code, e.message, file=str(f))
|
||||
failures += 1
|
||||
continue
|
||||
results.append(info)
|
||||
if not as_json:
|
||||
_print_human(info)
|
||||
if as_json:
|
||||
out = results[0] if len(results) == 1 else results
|
||||
sys.stdout.write(json.dumps(out, default=_jsonable, ensure_ascii=False, indent=2) + "\n")
|
||||
if failures and not results:
|
||||
return ExitCode.USAGE
|
||||
return ExitCode.PARTIAL if failures else ExitCode.OK
|
||||
|
||||
run_guarded(rep, body)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app()
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray cn package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray core package."""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Atomic output writing and overwrite policy (spec CLI-010, CLI-011).
|
||||
|
||||
Every output is written to ``<name>.part`` in the same directory, fsynced, then renamed, so an interrupted run
|
||||
never leaves a truncated file under its final name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from collections.abc import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import IO
|
||||
|
||||
from simurg.core.errors import SimurgError
|
||||
|
||||
PART_SUFFIX = ".part"
|
||||
|
||||
|
||||
def _fsync_dir(d: Path) -> None:
|
||||
with contextlib.suppress(OSError):
|
||||
fd = os.open(d, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def atomic_open(path: Path, mode: str = "wb", encoding: str | None = None) -> Iterator[IO[bytes] | IO[str]]:
|
||||
"""Open ``path.part`` for writing; on success fsync and rename to ``path``; on error remove the part file."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(path.name + PART_SUFFIX)
|
||||
fh: IO[bytes] | IO[str] = (
|
||||
open(tmp, mode) # noqa: SIM115 - closed below
|
||||
if "b" in mode
|
||||
else open(tmp, mode, encoding=encoding or "utf-8", newline="") # noqa: SIM115
|
||||
)
|
||||
try:
|
||||
yield fh
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
fh.close()
|
||||
os.replace(tmp, path)
|
||||
_fsync_dir(path.parent)
|
||||
except BaseException:
|
||||
fh.close()
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
tmp.unlink()
|
||||
raise
|
||||
|
||||
|
||||
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
||||
with atomic_open(path, "wb") as fh:
|
||||
fh.write(data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
with atomic_open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(text) # type: ignore[call-overload]
|
||||
|
||||
|
||||
def check_outputs_absent(paths: Iterable[Path], *, force: bool) -> None:
|
||||
"""Pre-flight overwrite check (CLI-010): raise E_OUTPUT_EXISTS listing up to 10 existing paths."""
|
||||
if force:
|
||||
return
|
||||
existing = [str(p) for p in paths if Path(p).exists()]
|
||||
if existing:
|
||||
shown = "\n ".join(existing[:10])
|
||||
more = f"\n … and {len(existing) - 10} more" if len(existing) > 10 else ""
|
||||
raise SimurgError(
|
||||
"E_OUTPUT_EXISTS",
|
||||
f"{len(existing)} output file(s) already exist:\n {shown}{more}",
|
||||
hint="use --force to replace them or --resume to keep completed samples",
|
||||
)
|
||||
|
||||
|
||||
def remove_stale_parts(directory: Path) -> list[Path]:
|
||||
"""Delete leftover ``*.part`` files (e.g. after a crash); returns what was removed."""
|
||||
removed = []
|
||||
for p in sorted(Path(directory).rglob(f"*{PART_SUFFIX}")):
|
||||
with contextlib.suppress(OSError):
|
||||
p.unlink()
|
||||
removed.append(p)
|
||||
return removed
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Stable message catalogue and exception types (spec CLI-014, CLI-016).
|
||||
|
||||
Every error or warning SimurgArray emits has a stable ``code``. The human-readable text may change between
|
||||
versions; the code may not. ``docs/messages.md`` is generated from :data:`CATALOGUE`
|
||||
(``python -m simurg.core.errors``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import Literal
|
||||
|
||||
Level = Literal["error", "warning", "info"]
|
||||
|
||||
|
||||
class ExitCode(IntEnum):
|
||||
"""Process exit codes (spec CLI-014)."""
|
||||
|
||||
OK = 0
|
||||
INTERNAL = 1
|
||||
USAGE = 2 # usage or pre-flight validation error; no per-sample work started
|
||||
PARTIAL = 3 # >= 1 sample failed and >= 1 succeeded
|
||||
ALL_FAILED = 4 # run completed but no sample succeeded
|
||||
SIGINT = 130
|
||||
SIGTERM = 143
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageDef:
|
||||
code: str
|
||||
level: Level
|
||||
summary: str
|
||||
spec: str
|
||||
|
||||
|
||||
def _m(code: str, level: Level, summary: str, spec: str) -> tuple[str, MessageDef]:
|
||||
return code, MessageDef(code, level, summary, spec)
|
||||
|
||||
|
||||
CATALOGUE: dict[str, MessageDef] = dict(
|
||||
[
|
||||
# --- CLI / run control -------------------------------------------------------------------
|
||||
_m("E_NO_INPUT", "error", "Neither an input folder nor a sample sheet was given", "CLI-006"),
|
||||
_m("E_NO_INPUT_FOLDER", "error", "Sample sheet has no RootFolder and no folder was given", "CLI-006"),
|
||||
_m("E_OUTPUT_EXISTS", "error", "Outputs already exist; use --force or --resume", "CLI-010"),
|
||||
_m("E_CONFIG_KEY", "error", "Unknown key in --config YAML", "CLI-019"),
|
||||
_m("E_CONFIG_PARSE", "error", "--config file is not valid YAML", "CLI-019"),
|
||||
_m("E_FILE_NOT_FOUND", "error", "Input file or folder does not exist", "CLI-016"),
|
||||
_m("E_FILE_UNREADABLE", "error", "Input file cannot be read", "CLI-016"),
|
||||
_m("E_INTERNAL", "error", "Internal error (bug); rerun with --debug and report", "CLI-014"),
|
||||
# --- sample sheet (IN-001 .. IN-017) -------------------------------------------------------
|
||||
_m("E_SHEET_DELIMITER", "error", "Sample sheet is not comma-delimited CSV", "IN-001"),
|
||||
_m("E_SHEET_EMPTY", "error", "Sample sheet has no data rows", "IN-001"),
|
||||
_m("E_SHEET_NO_KEY_COLUMNS", "error", "Sample sheet lacks SentrixBarcode_A/SentrixPosition_A", "IN-002"),
|
||||
_m("E_SHEET_DUP_COLUMN", "error", "Duplicate column (column names are case-insensitive)", "IN-003"),
|
||||
_m("W_SHEET_EMPTY_COLUMN", "warning", "Columns with empty headers are ignored", "IN-004"),
|
||||
_m("E_SHEET_RESERVED", "error", "Sample sheet column collides with an output column", "IN-005"),
|
||||
_m("E_SHEET_TOO_LONG", "error", "Sample sheet name or value longer than 500 characters", "IN-006"),
|
||||
_m("E_SHEET_BARCODE_FORMAT", "error", "Invalid SentrixBarcode_A / SentrixPosition_A value", "IN-007"),
|
||||
_m("W_SHEET_BARCODE_REPAIRED", "warning", "Barcode written as a decimal was repaired", "IN-007"),
|
||||
_m("W_SHEET_ROOTFOLDER_OVERRIDDEN", "warning", "CLI folder overrides the sheet RootFolder", "IN-008"),
|
||||
_m("E_SHEET_DUP_SAMPLE", "error", "The same barcode/position appears in several rows", "IN-009"),
|
||||
_m("E_SHEET_NAME_EMPTY", "error", "Sample_Name is empty in at least one row", "IN-010"),
|
||||
_m("E_SHEET_NAME_COLLISION", "error", "Sample names are not unique or clash with sample IDs", "IN-010"),
|
||||
_m("W_SHEET_SEX_CONFLICT", "warning", "Reported-sex columns disagree", "IN-014"),
|
||||
# --- IDAT / GTC discovery & parsing (IN-020 .. IN-033) --------------------------------------
|
||||
_m("E_DUP_IDAT", "error", "Several IDAT pairs resolve to the same sample", "IN-022"),
|
||||
_m("W_DUP_IDAT_RESOLVED", "warning", "Duplicate IDAT pair resolved by --on-duplicate-idat", "IN-022"),
|
||||
_m("W_IDAT_UNPAIRED", "warning", "IDAT channel file without its partner", "IN-021"),
|
||||
_m("E_IDAT_FORMAT", "error", "IDAT file is malformed", "IN-023"),
|
||||
_m("E_IDAT_ENCRYPTED", "error", "Encrypted (version 1) IDATs are not supported", "IN-023"),
|
||||
_m("E_IDAT_PAIR_MISMATCH", "error", "Red and Grn IDATs of a sample do not match", "IN-023"),
|
||||
_m("W_IDAT_NAME_MISMATCH", "warning", "IDAT barcode/position disagrees with its file name", "IN-023"),
|
||||
_m("W_IDAT_NO_SCAN_TIME", "warning", "IDAT has no scan entry in its run info", "IN-024"),
|
||||
_m("I_UNLISTED_IDATS", "info", "IDAT pairs not listed in the sample sheet are ignored", "IN-016"),
|
||||
_m("E_DUP_GTC", "error", "Several GTC files resolve to the same sample", "IN-030"),
|
||||
_m("E_GTC_CORRUPT", "warning", "GTC file failed validation; sample skipped", "IN-032"),
|
||||
_m("W_GTC_FOREIGN", "warning", "GTC was produced by another tool", "IN-031"),
|
||||
_m("E_GTC_UNSUITABLE", "error", "GTC lacks fields required by this command", "IN-031"),
|
||||
# --- manifests & cluster files (IN-040 .. IN-047) -------------------------------------------
|
||||
_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_BPM_CSV_MISMATCH", "error", "BPM and CSV manifests describe different loci", "IN-042"),
|
||||
_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_MANIFEST_NO_REFSTRAND", "warning", "Manifest lacks RefStrand annotation", "IN-040"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SimurgError(Exception):
|
||||
"""An error with a stable catalogue code and an exit code."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
exit_code: ExitCode = ExitCode.USAGE,
|
||||
hint: str | None = None,
|
||||
path: str | None = None,
|
||||
) -> None:
|
||||
if code not in CATALOGUE:
|
||||
raise ValueError(f"unknown message code {code!r}")
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.exit_code = exit_code
|
||||
self.hint = hint
|
||||
self.path = path
|
||||
super().__init__(f"[{code}] {message}" + (f" (hint: {hint})" if hint else ""))
|
||||
|
||||
|
||||
class FormatError(SimurgError):
|
||||
"""Malformed binary/text input. ``code`` names the file type (E_IDAT_FORMAT, E_GTC_CORRUPT, ...)."""
|
||||
|
||||
|
||||
def render_markdown() -> str:
|
||||
"""Render the catalogue as ``docs/messages.md``."""
|
||||
lines = [
|
||||
"# SimurgArray message catalogue",
|
||||
"",
|
||||
"Generated from `src/simurg/core/errors.py` — do not edit by hand.",
|
||||
"",
|
||||
"| Code | Level | Meaning | Spec |",
|
||||
"|---|---|---|---|",
|
||||
]
|
||||
for d in sorted(CATALOGUE.values(), key=lambda m: m.code):
|
||||
lines.append(f"| `{d.code}` | {d.level} | {d.summary} | {d.spec} |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
print(render_markdown(), end="")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Structured message reporting — spec CLI-007 (logging), CLI-008 (rate limiting), CLI-009 (run summary).
|
||||
|
||||
Messages carry a stable catalogue ``code``. Human-readable lines go to stderr; with ``--log-json`` each record is
|
||||
one JSON object per line (schema ``simurg.log/1``). A log file, when configured, always receives JSON Lines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
from simurg.core.errors import CATALOGUE
|
||||
|
||||
LOG_SCHEMA = "simurg.log/1"
|
||||
LEVELS = {"debug": 10, "info": 20, "warning": 30, "error": 40}
|
||||
MAX_PER_SAMPLE = 20
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reporter:
|
||||
command: str = ""
|
||||
run_id: str = ""
|
||||
level: str = "info"
|
||||
json_stderr: bool = False
|
||||
debug: bool = False
|
||||
quiet: bool = False
|
||||
log_file: Path | None = None
|
||||
stream: IO[str] = field(default_factory=lambda: sys.stderr)
|
||||
counts: Counter[str] = field(default_factory=Counter)
|
||||
_per_sample: Counter[tuple[str, str]] = field(default_factory=Counter)
|
||||
_suppressed: Counter[tuple[str, str]] = field(default_factory=Counter)
|
||||
_fh: IO[str] | None = None
|
||||
|
||||
def open(self) -> None:
|
||||
if self.log_file is not None:
|
||||
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = self.log_file.open("a", encoding="utf-8")
|
||||
|
||||
def close(self) -> None:
|
||||
self._flush_suppressed()
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
|
||||
# -- core ---------------------------------------------------------------------------------------------
|
||||
def emit(self, level: str, code: str | None, msg: str, *, exc: BaseException | None = None, **ctx: Any) -> None:
|
||||
if code:
|
||||
self.counts[code] += 1
|
||||
sample = ctx.get("sample_id") or ""
|
||||
if code and level == "warning":
|
||||
key = (code, sample)
|
||||
self._per_sample[key] += 1
|
||||
if self._per_sample[key] > MAX_PER_SAMPLE:
|
||||
self._suppressed[key] += 1
|
||||
self._write_file(level, code, msg, exc, ctx)
|
||||
return
|
||||
record_exc = "".join(traceback.format_exception(exc)) if (exc is not None and self.debug) else None
|
||||
self._write_file(level, code, msg, exc, ctx)
|
||||
if LEVELS.get(level, 20) < LEVELS.get(self.level, 20):
|
||||
return
|
||||
if self.quiet and level in ("info", "debug"):
|
||||
return
|
||||
if self.json_stderr:
|
||||
self.stream.write(json.dumps(self._record(level, code, msg, record_exc, ctx), ensure_ascii=False) + "\n")
|
||||
else:
|
||||
prefix = {"error": "ERROR", "warning": "WARNING", "info": "INFO", "debug": "DEBUG"}.get(level, level)
|
||||
code_s = f" [{code}]" if code else ""
|
||||
self.stream.write(f"{prefix}{code_s}: {msg}\n")
|
||||
if record_exc:
|
||||
self.stream.write(record_exc)
|
||||
self.stream.flush()
|
||||
|
||||
def _record(self, level: str, code: str | None, msg: str, exc: str | None, ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
rec: dict[str, Any] = {
|
||||
"schema": LOG_SCHEMA,
|
||||
"ts": _now(),
|
||||
"level": level,
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
"command": self.command,
|
||||
"run_id": self.run_id,
|
||||
"step": ctx.pop("step", None),
|
||||
"sample_id": ctx.pop("sample_id", None),
|
||||
"locus": ctx.pop("locus", None),
|
||||
"file": ctx.pop("file", None),
|
||||
"count": ctx.pop("count", 1),
|
||||
"elapsed_ms": ctx.pop("elapsed_ms", None),
|
||||
"exc": exc,
|
||||
}
|
||||
rec.update(ctx)
|
||||
return rec
|
||||
|
||||
def _write_file(
|
||||
self, level: str, code: str | None, msg: str, exc: BaseException | None, ctx: dict[str, Any]
|
||||
) -> None:
|
||||
if self._fh is None:
|
||||
return
|
||||
ex = "".join(traceback.format_exception(exc)) if exc is not None else None
|
||||
self._fh.write(json.dumps(self._record(level, code, msg, ex, dict(ctx)), ensure_ascii=False) + "\n")
|
||||
|
||||
def _flush_suppressed(self) -> None:
|
||||
for (code, sample), n in sorted(self._suppressed.items()):
|
||||
where = f" for {sample}" if sample else ""
|
||||
self.emit("info", None, f"{code} repeated {n} more times{where}")
|
||||
self._suppressed.clear()
|
||||
|
||||
# -- helpers ------------------------------------------------------------------------------------------
|
||||
def warn(self, code: str, msg: str, **ctx: Any) -> None:
|
||||
self.emit(CATALOGUE[code].level if code in CATALOGUE else "warning", code, msg, **ctx)
|
||||
|
||||
def info(self, msg: str, code: str | None = None, **ctx: Any) -> None:
|
||||
self.emit("info", code, msg, **ctx)
|
||||
|
||||
def error(self, code: str, msg: str, *, exc: BaseException | None = None, **ctx: Any) -> None:
|
||||
self.emit("error", code, msg, exc=exc, **ctx)
|
||||
|
||||
def warning_counts(self) -> dict[str, int]:
|
||||
return dict(sorted(self.counts.items()))
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Provenance and the run manifest — plan §15, spec IN-047, OUT-073, CLI-005.
|
||||
|
||||
Timestamps and the run ID live only in ``run_manifest.json`` so that data outputs stay byte-identical across runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from simurg import PRODUCT_NAME, __version__
|
||||
from simurg.core.atomic import atomic_write_text
|
||||
|
||||
MANIFEST_SCHEMA = "simurg.run_manifest/1"
|
||||
|
||||
|
||||
def sha256_file(path: str | Path, chunk: int = 1 << 20) -> str:
|
||||
h = hashlib.sha256()
|
||||
with Path(path).open("rb") as fh:
|
||||
while b := fh.read(chunk):
|
||||
h.update(b)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
"""Sortable, unique run ID: UTC timestamp + 8 random hex digits."""
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(4)
|
||||
|
||||
|
||||
def git_revision() -> str | None:
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
head = parent / ".git" / "HEAD"
|
||||
if head.is_file():
|
||||
ref = head.read_text().strip()
|
||||
if ref.startswith("ref: "):
|
||||
ref_file = parent / ".git" / ref[5:]
|
||||
return ref_file.read_text().strip()[:12] if ref_file.is_file() else None
|
||||
return ref[:12]
|
||||
return None
|
||||
|
||||
|
||||
def environment() -> dict[str, Any]:
|
||||
return {
|
||||
"python": sys.version.split()[0],
|
||||
"numpy": np.__version__,
|
||||
"platform": platform.platform(),
|
||||
"machine": platform.machine(),
|
||||
"cpu_count": os.cpu_count(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunManifest:
|
||||
command: str
|
||||
argv: list[str]
|
||||
run_id: str = field(default_factory=new_run_id)
|
||||
started: str = field(default_factory=lambda: _dt.datetime.now(_dt.UTC).isoformat())
|
||||
effective_options: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
inputs: list[dict[str, Any]] = field(default_factory=list)
|
||||
outputs: list[dict[str, Any]] = field(default_factory=list)
|
||||
sheet_header: dict[str, str] = field(default_factory=dict)
|
||||
failed_samples: list[dict[str, str]] = field(default_factory=list)
|
||||
summary: dict[str, Any] = field(default_factory=dict)
|
||||
schema_versions: dict[str, str] = field(default_factory=dict)
|
||||
status: str = "running"
|
||||
seed: int = 42
|
||||
|
||||
def set_option(self, name: str, value: Any, source: str) -> None:
|
||||
"""Record an effective option with its source ``cli|yaml:<path>|profile:<name>|default`` (CLI-005)."""
|
||||
self.effective_options[name] = {"value": value, "source": source}
|
||||
|
||||
def add_input(self, role: str, path: str | Path, *, hash_file: bool = True) -> None:
|
||||
p = Path(path)
|
||||
entry: dict[str, Any] = {"role": role, "path": str(p.resolve()), "name": p.name}
|
||||
if p.is_file():
|
||||
entry["size"] = p.stat().st_size
|
||||
if hash_file:
|
||||
entry["sha256"] = sha256_file(p)
|
||||
self.inputs.append(entry)
|
||||
|
||||
def add_output(self, path: str | Path, schema: str | None = None) -> None:
|
||||
p = Path(path)
|
||||
entry: dict[str, Any] = {"path": str(p.resolve())}
|
||||
if p.is_file():
|
||||
entry["size"] = p.stat().st_size
|
||||
entry["sha256"] = sha256_file(p)
|
||||
if schema:
|
||||
entry["schema"] = schema
|
||||
self.schema_versions[p.name] = schema
|
||||
self.outputs.append(entry)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": MANIFEST_SCHEMA,
|
||||
"product": PRODUCT_NAME,
|
||||
"version": __version__,
|
||||
"git_revision": git_revision(),
|
||||
"run_id": self.run_id,
|
||||
"command": self.command,
|
||||
"argv": self.argv,
|
||||
"status": self.status,
|
||||
"started": self.started,
|
||||
"finished": _dt.datetime.now(_dt.UTC).isoformat(),
|
||||
"seed": self.seed,
|
||||
"environment": environment(),
|
||||
"effective_options": self.effective_options,
|
||||
"inputs": self.inputs,
|
||||
"outputs": self.outputs,
|
||||
"sheet_header": self.sheet_header,
|
||||
"failed_samples": self.failed_samples,
|
||||
"summary": self.summary,
|
||||
"schema_versions": self.schema_versions,
|
||||
}
|
||||
|
||||
def write(self, path: str | Path) -> None:
|
||||
atomic_write_text(Path(path), json.dumps(self.to_dict(), indent=2, ensure_ascii=False) + "\n")
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray formats package."""
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Little-endian binary reading/writing helpers shared by the IDAT, BPM, EGT and GTC codecs.
|
||||
|
||||
All Illumina binary formats are little-endian. Strings are .NET ``BinaryWriter`` strings: a 7-bit varint
|
||||
(LEB128) length prefix followed by UTF-8 bytes (plan §6; BeadArrayFiles ``read_string``, BSD-2).
|
||||
Decoding is tolerant (spec IN-033): invalid UTF-8 becomes U+FFFD, while a length running past the end of the
|
||||
buffer is a :class:`~simurg.core.errors.FormatError`, never an uncaught exception.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import mmap
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from simurg.core.errors import FormatError
|
||||
|
||||
_I8 = struct.Struct("<b")
|
||||
_U8 = struct.Struct("<B")
|
||||
_I16 = struct.Struct("<h")
|
||||
_U16 = struct.Struct("<H")
|
||||
_I32 = struct.Struct("<i")
|
||||
_U32 = struct.Struct("<I")
|
||||
_I64 = struct.Struct("<q")
|
||||
_F32 = struct.Struct("<f")
|
||||
|
||||
GZIP_MAGIC = b"\x1f\x8b"
|
||||
|
||||
|
||||
def load_bytes(path: str | Path) -> bytes | mmap.mmap:
|
||||
"""Return the file content: memory-mapped when plain, decompressed when gzip."""
|
||||
p = Path(path)
|
||||
with p.open("rb") as fh:
|
||||
head = fh.read(2)
|
||||
if head == GZIP_MAGIC:
|
||||
fh.seek(0)
|
||||
with gzip.GzipFile(fileobj=fh) as gz:
|
||||
return gz.read()
|
||||
fh.seek(0, io.SEEK_END)
|
||||
if fh.tell() == 0:
|
||||
return b""
|
||||
return mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
|
||||
|
||||
class BinaryReader:
|
||||
"""Bounds-checked sequential reader over an immutable buffer.
|
||||
|
||||
``err_code`` is the catalogue code raised on malformed input (e.g. ``E_IDAT_FORMAT``).
|
||||
"""
|
||||
|
||||
__slots__ = ("_buf", "_view", "err_code", "path", "pos", "size")
|
||||
|
||||
def __init__(self, buf: bytes | mmap.mmap | memoryview, err_code: str, path: str | None = None) -> None:
|
||||
self._buf = buf
|
||||
self._view = memoryview(buf)
|
||||
self.size = len(self._view)
|
||||
self.pos = 0
|
||||
self.err_code = err_code
|
||||
self.path = path
|
||||
|
||||
# -- errors ------------------------------------------------------------------------------------
|
||||
def fail(self, message: str) -> FormatError:
|
||||
where = f"{self.path}: " if self.path else ""
|
||||
return FormatError(self.err_code, f"{where}{message}", path=self.path)
|
||||
|
||||
def _need(self, n: int, what: str) -> int:
|
||||
if n < 0 or self.pos + n > self.size:
|
||||
raise self.fail(f"unexpected end of file reading {what} at offset {self.pos} (need {n} bytes)")
|
||||
start = self.pos
|
||||
self.pos += n
|
||||
return start
|
||||
|
||||
# -- positioning -------------------------------------------------------------------------------
|
||||
def seek(self, offset: int, what: str = "section") -> None:
|
||||
if offset < 0 or offset > self.size:
|
||||
raise self.fail(f"{what} offset {offset} outside file (size {self.size})")
|
||||
self.pos = offset
|
||||
|
||||
def remaining(self) -> int:
|
||||
return self.size - self.pos
|
||||
|
||||
# -- scalars -----------------------------------------------------------------------------------
|
||||
def _unpack(self, s: struct.Struct, what: str) -> Any:
|
||||
start = self._need(s.size, what)
|
||||
return s.unpack_from(self._view, start)[0]
|
||||
|
||||
def i8(self, what: str = "int8") -> int:
|
||||
return int(self._unpack(_I8, what))
|
||||
|
||||
def u8(self, what: str = "uint8") -> int:
|
||||
return int(self._unpack(_U8, what))
|
||||
|
||||
def i16(self, what: str = "int16") -> int:
|
||||
return int(self._unpack(_I16, what))
|
||||
|
||||
def u16(self, what: str = "uint16") -> int:
|
||||
return int(self._unpack(_U16, what))
|
||||
|
||||
def i32(self, what: str = "int32") -> int:
|
||||
return int(self._unpack(_I32, what))
|
||||
|
||||
def u32(self, what: str = "uint32") -> int:
|
||||
return int(self._unpack(_U32, what))
|
||||
|
||||
def i64(self, what: str = "int64") -> int:
|
||||
return int(self._unpack(_I64, what))
|
||||
|
||||
def f32(self, what: str = "float32") -> float:
|
||||
return float(self._unpack(_F32, what))
|
||||
|
||||
def raw(self, n: int, what: str = "bytes") -> bytes:
|
||||
start = self._need(n, what)
|
||||
return bytes(self._view[start : start + n])
|
||||
|
||||
# -- strings -----------------------------------------------------------------------------------
|
||||
def varint_length(self, what: str = "string length") -> int:
|
||||
""".NET 7-bit encoded int (at most 5 bytes)."""
|
||||
result = 0
|
||||
shift = 0
|
||||
for _ in range(5):
|
||||
b = self.u8(what)
|
||||
result |= (b & 0x7F) << shift
|
||||
if not b & 0x80:
|
||||
return result
|
||||
shift += 7
|
||||
raise self.fail(f"invalid 7-bit length prefix for {what} at offset {self.pos}")
|
||||
|
||||
def string(self, what: str = "string") -> str:
|
||||
n = self.varint_length(what)
|
||||
start = self._need(n, what)
|
||||
return bytes(self._view[start : start + n]).decode("utf-8", errors="replace")
|
||||
|
||||
def skip_string(self, what: str = "string") -> None:
|
||||
n = self.varint_length(what)
|
||||
self._need(n, what)
|
||||
|
||||
# -- arrays ------------------------------------------------------------------------------------
|
||||
def array(self, dtype: npt.DTypeLike, count: int, what: str = "array") -> npt.NDArray[Any]:
|
||||
"""Zero-copy little-endian array view (read-only)."""
|
||||
dt = np.dtype(dtype).newbyteorder("<")
|
||||
if count < 0:
|
||||
raise self.fail(f"negative element count {count} for {what}")
|
||||
start = self._need(count * dt.itemsize, what)
|
||||
return np.frombuffer(self._view, dtype=dt, count=count, offset=start)
|
||||
|
||||
def counted_array(self, dtype: npt.DTypeLike, what: str = "array") -> npt.NDArray[Any]:
|
||||
"""int32 element count followed by the elements."""
|
||||
return self.array(dtype, self.i32(f"{what} count"), what)
|
||||
|
||||
|
||||
class BinaryWriter:
|
||||
"""Little-endian writer into an in-memory buffer."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buf = io.BytesIO()
|
||||
|
||||
def tell(self) -> int:
|
||||
return self._buf.tell()
|
||||
|
||||
def getvalue(self) -> bytes:
|
||||
return self._buf.getvalue()
|
||||
|
||||
def write(self, b: bytes) -> None:
|
||||
self._buf.write(b)
|
||||
|
||||
def u8(self, v: int) -> None:
|
||||
self._buf.write(_U8.pack(v))
|
||||
|
||||
def i16(self, v: int) -> None:
|
||||
self._buf.write(_I16.pack(v))
|
||||
|
||||
def u16(self, v: int) -> None:
|
||||
self._buf.write(_U16.pack(v))
|
||||
|
||||
def i32(self, v: int) -> None:
|
||||
self._buf.write(_I32.pack(v))
|
||||
|
||||
def u32(self, v: int) -> None:
|
||||
self._buf.write(_U32.pack(v))
|
||||
|
||||
def f32(self, v: float) -> None:
|
||||
self._buf.write(_F32.pack(v))
|
||||
|
||||
def string(self, s: str | bytes) -> None:
|
||||
data = s.encode("utf-8") if isinstance(s, str) else s
|
||||
n = len(data)
|
||||
while True:
|
||||
b = n & 0x7F
|
||||
n >>= 7
|
||||
if n:
|
||||
self._buf.write(bytes([b | 0x80]))
|
||||
else:
|
||||
self._buf.write(bytes([b]))
|
||||
break
|
||||
self._buf.write(data)
|
||||
|
||||
def array(self, arr: npt.ArrayLike, dtype: npt.DTypeLike) -> None:
|
||||
self._buf.write(np.ascontiguousarray(arr, dtype=np.dtype(dtype).newbyteorder("<")).tobytes())
|
||||
|
||||
def counted_array(self, arr: npt.ArrayLike, dtype: npt.DTypeLike) -> None:
|
||||
a = np.asarray(arr)
|
||||
self.i32(len(a))
|
||||
self.array(a, dtype)
|
||||
@@ -0,0 +1,449 @@
|
||||
"""BPM (binary bead-pool manifest) reader — plan §6.2, spec IN-040.
|
||||
|
||||
Layout (little-endian, .NET strings)::
|
||||
|
||||
char[3] "BPM"; uint8 1
|
||||
int32 version (flag 0x1000 is XOR-ed off; 3..5 supported)
|
||||
string manifest_name
|
||||
string control_config (version > 1)
|
||||
int32 num_loci
|
||||
int32[num_loci] indexes
|
||||
string[num_loci] names
|
||||
uint8[num_loci] norm_ids (< 100)
|
||||
num_loci × LocusEntry
|
||||
[int32 m; m × string] trailer header lines (optional)
|
||||
|
||||
LocusEntry field order follows gtc2vcf ``locusentry_read`` (MIT) and BeadArrayFiles ``LocusEntry`` (BSD-2).
|
||||
Loci are returned in **names order**, which is the order of every per-locus array in a GTC file.
|
||||
|
||||
Normalization-bin rule (plan §6.2)::
|
||||
|
||||
norm_id' = (norm_id + 100 * assay_type) mod 256 # mimics the vendor's byte wrap (Omni5)
|
||||
lookup = rank of norm_id' among the sorted distinct values
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from simurg.core.errors import FormatError
|
||||
from simurg.formats.binutil import BinaryReader, load_bytes
|
||||
|
||||
ASSAY_INF_II = 0
|
||||
ASSAY_INF_I_AT = 1 # red channel
|
||||
ASSAY_INF_I_GC = 2 # green channel
|
||||
|
||||
_I32 = struct.Struct("<i")
|
||||
_F32X4 = struct.Struct("<4f")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Manifest:
|
||||
"""Columnar manifest; every list/array has one entry per locus in names order."""
|
||||
|
||||
path: str
|
||||
format: str # "bpm" or "csv"
|
||||
version: int
|
||||
manifest_name: str
|
||||
control_config: str
|
||||
names: list[str]
|
||||
ilmn_ids: list[str]
|
||||
snps: list[str]
|
||||
chroms: list[str]
|
||||
map_infos: npt.NDArray[np.int64]
|
||||
ilmn_strands: list[str]
|
||||
source_strands: list[str]
|
||||
ref_strands: list[str]
|
||||
ploidies: list[str]
|
||||
species: list[str]
|
||||
genome_builds: list[str]
|
||||
sources: list[str]
|
||||
source_versions: list[str]
|
||||
customer_strands: list[str]
|
||||
source_seqs: list[str]
|
||||
top_genomic_seqs: list[str]
|
||||
allele_a_probe_seqs: list[str]
|
||||
allele_b_probe_seqs: list[str]
|
||||
address_a: npt.NDArray[np.int32]
|
||||
address_b: npt.NDArray[np.int32]
|
||||
assay_types: npt.NDArray[np.uint8]
|
||||
exp_clusters: npt.NDArray[np.int8]
|
||||
intensity_only: npt.NDArray[np.bool_]
|
||||
norm_ids: npt.NDArray[np.uint8] # raw per-locus IDs (< 100)
|
||||
entry_versions: npt.NDArray[np.int8]
|
||||
base_fractions: npt.NDArray[np.float32] | None = None # (n, 4): A, C, T, G (entry v >= 7)
|
||||
header_lines: list[str] = field(default_factory=list)
|
||||
controls: list[dict[str, str]] = field(default_factory=list)
|
||||
heading: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def num_loci(self) -> int:
|
||||
return len(self.names)
|
||||
|
||||
@property
|
||||
def has_ref_strand(self) -> bool:
|
||||
return any(s for s in self.ref_strands)
|
||||
|
||||
def norm_bin_ids(self) -> npt.NDArray[np.uint8]:
|
||||
"""``(norm_id + 100 * assay_type) mod 256`` per locus."""
|
||||
return ((self.norm_ids.astype(np.int64) + 100 * self.assay_types.astype(np.int64)) % 256).astype(np.uint8)
|
||||
|
||||
def normalization_lookups(self) -> npt.NDArray[np.int32]:
|
||||
"""Index into the GTC's normalization-transform list for every locus."""
|
||||
ids = self.norm_bin_ids()
|
||||
uniq = np.unique(ids) # sorted
|
||||
return np.searchsorted(uniq, ids).astype(np.int32)
|
||||
|
||||
def name_index(self) -> dict[str, int]:
|
||||
return {n: i for i, n in enumerate(self.names)}
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
at = self.assay_types
|
||||
return {
|
||||
"path": self.path,
|
||||
"format": self.format,
|
||||
"version": self.version,
|
||||
"manifest_name": self.manifest_name,
|
||||
"num_loci": self.num_loci,
|
||||
"entry_versions": sorted({int(v) for v in np.unique(self.entry_versions)}),
|
||||
"infinium_ii": int((at == ASSAY_INF_II).sum()),
|
||||
"infinium_i_at": int((at == ASSAY_INF_I_AT).sum()),
|
||||
"infinium_i_gc": int((at == ASSAY_INF_I_GC).sum()),
|
||||
"intensity_only": int(self.intensity_only.sum()),
|
||||
"normalization_bins": len(np.unique(self.norm_bin_ids())),
|
||||
"has_ref_strand": self.has_ref_strand,
|
||||
"n_controls": len(self.controls),
|
||||
"genome_builds": sorted(set(self.genome_builds) - {""}),
|
||||
"chromosomes": sorted(set(self.chroms), key=_chrom_key),
|
||||
}
|
||||
|
||||
|
||||
def _chrom_key(c: str) -> tuple[int, str]:
|
||||
return (int(c), "") if c.isdigit() else (1000, c)
|
||||
|
||||
|
||||
def parse_control_config(text: str) -> list[dict[str, str]]:
|
||||
"""Control-config lines ``addr[:addr..],Category,Color,Name`` → dicts."""
|
||||
out: list[dict[str, str]] = []
|
||||
for line in text.splitlines():
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
if len(parts) < 4 or not parts[0]:
|
||||
continue
|
||||
out.append({"address": parts[0], "category": parts[1], "color": parts[2], "name": ",".join(parts[3:])})
|
||||
return out
|
||||
|
||||
|
||||
def read_bpm(path: str | Path) -> Manifest:
|
||||
"""Parse a BPM file. Raises FormatError(E_BPM_FORMAT)."""
|
||||
spath = str(path)
|
||||
buf = load_bytes(path)
|
||||
r = BinaryReader(buf, "E_BPM_FORMAT", spath)
|
||||
if r.size < 8 or r.raw(3, "magic") != b"BPM":
|
||||
raise r.fail("bad magic (not a BPM file)")
|
||||
if (fmt := r.u8("format byte")) != 1:
|
||||
raise r.fail(f"unknown BPM format byte {fmt}")
|
||||
version = r.i32("version")
|
||||
if version & 0x1000:
|
||||
version ^= 0x1000
|
||||
if not 3 <= version <= 5:
|
||||
raise r.fail(f"unsupported BPM version {version}")
|
||||
manifest_name = r.string("manifest name")
|
||||
control_config = r.string("control config") if version > 1 else ""
|
||||
n = r.i32("number of loci")
|
||||
if n <= 0 or n > 50_000_000:
|
||||
raise r.fail(f"implausible number of loci {n}")
|
||||
r.array("<i4", n, "indexes")
|
||||
names = [r.string("locus name") for _ in range(n)]
|
||||
norm_ids = np.array(r.array("u1", n, "normalization ids"), dtype=np.uint8)
|
||||
if (norm_ids >= 100).any():
|
||||
raise r.fail("invalid normalization ID (>= 100)")
|
||||
name_to_idx: dict[str, int] = {}
|
||||
for i, nm in enumerate(names):
|
||||
if nm in name_to_idx:
|
||||
raise r.fail(f"locus name {nm!r} present more than once")
|
||||
name_to_idx[nm] = i
|
||||
|
||||
cols = _parse_locus_entries(r, n, name_to_idx)
|
||||
|
||||
header_lines: list[str] = []
|
||||
if r.remaining() >= 4:
|
||||
m = r.i32("trailer count")
|
||||
if 0 <= m <= 1_000_000:
|
||||
header_lines = [r.string("trailer line") for _ in range(m)]
|
||||
|
||||
return Manifest(
|
||||
path=spath,
|
||||
format="bpm",
|
||||
version=version,
|
||||
manifest_name=manifest_name,
|
||||
control_config=control_config,
|
||||
names=names,
|
||||
norm_ids=norm_ids,
|
||||
header_lines=header_lines,
|
||||
controls=parse_control_config(control_config),
|
||||
**cols,
|
||||
)
|
||||
|
||||
|
||||
def _parse_locus_entries(r: BinaryReader, n: int, name_to_idx: dict[str, int]) -> dict[str, Any]:
|
||||
"""Hot loop: parse ``n`` LocusEntry records (optimised pure Python; Rust in phase P9)."""
|
||||
view = memoryview(r._buf)
|
||||
size = r.size
|
||||
pos = r.pos
|
||||
intern: dict[bytes, str] = {}
|
||||
unpack_i32 = _I32.unpack_from
|
||||
unpack_4f = _F32X4.unpack_from
|
||||
|
||||
def fail(msg: str) -> FormatError:
|
||||
r.pos = pos
|
||||
return r.fail(msg)
|
||||
|
||||
def s_at(p: int) -> tuple[str, int]:
|
||||
# .NET 7-bit length-prefixed UTF-8 string, interned
|
||||
if p >= size:
|
||||
raise fail("unexpected end of file in locus entry")
|
||||
b = view[p]
|
||||
p += 1
|
||||
ln = b & 0x7F
|
||||
shift = 7
|
||||
while b & 0x80:
|
||||
if p >= size or shift > 28:
|
||||
raise fail("bad string length in locus entry")
|
||||
b = view[p]
|
||||
p += 1
|
||||
ln |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
end = p + ln
|
||||
if end > size:
|
||||
raise fail("string runs past end of file in locus entry")
|
||||
raw = bytes(view[p:end])
|
||||
s = intern.get(raw)
|
||||
if s is None:
|
||||
s = raw.decode("utf-8", errors="replace")
|
||||
if ln <= 64:
|
||||
intern[raw] = s
|
||||
return s, end
|
||||
|
||||
str_cols = [
|
||||
"ilmn_ids",
|
||||
"snps",
|
||||
"chroms",
|
||||
"ilmn_strands",
|
||||
"source_strands",
|
||||
"ref_strands",
|
||||
"ploidies",
|
||||
"species",
|
||||
"genome_builds",
|
||||
"sources",
|
||||
"source_versions",
|
||||
"customer_strands",
|
||||
"source_seqs",
|
||||
"top_genomic_seqs",
|
||||
"allele_a_probe_seqs",
|
||||
"allele_b_probe_seqs",
|
||||
]
|
||||
out: dict[str, list[str]] = {c: [""] * n for c in str_cols}
|
||||
map_infos = np.zeros(n, dtype=np.int64)
|
||||
address_a = np.zeros(n, dtype=np.int32)
|
||||
address_b = np.zeros(n, dtype=np.int32)
|
||||
assay = np.zeros(n, dtype=np.uint8)
|
||||
exp_clusters = np.zeros(n, dtype=np.int8)
|
||||
intensity_only = np.zeros(n, dtype=np.bool_)
|
||||
versions = np.zeros(n, dtype=np.int8)
|
||||
fracs = np.zeros((n, 4), dtype=np.float32)
|
||||
seen = np.zeros(n, dtype=np.bool_)
|
||||
any_v7 = False
|
||||
|
||||
o_ilmn, o_snp, o_chr = out["ilmn_ids"], out["snps"], out["chroms"]
|
||||
o_istr, o_sstr, o_rstr = out["ilmn_strands"], out["source_strands"], out["ref_strands"]
|
||||
o_ploidy, o_species, o_build = out["ploidies"], out["species"], out["genome_builds"]
|
||||
o_source, o_sver, o_cstr = out["sources"], out["source_versions"], out["customer_strands"]
|
||||
o_sseq, o_top, o_pa, o_pb = (
|
||||
out["source_seqs"],
|
||||
out["top_genomic_seqs"],
|
||||
out["allele_a_probe_seqs"],
|
||||
out["allele_b_probe_seqs"],
|
||||
)
|
||||
|
||||
for _ in range(n):
|
||||
if pos + 4 > size:
|
||||
raise fail("unexpected end of file at locus entry")
|
||||
ver = unpack_i32(view, pos)[0]
|
||||
pos += 4
|
||||
if ver not in (4, 6, 7, 8):
|
||||
raise fail(f"unsupported locus entry version {ver}")
|
||||
ilmn_id, pos = s_at(pos)
|
||||
name, pos = s_at(pos)
|
||||
_, pos = s_at(pos)
|
||||
_, pos = s_at(pos)
|
||||
_, pos = s_at(pos)
|
||||
if pos + 4 > size:
|
||||
raise fail("unexpected end of file at locus index")
|
||||
pos += 4 # index (1-based); BeadArrayFiles maps entries by name, as we do
|
||||
_, pos = s_at(pos)
|
||||
ilmn_strand, pos = s_at(pos)
|
||||
snp, pos = s_at(pos)
|
||||
chrom, pos = s_at(pos)
|
||||
ploidy, pos = s_at(pos)
|
||||
species, pos = s_at(pos)
|
||||
map_info, pos = s_at(pos)
|
||||
top_seq, pos = s_at(pos)
|
||||
cust_strand, pos = s_at(pos)
|
||||
if pos + 8 > size:
|
||||
raise fail("unexpected end of file at addresses")
|
||||
a_addr = unpack_i32(view, pos)[0]
|
||||
b_addr = unpack_i32(view, pos + 4)[0]
|
||||
pos += 8
|
||||
probe_a, pos = s_at(pos)
|
||||
probe_b, pos = s_at(pos)
|
||||
build, pos = s_at(pos)
|
||||
source, pos = s_at(pos)
|
||||
source_ver, pos = s_at(pos)
|
||||
source_strand, pos = s_at(pos)
|
||||
source_seq, pos = s_at(pos)
|
||||
at = -1
|
||||
exp_cl = 0
|
||||
int_only = 0
|
||||
if ver >= 6:
|
||||
if pos + 4 > size:
|
||||
raise fail("unexpected end of file at assay type")
|
||||
exp_cl = int.from_bytes(view[pos + 1 : pos + 2], "little", signed=True)
|
||||
int_only = view[pos + 2]
|
||||
at = view[pos + 3]
|
||||
pos += 4
|
||||
if at > 2:
|
||||
raise fail(f"invalid assay type {at}")
|
||||
if (b_addr == 0) != (at == 0):
|
||||
raise fail("assay type is inconsistent with address B")
|
||||
f4 = None
|
||||
if ver >= 7:
|
||||
if pos + 16 > size:
|
||||
raise fail("unexpected end of file at base fractions")
|
||||
f4 = unpack_4f(view, pos)
|
||||
pos += 16
|
||||
ref_strand = ""
|
||||
if ver >= 8:
|
||||
ref_strand, pos = s_at(pos)
|
||||
|
||||
idx = name_to_idx.get(name)
|
||||
if idx is None:
|
||||
raise fail(f"locus entry {name!r} not in the names table")
|
||||
if seen[idx]:
|
||||
raise fail(f"locus entry {name!r} appears twice")
|
||||
seen[idx] = True
|
||||
if at < 0:
|
||||
at = infer_assay_type(probe_a, probe_b, source_seq)
|
||||
o_ilmn[idx] = ilmn_id
|
||||
o_snp[idx] = snp
|
||||
o_chr[idx] = chrom
|
||||
o_istr[idx] = ilmn_strand
|
||||
o_sstr[idx] = source_strand
|
||||
o_rstr[idx] = ref_strand
|
||||
o_ploidy[idx] = ploidy
|
||||
o_species[idx] = species
|
||||
o_build[idx] = build
|
||||
o_source[idx] = source
|
||||
o_sver[idx] = source_ver
|
||||
o_cstr[idx] = cust_strand
|
||||
o_sseq[idx] = source_seq
|
||||
o_top[idx] = top_seq
|
||||
o_pa[idx] = probe_a
|
||||
o_pb[idx] = probe_b
|
||||
try:
|
||||
map_infos[idx] = int(map_info) if map_info else 0
|
||||
except ValueError:
|
||||
raise fail(f"non-integer MapInfo {map_info!r} for {name!r}") from None
|
||||
address_a[idx] = a_addr
|
||||
address_b[idx] = b_addr
|
||||
assay[idx] = at
|
||||
exp_clusters[idx] = exp_cl
|
||||
intensity_only[idx] = bool(int_only)
|
||||
versions[idx] = ver
|
||||
if f4 is not None:
|
||||
fracs[idx] = f4
|
||||
any_v7 = True
|
||||
|
||||
if not seen.all():
|
||||
raise fail(f"{int((~seen).sum())} loci in the names table have no locus entry")
|
||||
r.pos = pos
|
||||
return {
|
||||
**out,
|
||||
"map_infos": map_infos,
|
||||
"address_a": address_a,
|
||||
"address_b": address_b,
|
||||
"assay_types": assay,
|
||||
"exp_clusters": exp_clusters,
|
||||
"intensity_only": intensity_only,
|
||||
"entry_versions": versions,
|
||||
"base_fractions": fracs if any_v7 else None,
|
||||
}
|
||||
|
||||
|
||||
_IUPAC = {
|
||||
"A": 1,
|
||||
"C": 2,
|
||||
"G": 4,
|
||||
"T": 8,
|
||||
"R": 5,
|
||||
"Y": 10,
|
||||
"S": 6,
|
||||
"W": 9,
|
||||
"K": 12,
|
||||
"M": 3,
|
||||
"B": 14,
|
||||
"D": 13,
|
||||
"H": 11,
|
||||
"V": 7,
|
||||
"N": 15,
|
||||
}
|
||||
|
||||
|
||||
def infer_assay_type(probe_a: str, probe_b: str, source_seq: str) -> int:
|
||||
"""Assay type for locus entries older than v6 (gtc2vcf ``get_assay_type``, MIT).
|
||||
|
||||
Returns 0 for Infinium II, 1 for Infinium I red (A/T), 2 for Infinium I green (C/G), 255 if unknown.
|
||||
"""
|
||||
if not probe_a or not source_seq:
|
||||
return 255
|
||||
if not probe_b:
|
||||
return 0
|
||||
left = source_seq.find("[")
|
||||
right = source_seq.find("]")
|
||||
if left <= 0 or right < 0 or right + 1 >= len(source_seq):
|
||||
return 255
|
||||
tl = source_seq[left - 1].upper()
|
||||
tr = source_seq[right + 1].upper()
|
||||
if tl in "AT" and tr in "AT":
|
||||
return 1
|
||||
if tl in "CG" and tr in "CG":
|
||||
return 2
|
||||
i = 2
|
||||
while i <= min(len(probe_a), len(probe_b)) and not (
|
||||
_IUPAC.get(probe_a[-i].upper(), 0) & _IUPAC.get(probe_b[-i].upper(), 0)
|
||||
):
|
||||
i += 1
|
||||
if i > min(len(probe_a), len(probe_b)):
|
||||
return 255
|
||||
t = probe_a[-i].upper()
|
||||
if t in "CGS":
|
||||
return 1
|
||||
if t in "ATW":
|
||||
return 2
|
||||
rules = {
|
||||
("Y", "G"): 1,
|
||||
("Y", "T"): 1,
|
||||
("Y", "A"): 2,
|
||||
("K", "C"): 1,
|
||||
("K", "A"): 2,
|
||||
("M", "G"): 1,
|
||||
("M", "T"): 2,
|
||||
("R", "C"): 1,
|
||||
("R", "T"): 2,
|
||||
}
|
||||
return rules.get((t, tr), 255)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""CSV manifest reader — plan §6.3, spec IN-041/IN-042.
|
||||
|
||||
Sections: ``[Heading]`` (key,value), ``[Assay]`` (header row + one row per locus), ``[Controls]``
|
||||
(``addr[:addr..],Category,Color,Name``). The CSV carries ``SourceSeq``/``TopGenomicSeq`` (needed for indels)
|
||||
but **no normalization IDs** — genotyping always needs the BPM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from simurg.core.errors import FormatError
|
||||
from simurg.formats.bpm import Manifest, infer_assay_type
|
||||
|
||||
REQUIRED_COLUMNS = ("IlmnID", "Name", "SNP", "AddressA_ID", "Chr", "MapInfo")
|
||||
_SEQUENCE_COLUMNS = ("SourceSeq", "TopGenomicSeq", "AlleleA_ProbeSeq", "AlleleB_ProbeSeq")
|
||||
|
||||
|
||||
def _open_text(path: Path) -> io.TextIOBase:
|
||||
raw = path.open("rb")
|
||||
head = raw.read(2)
|
||||
raw.seek(0)
|
||||
if head == b"\x1f\x8b":
|
||||
import gzip
|
||||
|
||||
return io.TextIOWrapper(gzip.GzipFile(fileobj=raw), encoding="utf-8-sig", errors="replace", newline="")
|
||||
return io.TextIOWrapper(raw, encoding="utf-8-sig", errors="replace", newline="")
|
||||
|
||||
|
||||
def _int_or_zero(s: str) -> int:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return 0
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return int(float(s))
|
||||
|
||||
|
||||
def read_csv_manifest(path: str | Path, *, load_sequences: bool = True) -> Manifest:
|
||||
"""Parse a CSV manifest into the same columnar :class:`Manifest` as :func:`read_bpm`.
|
||||
|
||||
``load_sequences=False`` skips probe/source sequences to save memory. Raises E_CSV_MANIFEST_FORMAT.
|
||||
"""
|
||||
p = Path(path)
|
||||
spath = str(p)
|
||||
|
||||
def fail(msg: str) -> FormatError:
|
||||
return FormatError("E_CSV_MANIFEST_FORMAT", f"{spath}: {msg}", path=spath)
|
||||
|
||||
heading: dict[str, str] = {}
|
||||
controls: list[dict[str, str]] = []
|
||||
cols: dict[str, list[str]] = {}
|
||||
header: list[str] | None = None
|
||||
section = None
|
||||
intern: dict[str, str] = {}
|
||||
|
||||
def it(s: str) -> str:
|
||||
v = intern.get(s)
|
||||
if v is None:
|
||||
intern[s] = s
|
||||
v = s
|
||||
return v
|
||||
|
||||
with _open_text(p) as fh:
|
||||
for row in csv.reader(fh):
|
||||
if not row or all(not c.strip() for c in row):
|
||||
continue
|
||||
first = row[0].strip()
|
||||
if first.startswith("[") and first.endswith("]"):
|
||||
section = first[1:-1].strip().lower()
|
||||
continue
|
||||
if section == "heading" and len(row) >= 2:
|
||||
heading[first.rstrip()] = row[1].strip()
|
||||
elif section == "heading":
|
||||
continue
|
||||
elif section == "assay":
|
||||
if header is None:
|
||||
header = [c.strip() for c in row]
|
||||
missing = [c for c in REQUIRED_COLUMNS if c not in header]
|
||||
if missing:
|
||||
raise fail(f"[Assay] section lacks required columns: {', '.join(missing)}")
|
||||
keep = [c for c in header if load_sequences or c not in _SEQUENCE_COLUMNS]
|
||||
cols = {c: [] for c in keep}
|
||||
idx = [(c, header.index(c)) for c in keep]
|
||||
continue
|
||||
if len(row) < len(header):
|
||||
row = row + [""] * (len(header) - len(row))
|
||||
for c, i in idx:
|
||||
v = row[i]
|
||||
cols[c].append(
|
||||
v
|
||||
if c in ("IlmnID", "Name", "SourceSeq", "TopGenomicSeq", "AlleleA_ProbeSeq", "AlleleB_ProbeSeq")
|
||||
else it(v)
|
||||
)
|
||||
elif section == "controls" and len(row) >= 4:
|
||||
controls.append(
|
||||
{
|
||||
"address": first,
|
||||
"category": row[1].strip(),
|
||||
"color": row[2].strip(),
|
||||
"name": ",".join(row[3:]).strip(),
|
||||
}
|
||||
)
|
||||
if header is None:
|
||||
raise fail("no [Assay] section")
|
||||
n = len(cols["Name"])
|
||||
if n == 0:
|
||||
raise fail("[Assay] section has no rows")
|
||||
|
||||
def col(name: str) -> list[str]:
|
||||
return cols.get(name) or [""] * n
|
||||
|
||||
try:
|
||||
addr_a = np.array([_int_or_zero(v) for v in cols["AddressA_ID"]], dtype=np.int32)
|
||||
addr_b = np.array([_int_or_zero(v) for v in col("AddressB_ID")], dtype=np.int32)
|
||||
map_info = np.array([_int_or_zero(v) for v in cols["MapInfo"]], dtype=np.int64)
|
||||
except ValueError as e:
|
||||
raise fail(f"non-numeric address or MapInfo value ({e})") from None
|
||||
probe_a, probe_b, src = col("AlleleA_ProbeSeq"), col("AlleleB_ProbeSeq"), col("SourceSeq")
|
||||
assay = np.zeros(n, dtype=np.uint8)
|
||||
for k in np.flatnonzero(addr_b).tolist():
|
||||
assay[k] = infer_assay_type(probe_a[k], probe_b[k], src[k]) if load_sequences else 255
|
||||
exp = np.array([_int_or_zero(v) for v in col("Exp_Clusters")], dtype=np.int8)
|
||||
int_only = np.array([_int_or_zero(v) != 0 for v in col("Intensity_Only")], dtype=np.bool_)
|
||||
|
||||
return Manifest(
|
||||
path=spath,
|
||||
format="csv",
|
||||
version=0,
|
||||
manifest_name=heading.get("Descriptor File Name", p.name),
|
||||
control_config="",
|
||||
names=cols["Name"],
|
||||
ilmn_ids=cols["IlmnID"],
|
||||
snps=cols["SNP"],
|
||||
chroms=cols["Chr"],
|
||||
map_infos=map_info,
|
||||
ilmn_strands=col("IlmnStrand"),
|
||||
source_strands=col("SourceStrand"),
|
||||
ref_strands=col("RefStrand"),
|
||||
ploidies=col("Ploidy"),
|
||||
species=col("Species"),
|
||||
genome_builds=col("GenomeBuild"),
|
||||
sources=col("Source"),
|
||||
source_versions=col("SourceVersion"),
|
||||
customer_strands=[""] * n,
|
||||
source_seqs=src,
|
||||
top_genomic_seqs=col("TopGenomicSeq"),
|
||||
allele_a_probe_seqs=probe_a,
|
||||
allele_b_probe_seqs=probe_b,
|
||||
address_a=addr_a,
|
||||
address_b=addr_b,
|
||||
assay_types=assay,
|
||||
exp_clusters=exp,
|
||||
intensity_only=int_only,
|
||||
norm_ids=np.zeros(n, dtype=np.uint8),
|
||||
entry_versions=np.zeros(n, dtype=np.int8),
|
||||
controls=controls,
|
||||
heading=heading,
|
||||
)
|
||||
|
||||
|
||||
def assay_format(manifest: Manifest) -> str:
|
||||
"""Platform code from the ``Assay Format`` heading (e.g. ``Infinium EX`` → ``EX``; ``Infinium LCG`` → ``LCG``)."""
|
||||
v = manifest.heading.get("Assay Format", "")
|
||||
return v.split()[-1] if v else ""
|
||||
|
||||
|
||||
def compare_manifests(bpm: Manifest, csvm: Manifest, max_examples: int = 10) -> list[str]:
|
||||
"""BPM ↔ CSV consistency (spec IN-042). Returns human-readable differences (empty list = consistent)."""
|
||||
problems: list[str] = []
|
||||
if bpm.num_loci != csvm.num_loci:
|
||||
problems.append(f"locus count differs: BPM {bpm.num_loci}, CSV {csvm.num_loci}")
|
||||
bidx = bpm.name_index()
|
||||
cidx = csvm.name_index()
|
||||
only_b = [n for n in bpm.names if n not in cidx]
|
||||
only_c = [n for n in csvm.names if n not in bidx]
|
||||
if only_b:
|
||||
problems.append(f"{len(only_b)} loci only in BPM, e.g. {', '.join(only_b[:max_examples])}")
|
||||
if only_c:
|
||||
problems.append(f"{len(only_c)} loci only in CSV, e.g. {', '.join(only_c[:max_examples])}")
|
||||
fields: list[tuple[str, Any, Any]] = [
|
||||
("Chr", bpm.chroms, csvm.chroms),
|
||||
("MapInfo", bpm.map_infos, csvm.map_infos),
|
||||
("SNP", bpm.snps, csvm.snps),
|
||||
("AddressA", bpm.address_a, csvm.address_a),
|
||||
("AddressB", bpm.address_b, csvm.address_b),
|
||||
]
|
||||
for label, bcol, ccol in fields:
|
||||
bad: list[str] = []
|
||||
count = 0
|
||||
for name, i in bidx.items():
|
||||
j = cidx.get(name)
|
||||
if j is None:
|
||||
continue
|
||||
if bcol[i] != ccol[j]:
|
||||
count += 1
|
||||
if len(bad) < max_examples:
|
||||
bad.append(f"{name} ({bcol[i]} vs {ccol[j]})")
|
||||
if count:
|
||||
problems.append(f"{label} differs for {count} loci, e.g. {'; '.join(bad)}")
|
||||
return problems
|
||||
@@ -0,0 +1,42 @@
|
||||
"""File-type sniffing for ``simurg inspect`` (spec CLI-020)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
|
||||
KINDS = ("idat", "bpm", "egt", "gtc", "csv_manifest", "samplesheet", "vcf", "unknown")
|
||||
|
||||
|
||||
def _head(path: Path, n: int = 4096) -> bytes:
|
||||
with path.open("rb") as fh:
|
||||
b = fh.read(n)
|
||||
if b[:2] == b"\x1f\x8b":
|
||||
try:
|
||||
with gzip.open(path, "rb") as gz:
|
||||
return gz.read(n)
|
||||
except OSError:
|
||||
return b
|
||||
return b
|
||||
|
||||
|
||||
def detect_kind(path: str | Path) -> str:
|
||||
p = Path(path)
|
||||
head = _head(p)
|
||||
name = p.name.lower()
|
||||
if head[:4] == b"IDAT":
|
||||
return "idat"
|
||||
if head[:3] == b"BPM":
|
||||
return "bpm"
|
||||
if head[:3] == b"gtc":
|
||||
return "gtc"
|
||||
if head.startswith(b"##fileformat=VCF"):
|
||||
return "vcf"
|
||||
if name.endswith(".egt") or (len(head) >= 5 and head[:4] == b"\x03\x00\x00\x00"):
|
||||
return "egt"
|
||||
text = head.decode("utf-8", errors="replace").lower()
|
||||
if "[assay]" in text or ("[heading]" in text and "descriptor file name" in text):
|
||||
return "csv_manifest"
|
||||
if "sentrixbarcode_a" in text or "beadchipname" in text or "[data]" in text:
|
||||
return "samplesheet"
|
||||
return "unknown"
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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
|
||||
@@ -0,0 +1,235 @@
|
||||
"""EGT cluster-file reader — plan §6.4, spec IN-044.
|
||||
|
||||
Layout (little-endian, .NET strings)::
|
||||
|
||||
int32 version (=3); string gencall_version, cluster_version, call_version, normalization_version, date_created
|
||||
uint8 is_wgt (=1); string manifest_name; int32 data_block_version; string opa; int32 num_records
|
||||
num_records × ClusterRecord (fixed size: 3 int32 + 12 float32 + extra floats)
|
||||
num_records × ClusterScore (float sep, float total(GenTrain), float original, uint8 edited)
|
||||
num_records × string genotype (unused)
|
||||
num_records × string locus_name (join to the manifest BY NAME)
|
||||
num_records × int32 address
|
||||
num_records × int32[3] cluster counts (must equal the N in each record)
|
||||
[data_block_version 9: num_records × float, meaning unknown]
|
||||
|
||||
The number of extra floats per record differs between open readers (BeadArrayFiles: 2 for dbv<5, 15 for
|
||||
dbv≥6; gtc2vcf: 0 for dbv 5, 15 for dbv≥7), and the intensity threshold is read for dbv 9 only
|
||||
(BeadArrayFiles) or for dbv≥7 (gtc2vcf) — plan §20 U3. We try the candidate record sizes in order and accept
|
||||
the first whose trailing cluster counts match the per-record N (a strong structural check).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from simurg.core.errors import FormatError
|
||||
from simurg.formats.binutil import BinaryReader, load_bytes
|
||||
|
||||
_BASE_FIELDS = [
|
||||
("n", "<i4", (3,)),
|
||||
("r_dev", "<f4", (3,)),
|
||||
("r_mean", "<f4", (3,)),
|
||||
("theta_dev", "<f4", (3,)),
|
||||
("theta_mean", "<f4", (3,)),
|
||||
]
|
||||
_SCORE_DTYPE = np.dtype([("cluster_sep", "<f4"), ("total_score", "<f4"), ("original_score", "<f4"), ("edited", "u1")])
|
||||
|
||||
|
||||
def _record_dtype(extra: int) -> np.dtype[Any]:
|
||||
fields: list[Any] = list(_BASE_FIELDS)
|
||||
if extra:
|
||||
fields.append(("extra", "<f4", (extra,)))
|
||||
return np.dtype(fields)
|
||||
|
||||
|
||||
def _extra_candidates(dbv: int) -> list[int]:
|
||||
if dbv < 5:
|
||||
return [2, 0]
|
||||
if dbv == 5:
|
||||
return [0, 2, 15]
|
||||
return [15, 0, 2]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterFile:
|
||||
"""Columnar EGT. Arrays with a trailing axis of 3 are ordered (AA, AB, BB)."""
|
||||
|
||||
path: str
|
||||
version: int
|
||||
gencall_version: str
|
||||
cluster_version: str
|
||||
call_version: str
|
||||
normalization_version: str
|
||||
date_created: str
|
||||
manifest_name: str
|
||||
data_block_version: int
|
||||
opa: str
|
||||
names: list[str]
|
||||
addresses: npt.NDArray[np.int32]
|
||||
n: npt.NDArray[np.int32] # (records, 3)
|
||||
theta_mean: npt.NDArray[np.float32]
|
||||
theta_dev: npt.NDArray[np.float32]
|
||||
r_mean: npt.NDArray[np.float32]
|
||||
r_dev: npt.NDArray[np.float32]
|
||||
intensity_threshold: npt.NDArray[np.float32] # NaN where not stored
|
||||
cluster_sep: npt.NDArray[np.float32]
|
||||
gentrain_score: npt.NDArray[np.float32]
|
||||
original_score: npt.NDArray[np.float32]
|
||||
edited: npt.NDArray[np.bool_]
|
||||
extra_floats: npt.NDArray[np.float32] | None
|
||||
trailer_floats: npt.NDArray[np.float32] | None
|
||||
extra_per_record: int
|
||||
|
||||
@property
|
||||
def num_records(self) -> int:
|
||||
return len(self.names)
|
||||
|
||||
def name_index(self) -> dict[str, int]:
|
||||
return {nm: i for i, nm in enumerate(self.names)}
|
||||
|
||||
def mean_r(self) -> npt.NDArray[np.float64]:
|
||||
"""Count-weighted mean R — the expected R of intensity-only loci (plan §6.4)."""
|
||||
w = self.n.astype(np.float64)
|
||||
tot = w.sum(axis=1)
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
return np.asarray((w * self.r_mean).sum(axis=1) / tot, dtype=np.float64)
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
thr = self.intensity_threshold
|
||||
return {
|
||||
"path": self.path,
|
||||
"version": self.version,
|
||||
"data_block_version": self.data_block_version,
|
||||
"extra_floats_per_record": self.extra_per_record,
|
||||
"num_records": self.num_records,
|
||||
"manifest_name": self.manifest_name,
|
||||
"gencall_version": self.gencall_version,
|
||||
"cluster_version": self.cluster_version,
|
||||
"call_version": self.call_version,
|
||||
"normalization_version": self.normalization_version,
|
||||
"date_created": self.date_created,
|
||||
"intensity_threshold_nonzero": int(np.count_nonzero(np.nan_to_num(thr))),
|
||||
"edited_records": int(self.edited.sum()),
|
||||
"zero_gentrain": int((self.gentrain_score == 0).sum()),
|
||||
}
|
||||
|
||||
|
||||
def read_egt(path: str | Path) -> ClusterFile:
|
||||
"""Parse an EGT cluster file. Raises FormatError(E_EGT_LOAD)."""
|
||||
spath = str(path)
|
||||
buf = load_bytes(path)
|
||||
r = BinaryReader(buf, "E_EGT_LOAD", spath)
|
||||
version = r.i32("version")
|
||||
if version != 3:
|
||||
raise r.fail(f"unsupported cluster file version {version}")
|
||||
gencall_version = r.string()
|
||||
cluster_version = r.string()
|
||||
call_version = r.string()
|
||||
normalization_version = r.string()
|
||||
date_created = r.string()
|
||||
if (is_wgt := r.u8("is_wgt")) != 1:
|
||||
raise r.fail(f"only WGT cluster files are supported (is_wgt={is_wgt})")
|
||||
manifest_name = r.string()
|
||||
dbv = r.i32("data block version")
|
||||
if not 1 <= dbv <= 9:
|
||||
raise r.fail(f"unsupported data block version {dbv}")
|
||||
opa = r.string()
|
||||
nrec = r.i32("number of records")
|
||||
if nrec <= 0 or nrec > 50_000_000:
|
||||
raise r.fail(f"implausible number of records {nrec}")
|
||||
records_start = r.pos
|
||||
|
||||
last_error: FormatError | None = None
|
||||
for extra in _extra_candidates(dbv):
|
||||
try:
|
||||
return _parse_body(
|
||||
r,
|
||||
records_start,
|
||||
nrec,
|
||||
extra,
|
||||
dbv,
|
||||
spath,
|
||||
version,
|
||||
gencall_version,
|
||||
cluster_version,
|
||||
call_version,
|
||||
normalization_version,
|
||||
date_created,
|
||||
manifest_name,
|
||||
opa,
|
||||
)
|
||||
except FormatError as e:
|
||||
last_error = e
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
|
||||
def _parse_body(
|
||||
r: BinaryReader,
|
||||
start: int,
|
||||
nrec: int,
|
||||
extra: int,
|
||||
dbv: int,
|
||||
spath: str,
|
||||
version: int,
|
||||
gencall_version: str,
|
||||
cluster_version: str,
|
||||
call_version: str,
|
||||
normalization_version: str,
|
||||
date_created: str,
|
||||
manifest_name: str,
|
||||
opa: str,
|
||||
) -> ClusterFile:
|
||||
r.seek(start)
|
||||
rec = r.array(_record_dtype(extra), nrec, "cluster records")
|
||||
scores = r.array(_SCORE_DTYPE, nrec, "cluster scores")
|
||||
for _ in range(nrec):
|
||||
r.skip_string("genotype")
|
||||
names = [r.string("locus name") for _ in range(nrec)]
|
||||
addresses = np.array(r.array("<i4", nrec, "addresses"), dtype=np.int32)
|
||||
counts = r.array("<i4", nrec * 3, "cluster counts").reshape(nrec, 3)
|
||||
n = np.array(rec["n"], dtype=np.int32)
|
||||
if not np.array_equal(counts, n):
|
||||
raise r.fail(f"cluster counts do not match record N (assuming {extra} extra floats per record)")
|
||||
trailer = None
|
||||
if dbv == 9 and r.remaining() >= 4 * nrec:
|
||||
trailer = np.array(r.array("<f4", nrec, "trailer floats"), dtype=np.float32)
|
||||
if len(set(names)) != nrec:
|
||||
raise r.fail("duplicate locus names")
|
||||
extra_arr = np.array(rec["extra"], dtype=np.float32) if extra else None
|
||||
if extra_arr is not None and dbv >= 7 and extra == 15:
|
||||
thr = extra_arr[:, 0].copy()
|
||||
else:
|
||||
thr = np.full(nrec, np.nan, dtype=np.float32)
|
||||
return ClusterFile(
|
||||
path=spath,
|
||||
version=version,
|
||||
gencall_version=gencall_version,
|
||||
cluster_version=cluster_version,
|
||||
call_version=call_version,
|
||||
normalization_version=normalization_version,
|
||||
date_created=date_created,
|
||||
manifest_name=manifest_name,
|
||||
data_block_version=dbv,
|
||||
opa=opa,
|
||||
names=names,
|
||||
addresses=addresses,
|
||||
n=n,
|
||||
theta_mean=np.array(rec["theta_mean"], dtype=np.float32),
|
||||
theta_dev=np.array(rec["theta_dev"], dtype=np.float32),
|
||||
r_mean=np.array(rec["r_mean"], dtype=np.float32),
|
||||
r_dev=np.array(rec["r_dev"], dtype=np.float32),
|
||||
intensity_threshold=thr,
|
||||
cluster_sep=np.array(scores["cluster_sep"], dtype=np.float32),
|
||||
gentrain_score=np.array(scores["total_score"], dtype=np.float32),
|
||||
original_score=np.array(scores["original_score"], dtype=np.float32),
|
||||
edited=np.array(scores["edited"] != 0),
|
||||
extra_floats=extra_arr,
|
||||
trailer_floats=trailer,
|
||||
extra_per_record=extra,
|
||||
)
|
||||
@@ -0,0 +1,548 @@
|
||||
"""GTC genotype file (versions 3–5) reader and writer — plan §6.5, spec IN-031/IN-032/IN-033, OUT-001.
|
||||
|
||||
Layout::
|
||||
|
||||
char[3] "gtc"; uint8 version; int32 M; M × {uint16 id; uint32 offset}
|
||||
(for ids 1, 2, 3 the "offset" IS the value)
|
||||
|
||||
Authoritative description: ``docs/GTC_File_Format_v5.pdf`` in Illumina BeadArrayFiles (BSD-2); canonical write
|
||||
order follows gtc2vcf ``gtc_write`` (MIT). The reader records the TOC order and the on-disk data order, so a
|
||||
file can be re-written byte-for-byte (P1 acceptance: round-trip).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from simurg.formats.binutil import BinaryReader, BinaryWriter, load_bytes
|
||||
|
||||
NUM_SNPS = 1
|
||||
PLOIDY = 2
|
||||
PLOIDY_TYPE = 3
|
||||
SAMPLE_NAME = 10
|
||||
SAMPLE_PLATE = 11
|
||||
SAMPLE_WELL = 12
|
||||
CLUSTER_FILE = 100
|
||||
SNP_MANIFEST = 101
|
||||
IMAGING_DATE = 200
|
||||
AUTOCALL_DATE = 201
|
||||
AUTOCALL_VERSION = 300
|
||||
NORMALIZATION_TRANSFORMS = 400
|
||||
CONTROLS_X = 500
|
||||
CONTROLS_Y = 501
|
||||
RAW_X = 1000
|
||||
RAW_Y = 1001
|
||||
GENOTYPES = 1002
|
||||
BASE_CALLS = 1003
|
||||
GENOTYPE_SCORES = 1004
|
||||
SCANNER_DATA = 1005
|
||||
CALL_RATE = 1006
|
||||
GENDER = 1007
|
||||
LOGR_DEV = 1008
|
||||
GC10 = 1009
|
||||
DX = 1010
|
||||
SAMPLE_DATA = 1011
|
||||
B_ALLELE_FREQS = 1012
|
||||
LOGR_RATIOS = 1013
|
||||
PERCENTILES_X = 1014
|
||||
PERCENTILES_Y = 1015
|
||||
SLIDE_IDENTIFIER = 1016
|
||||
|
||||
INLINE_IDS = (NUM_SNPS, PLOIDY, PLOIDY_TYPE)
|
||||
|
||||
TOC_NAMES: dict[int, str] = {
|
||||
NUM_SNPS: "num_snps",
|
||||
PLOIDY: "ploidy",
|
||||
PLOIDY_TYPE: "ploidy_type",
|
||||
SAMPLE_NAME: "sample_name",
|
||||
SAMPLE_PLATE: "sample_plate",
|
||||
SAMPLE_WELL: "sample_well",
|
||||
CLUSTER_FILE: "cluster_file",
|
||||
SNP_MANIFEST: "snp_manifest",
|
||||
IMAGING_DATE: "imaging_date",
|
||||
AUTOCALL_DATE: "autocall_date",
|
||||
AUTOCALL_VERSION: "autocall_version",
|
||||
NORMALIZATION_TRANSFORMS: "normalization_transforms",
|
||||
CONTROLS_X: "controls_x",
|
||||
CONTROLS_Y: "controls_y",
|
||||
RAW_X: "raw_x",
|
||||
RAW_Y: "raw_y",
|
||||
GENOTYPES: "genotypes",
|
||||
BASE_CALLS: "base_calls",
|
||||
GENOTYPE_SCORES: "genotype_scores",
|
||||
SCANNER_DATA: "scanner_data",
|
||||
CALL_RATE: "call_rate",
|
||||
GENDER: "gender",
|
||||
LOGR_DEV: "logr_dev",
|
||||
GC10: "gc10",
|
||||
DX: "dx",
|
||||
SAMPLE_DATA: "sample_data",
|
||||
B_ALLELE_FREQS: "b_allele_freqs",
|
||||
LOGR_RATIOS: "logr_ratios",
|
||||
PERCENTILES_X: "percentiles_x",
|
||||
PERCENTILES_Y: "percentiles_y",
|
||||
SLIDE_IDENTIFIER: "slide_identifier",
|
||||
}
|
||||
|
||||
# canonical order (gtc2vcf gtc_write); v3 omits PLOIDY, PLOIDY_TYPE and 1012..1016
|
||||
CANONICAL_ORDER_V5 = [
|
||||
NUM_SNPS,
|
||||
PLOIDY,
|
||||
PLOIDY_TYPE,
|
||||
SAMPLE_NAME,
|
||||
SAMPLE_PLATE,
|
||||
SAMPLE_WELL,
|
||||
CLUSTER_FILE,
|
||||
SNP_MANIFEST,
|
||||
IMAGING_DATE,
|
||||
AUTOCALL_DATE,
|
||||
AUTOCALL_VERSION,
|
||||
NORMALIZATION_TRANSFORMS,
|
||||
CONTROLS_X,
|
||||
CONTROLS_Y,
|
||||
RAW_X,
|
||||
RAW_Y,
|
||||
GENOTYPES,
|
||||
BASE_CALLS,
|
||||
GENOTYPE_SCORES,
|
||||
SCANNER_DATA,
|
||||
CALL_RATE,
|
||||
GENDER,
|
||||
LOGR_DEV,
|
||||
GC10,
|
||||
DX,
|
||||
SAMPLE_DATA,
|
||||
B_ALLELE_FREQS,
|
||||
LOGR_RATIOS,
|
||||
PERCENTILES_X,
|
||||
PERCENTILES_Y,
|
||||
SLIDE_IDENTIFIER,
|
||||
]
|
||||
_V4_PLUS = {PLOIDY, PLOIDY_TYPE, B_ALLELE_FREQS, LOGR_RATIOS, PERCENTILES_X, PERCENTILES_Y, SLIDE_IDENTIFIER}
|
||||
CANONICAL_ORDER_V3 = [i for i in CANONICAL_ORDER_V5 if i not in _V4_PLUS]
|
||||
|
||||
GENOTYPE_CODES = [
|
||||
"NC",
|
||||
"AA",
|
||||
"AB",
|
||||
"BB",
|
||||
"NULL",
|
||||
"A",
|
||||
"B",
|
||||
"AAA",
|
||||
"AAB",
|
||||
"ABB",
|
||||
"BBB",
|
||||
"AAAA",
|
||||
"AAAB",
|
||||
"AABB",
|
||||
"ABBB",
|
||||
"BBBB",
|
||||
"AAAAA",
|
||||
"AAAAB",
|
||||
"AAABB",
|
||||
"AABBB",
|
||||
"ABBBB",
|
||||
"BBBBB",
|
||||
"AAAAAA",
|
||||
"AAAAAB",
|
||||
"AAAABB",
|
||||
"AAABBB",
|
||||
"AABBBB",
|
||||
"ABBBBB",
|
||||
"BBBBBB",
|
||||
"AAAAAAA",
|
||||
"AAAAAAB",
|
||||
"AAAAABB",
|
||||
"AAAABBB",
|
||||
"AAABBBB",
|
||||
"AABBBBB",
|
||||
"ABBBBBB",
|
||||
"BBBBBBB",
|
||||
"AAAAAAAA",
|
||||
"AAAAAAAB",
|
||||
"AAAAAABB",
|
||||
"AAAAABBB",
|
||||
"AAAABBBB",
|
||||
"AAABBBBB",
|
||||
"AABBBBBB",
|
||||
"ABBBBBBB",
|
||||
"BBBBBBBB",
|
||||
]
|
||||
|
||||
XFORM_DTYPE = np.dtype(
|
||||
[
|
||||
("version", "<i4"),
|
||||
("offset_x", "<f4"),
|
||||
("offset_y", "<f4"),
|
||||
("scale_x", "<f4"),
|
||||
("scale_y", "<f4"),
|
||||
("shear", "<f4"),
|
||||
("theta", "<f4"),
|
||||
("reserved", "<f4", (6,)),
|
||||
]
|
||||
)
|
||||
assert XFORM_DTYPE.itemsize == 52
|
||||
SAMPLE_DATA_DTYPE = np.dtype(
|
||||
[("p50gc", "<f4"), ("num_calls", "<i4"), ("num_no_calls", "<i4"), ("num_intensity_only", "<i4")]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScannerData:
|
||||
name: str = ""
|
||||
pmt_green: bytes = b"\x00\x00\x00\x00" # 4 raw bytes: int32 in BeadArrayFiles, float in gtc2vcf
|
||||
pmt_red: bytes = b"\x00\x00\x00\x00"
|
||||
version: str = ""
|
||||
user: str = ""
|
||||
|
||||
@property
|
||||
def pmt_green_int(self) -> int:
|
||||
return int.from_bytes(self.pmt_green, "little", signed=True)
|
||||
|
||||
@property
|
||||
def pmt_red_int(self) -> int:
|
||||
return int.from_bytes(self.pmt_red, "little", signed=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gtc:
|
||||
"""In-memory GTC content. Arrays are per locus in BPM names order."""
|
||||
|
||||
version: int = 5
|
||||
num_snps: int = 0
|
||||
ploidy: int = 2
|
||||
ploidy_type: int = 1
|
||||
sample_name: str = ""
|
||||
sample_plate: str = ""
|
||||
sample_well: str = ""
|
||||
cluster_file: str = ""
|
||||
snp_manifest: str = ""
|
||||
imaging_date: str = ""
|
||||
autocall_date: str = ""
|
||||
autocall_version: str = ""
|
||||
normalization_transforms: npt.NDArray[Any] = field(default_factory=lambda: np.zeros(0, XFORM_DTYPE))
|
||||
controls_x: npt.NDArray[np.uint16] = field(default_factory=lambda: np.zeros(0, np.uint16))
|
||||
controls_y: npt.NDArray[np.uint16] = field(default_factory=lambda: np.zeros(0, np.uint16))
|
||||
raw_x: npt.NDArray[np.uint16] = field(default_factory=lambda: np.zeros(0, np.uint16))
|
||||
raw_y: npt.NDArray[np.uint16] = field(default_factory=lambda: np.zeros(0, np.uint16))
|
||||
genotypes: npt.NDArray[np.uint8] = field(default_factory=lambda: np.zeros(0, np.uint8))
|
||||
base_calls: npt.NDArray[np.uint8] = field(default_factory=lambda: np.zeros((0, 2), np.uint8))
|
||||
genotype_scores: npt.NDArray[np.float32] = field(default_factory=lambda: np.zeros(0, np.float32))
|
||||
scanner_data: ScannerData = field(default_factory=ScannerData)
|
||||
call_rate: float = 0.0
|
||||
gender: bytes = b"U"
|
||||
logr_dev: float = 0.0
|
||||
gc10: float = 0.0
|
||||
dx: int = 0
|
||||
sample_data: npt.NDArray[Any] = field(default_factory=lambda: np.zeros(1, SAMPLE_DATA_DTYPE)[0])
|
||||
b_allele_freqs: npt.NDArray[np.float32] = field(default_factory=lambda: np.zeros(0, np.float32))
|
||||
logr_ratios: npt.NDArray[np.float32] = field(default_factory=lambda: np.zeros(0, np.float32))
|
||||
percentiles_x: npt.NDArray[np.uint16] = field(default_factory=lambda: np.zeros(3, np.uint16))
|
||||
percentiles_y: npt.NDArray[np.uint16] = field(default_factory=lambda: np.zeros(3, np.uint16))
|
||||
slide_identifier: str = ""
|
||||
# layout bookkeeping (set by the reader; None → canonical order)
|
||||
toc_order: list[int] | None = None
|
||||
data_order: list[int] | None = None
|
||||
present: set[int] | None = None
|
||||
path: str | None = None
|
||||
|
||||
# -- convenience ------------------------------------------------------------------------------------
|
||||
@property
|
||||
def gc50(self) -> float:
|
||||
return float(self.sample_data["p50gc"])
|
||||
|
||||
@property
|
||||
def num_calls(self) -> int:
|
||||
return int(self.sample_data["num_calls"])
|
||||
|
||||
@property
|
||||
def num_no_calls(self) -> int:
|
||||
return int(self.sample_data["num_no_calls"])
|
||||
|
||||
@property
|
||||
def num_intensity_only(self) -> int:
|
||||
return int(self.sample_data["num_intensity_only"])
|
||||
|
||||
@property
|
||||
def gender_char(self) -> str:
|
||||
return self.gender.decode("latin-1") if self.gender else ""
|
||||
|
||||
def genotype_strings(self) -> list[str]:
|
||||
return [GENOTYPE_CODES[g] if g < len(GENOTYPE_CODES) else "?" for g in self.genotypes]
|
||||
|
||||
def base_call_strings(self) -> list[str]:
|
||||
"""TOP-strand base calls; '-' for no-call (BeadArrayFiles ``get_base_calls`` for diploids)."""
|
||||
return [bytes(bc).decode("latin-1") for bc in self.base_calls]
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
def f(v: float) -> float | None:
|
||||
return None if math.isnan(v) else round(float(v), 6)
|
||||
|
||||
return {
|
||||
"path": self.path,
|
||||
"version": self.version,
|
||||
"num_snps": self.num_snps,
|
||||
"ploidy": self.ploidy,
|
||||
"ploidy_type": self.ploidy_type,
|
||||
"sample_name": self.sample_name,
|
||||
"sample_plate": self.sample_plate,
|
||||
"sample_well": self.sample_well,
|
||||
"slide_identifier": self.slide_identifier,
|
||||
"cluster_file": self.cluster_file,
|
||||
"snp_manifest": self.snp_manifest,
|
||||
"imaging_date": self.imaging_date,
|
||||
"autocall_date": self.autocall_date,
|
||||
"autocall_version": self.autocall_version,
|
||||
"n_normalization_transforms": len(self.normalization_transforms),
|
||||
"n_controls": len(self.controls_x),
|
||||
"call_rate": f(self.call_rate),
|
||||
"gender": self.gender_char,
|
||||
"logr_dev": f(self.logr_dev),
|
||||
"gc10": f(self.gc10),
|
||||
"gc50": f(self.gc50),
|
||||
"num_calls": self.num_calls,
|
||||
"num_no_calls": self.num_no_calls,
|
||||
"num_intensity_only": self.num_intensity_only,
|
||||
"percentiles_x": [int(v) for v in self.percentiles_x],
|
||||
"percentiles_y": [int(v) for v in self.percentiles_y],
|
||||
"scanner": {
|
||||
"name": self.scanner_data.name,
|
||||
"version": self.scanner_data.version,
|
||||
"user": self.scanner_data.user,
|
||||
},
|
||||
"toc": [TOC_NAMES.get(i, str(i)) for i in (self.toc_order or [])],
|
||||
}
|
||||
|
||||
|
||||
def read_gtc(path: str | Path, *, check_write_complete: bool = True) -> Gtc:
|
||||
"""Parse a GTC file. Raises FormatError(E_GTC_CORRUPT) on any structural problem (IN-032)."""
|
||||
spath = str(path)
|
||||
buf = load_bytes(path)
|
||||
r = BinaryReader(buf, "E_GTC_CORRUPT", spath)
|
||||
if r.size < 8 or r.raw(3, "magic") != b"gtc":
|
||||
raise r.fail("bad magic (not a GTC file)")
|
||||
version = r.u8("version")
|
||||
if version not in (3, 4, 5):
|
||||
raise r.fail(f"unsupported GTC version {version}")
|
||||
m = r.i32("TOC size")
|
||||
if not 0 < m < 1024:
|
||||
raise r.fail(f"implausible TOC size {m}")
|
||||
toc: dict[int, int] = {}
|
||||
order: list[int] = []
|
||||
for _ in range(m):
|
||||
tid = r.u16("TOC id")
|
||||
off = r.u32("TOC offset")
|
||||
if tid in toc:
|
||||
raise r.fail(f"duplicate TOC id {tid}")
|
||||
toc[tid] = off
|
||||
order.append(tid)
|
||||
g = Gtc(version=version, toc_order=order, present=set(toc), path=spath)
|
||||
for tid in INLINE_IDS:
|
||||
if tid in toc:
|
||||
setattr(g, TOC_NAMES[tid], _as_i32(toc[tid]))
|
||||
if NUM_SNPS not in toc:
|
||||
raise r.fail("missing NumSNPs entry")
|
||||
n = g.num_snps
|
||||
if n < 0:
|
||||
raise r.fail(f"negative NumSNPs {n}")
|
||||
|
||||
def at(tid: int) -> bool:
|
||||
if tid not in toc:
|
||||
return False
|
||||
r.seek(toc[tid], TOC_NAMES.get(tid, str(tid)))
|
||||
return True
|
||||
|
||||
def arr(tid: int, dtype: Any, expect: int | None = None) -> npt.NDArray[Any]:
|
||||
a = r.counted_array(dtype, TOC_NAMES[tid])
|
||||
if expect is not None and len(a) != expect:
|
||||
raise r.fail(f"{TOC_NAMES[tid]} has {len(a)} entries, expected {expect}")
|
||||
return np.array(a)
|
||||
|
||||
for tid, attr in (
|
||||
(SAMPLE_NAME, "sample_name"),
|
||||
(SAMPLE_PLATE, "sample_plate"),
|
||||
(SAMPLE_WELL, "sample_well"),
|
||||
(CLUSTER_FILE, "cluster_file"),
|
||||
(SNP_MANIFEST, "snp_manifest"),
|
||||
(IMAGING_DATE, "imaging_date"),
|
||||
(AUTOCALL_DATE, "autocall_date"),
|
||||
(AUTOCALL_VERSION, "autocall_version"),
|
||||
(SLIDE_IDENTIFIER, "slide_identifier"),
|
||||
):
|
||||
if at(tid):
|
||||
setattr(g, attr, r.string(attr))
|
||||
if at(NORMALIZATION_TRANSFORMS):
|
||||
g.normalization_transforms = arr(NORMALIZATION_TRANSFORMS, XFORM_DTYPE)
|
||||
if at(CONTROLS_X):
|
||||
g.controls_x = arr(CONTROLS_X, "<u2")
|
||||
if at(CONTROLS_Y):
|
||||
g.controls_y = arr(CONTROLS_Y, "<u2")
|
||||
if at(RAW_X):
|
||||
g.raw_x = arr(RAW_X, "<u2", n)
|
||||
if at(RAW_Y):
|
||||
g.raw_y = arr(RAW_Y, "<u2", n)
|
||||
if at(GENOTYPES):
|
||||
g.genotypes = arr(GENOTYPES, "u1", n)
|
||||
if at(BASE_CALLS):
|
||||
cnt = r.i32("base calls count")
|
||||
if cnt != n:
|
||||
raise r.fail(f"base_calls has {cnt} entries, expected {n}")
|
||||
g.base_calls = np.array(r.array("u1", 2 * n, "base calls")).reshape(n, 2)
|
||||
if at(GENOTYPE_SCORES):
|
||||
g.genotype_scores = arr(GENOTYPE_SCORES, "<f4", n)
|
||||
if at(SCANNER_DATA):
|
||||
g.scanner_data = ScannerData(
|
||||
name=r.string("scanner name"),
|
||||
pmt_green=r.raw(4, "pmt green"),
|
||||
pmt_red=r.raw(4, "pmt red"),
|
||||
version=r.string("scanner version"),
|
||||
user=r.string("imaging user"),
|
||||
)
|
||||
if at(CALL_RATE):
|
||||
g.call_rate = r.f32("call rate")
|
||||
if at(GENDER):
|
||||
g.gender = r.raw(1, "gender")
|
||||
if at(LOGR_DEV):
|
||||
g.logr_dev = r.f32("logr dev")
|
||||
if at(GC10):
|
||||
g.gc10 = r.f32("gc10")
|
||||
if at(DX):
|
||||
g.dx = r.i32("dx")
|
||||
if at(SAMPLE_DATA):
|
||||
g.sample_data = np.array(r.array(SAMPLE_DATA_DTYPE, 1, "sample data"))[0]
|
||||
if at(B_ALLELE_FREQS):
|
||||
g.b_allele_freqs = arr(B_ALLELE_FREQS, "<f4", n)
|
||||
if at(LOGR_RATIOS):
|
||||
g.logr_ratios = arr(LOGR_RATIOS, "<f4", n)
|
||||
if at(PERCENTILES_X):
|
||||
g.percentiles_x = np.array(r.array("<u2", 3, "percentiles x"))
|
||||
if at(PERCENTILES_Y):
|
||||
g.percentiles_y = np.array(r.array("<u2", 3, "percentiles y"))
|
||||
unknown = [t for t in toc if t not in TOC_NAMES]
|
||||
if unknown:
|
||||
raise r.fail(f"unknown TOC ids {unknown}")
|
||||
g.data_order = sorted((t for t in toc if t not in INLINE_IDS), key=lambda t: toc[t])
|
||||
if check_write_complete:
|
||||
required = {3: SAMPLE_DATA, 4: LOGR_RATIOS, 5: SLIDE_IDENTIFIER}[version]
|
||||
if required not in toc:
|
||||
raise r.fail(f"GTC v{version} is incomplete (missing {TOC_NAMES[required]})")
|
||||
return g
|
||||
|
||||
|
||||
def _as_i32(v: int) -> int:
|
||||
return v - (1 << 32) if v >= 1 << 31 else v
|
||||
|
||||
|
||||
def _write_field(w: BinaryWriter, g: Gtc, tid: int) -> None:
|
||||
n = g.num_snps
|
||||
if tid in (
|
||||
SAMPLE_NAME,
|
||||
SAMPLE_PLATE,
|
||||
SAMPLE_WELL,
|
||||
CLUSTER_FILE,
|
||||
SNP_MANIFEST,
|
||||
IMAGING_DATE,
|
||||
AUTOCALL_DATE,
|
||||
AUTOCALL_VERSION,
|
||||
SLIDE_IDENTIFIER,
|
||||
):
|
||||
w.string(getattr(g, TOC_NAMES[tid]))
|
||||
elif tid == NORMALIZATION_TRANSFORMS:
|
||||
xf = np.asarray(g.normalization_transforms, dtype=XFORM_DTYPE)
|
||||
w.i32(len(xf))
|
||||
w.write(xf.tobytes())
|
||||
elif tid in (CONTROLS_X, CONTROLS_Y):
|
||||
w.counted_array(getattr(g, TOC_NAMES[tid]), "<u2")
|
||||
elif tid in (RAW_X, RAW_Y):
|
||||
_check_len(g, tid, n)
|
||||
w.counted_array(getattr(g, TOC_NAMES[tid]), "<u2")
|
||||
elif tid == GENOTYPES:
|
||||
_check_len(g, tid, n)
|
||||
w.counted_array(g.genotypes, "u1")
|
||||
elif tid == BASE_CALLS:
|
||||
bc = np.asarray(g.base_calls, dtype=np.uint8).reshape(-1, 2)
|
||||
if len(bc) != n:
|
||||
raise ValueError(f"base_calls has {len(bc)} rows, expected {n}")
|
||||
w.i32(n)
|
||||
w.write(bc.tobytes())
|
||||
elif tid in (GENOTYPE_SCORES, B_ALLELE_FREQS, LOGR_RATIOS):
|
||||
_check_len(g, tid, n)
|
||||
w.counted_array(getattr(g, TOC_NAMES[tid]), "<f4")
|
||||
elif tid == SCANNER_DATA:
|
||||
s = g.scanner_data
|
||||
w.string(s.name)
|
||||
w.write(s.pmt_green)
|
||||
w.write(s.pmt_red)
|
||||
w.string(s.version)
|
||||
w.string(s.user)
|
||||
elif tid in (CALL_RATE, LOGR_DEV, GC10):
|
||||
w.f32(getattr(g, TOC_NAMES[tid]))
|
||||
elif tid == GENDER:
|
||||
w.write(g.gender[:1] or b"U")
|
||||
elif tid == DX:
|
||||
w.i32(g.dx)
|
||||
elif tid == SAMPLE_DATA:
|
||||
w.write(np.asarray(g.sample_data, dtype=SAMPLE_DATA_DTYPE).tobytes())
|
||||
elif tid in (PERCENTILES_X, PERCENTILES_Y):
|
||||
w.array(getattr(g, TOC_NAMES[tid]), "<u2")
|
||||
else:
|
||||
raise ValueError(f"cannot write TOC id {tid}")
|
||||
|
||||
|
||||
def _check_len(g: Gtc, tid: int, n: int) -> None:
|
||||
if len(getattr(g, TOC_NAMES[tid])) != n:
|
||||
raise ValueError(f"{TOC_NAMES[tid]} length {len(getattr(g, TOC_NAMES[tid]))} != num_snps {n}")
|
||||
|
||||
|
||||
def encode_gtc(g: Gtc, *, canonical: bool = False) -> bytes:
|
||||
"""Serialize. ``canonical=True`` ignores the recorded layout and uses the gtc2vcf order for ``g.version``."""
|
||||
if canonical or g.toc_order is None:
|
||||
toc_order = list(CANONICAL_ORDER_V5 if g.version >= 4 else CANONICAL_ORDER_V3)
|
||||
data_order = [t for t in toc_order if t not in INLINE_IDS]
|
||||
else:
|
||||
toc_order = list(g.toc_order)
|
||||
data_order = list(g.data_order or [t for t in toc_order if t not in INLINE_IDS])
|
||||
header_len = 4 + 4 + 6 * len(toc_order)
|
||||
body = BinaryWriter()
|
||||
offsets: dict[int, int] = {}
|
||||
for tid in data_order:
|
||||
offsets[tid] = header_len + body.tell()
|
||||
_write_field(body, g, tid)
|
||||
w = BinaryWriter()
|
||||
w.write(b"gtc")
|
||||
w.u8(g.version)
|
||||
w.i32(len(toc_order))
|
||||
for tid in toc_order:
|
||||
w.u16(tid)
|
||||
if tid in INLINE_IDS:
|
||||
w.u32(getattr(g, TOC_NAMES[tid]) & 0xFFFFFFFF)
|
||||
else:
|
||||
w.u32(offsets[tid])
|
||||
w.write(body.getvalue())
|
||||
return w.getvalue()
|
||||
|
||||
|
||||
def write_gtc(g: Gtc, path: str | Path, *, canonical: bool = False) -> None:
|
||||
"""Write atomically (spec CLI-011)."""
|
||||
from simurg.core.atomic import atomic_write_bytes
|
||||
|
||||
atomic_write_bytes(Path(path), encode_gtc(g, canonical=canonical))
|
||||
|
||||
|
||||
def validate_gtc(g: Gtc) -> list[str]:
|
||||
"""Light semantic checks used by ``simurg inspect`` (not raised)."""
|
||||
problems: list[str] = []
|
||||
n = g.num_snps
|
||||
for name in ("raw_x", "raw_y", "genotypes", "genotype_scores"):
|
||||
if len(getattr(g, name)) not in (0, n):
|
||||
problems.append(f"{name} length {len(getattr(g, name))} != num_snps {n}")
|
||||
if len(g.genotypes) and int(g.genotypes.max(initial=0)) >= len(GENOTYPE_CODES):
|
||||
problems.append("genotype code out of range")
|
||||
return problems
|
||||
@@ -0,0 +1,343 @@
|
||||
"""IDAT (binary, version 3) reader — plan §6.1, spec IN-023/IN-024/IN-025.
|
||||
|
||||
Layout (little-endian)::
|
||||
|
||||
char[4] "IDAT"; int64 version (3); int32 n_fields; n_fields × {uint16 code; int64 offset}
|
||||
|
||||
Field codes follow illuminaio (GPL, consulted as documentation only), gtc2vcf ``idat2gtc.c`` (MIT) and
|
||||
BeadArrayFiles (BSD-2). The element type of the MidBlock (code 200) differs between those readers (int32 in
|
||||
illuminaio, uint8 in gtc2vcf); we infer it from the space available before the next section (plan §20 U2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from simurg.core.errors import FormatError
|
||||
from simurg.formats.binutil import BinaryReader, load_bytes
|
||||
|
||||
MAGIC = b"IDAT"
|
||||
|
||||
# field codes
|
||||
N_SNPS = 1000
|
||||
ILLUMINA_ID = 102
|
||||
SD = 103
|
||||
MEAN = 104
|
||||
NBEADS = 107
|
||||
MID_BLOCK = 200
|
||||
RUN_INFO = 300
|
||||
RED_GREEN = 400
|
||||
MANIFEST = 401
|
||||
BARCODE = 402
|
||||
CHIP_TYPE = 403
|
||||
POSITION = 404
|
||||
BEADSET = 405
|
||||
SAMPLE_ID = 406
|
||||
DESCRIPTION = 407
|
||||
PLATE = 408
|
||||
WELL = 409
|
||||
SAMPLE_COUNT = 410
|
||||
VLN = 510
|
||||
|
||||
FIELD_NAMES: dict[int, str] = {
|
||||
N_SNPS: "n_addresses",
|
||||
ILLUMINA_ID: "address_ids",
|
||||
SD: "sd",
|
||||
MEAN: "mean",
|
||||
NBEADS: "n_beads",
|
||||
MID_BLOCK: "mid_block",
|
||||
RUN_INFO: "run_info",
|
||||
RED_GREEN: "red_green",
|
||||
MANIFEST: "manifest",
|
||||
BARCODE: "barcode",
|
||||
CHIP_TYPE: "chip_type",
|
||||
POSITION: "position",
|
||||
BEADSET: "beadset",
|
||||
SAMPLE_ID: "sample_id",
|
||||
DESCRIPTION: "description",
|
||||
PLATE: "plate",
|
||||
WELL: "well",
|
||||
SAMPLE_COUNT: "sample_count",
|
||||
VLN: "vln",
|
||||
}
|
||||
|
||||
_STRING_FIELDS = {
|
||||
MANIFEST: "manifest",
|
||||
BARCODE: "barcode",
|
||||
CHIP_TYPE: "chip_type",
|
||||
POSITION: "position",
|
||||
BEADSET: "beadset",
|
||||
SAMPLE_ID: "sample_id",
|
||||
DESCRIPTION: "description",
|
||||
PLATE: "plate",
|
||||
WELL: "well",
|
||||
VLN: "vln",
|
||||
}
|
||||
|
||||
_NAME_RE = re.compile(
|
||||
r"^(?P<barcode>[^_/\\]+)_(?P<position>R\d{2}C\d{2})_(?P<channel>Grn|Red)\.idat(\.gz)?$", re.IGNORECASE
|
||||
)
|
||||
|
||||
_DATE_FORMATS = (
|
||||
"%m/%d/%Y %I:%M:%S %p",
|
||||
"%m/%d/%Y %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%d.%m.%Y %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunInfoEntry:
|
||||
run_time: str
|
||||
block_type: str
|
||||
block_pars: str
|
||||
block_code: str
|
||||
code_version: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Idat:
|
||||
"""One colour channel of one BeadChip section."""
|
||||
|
||||
path: str
|
||||
version: int
|
||||
toc: dict[int, int]
|
||||
n_addresses: int
|
||||
address_ids: npt.NDArray[np.int32]
|
||||
mean: npt.NDArray[np.uint16]
|
||||
sd: npt.NDArray[np.uint16]
|
||||
n_beads: npt.NDArray[np.uint8]
|
||||
mid_block: npt.NDArray[Any] | None = None
|
||||
mid_block_itemsize: int | None = None
|
||||
run_info: list[RunInfoEntry] = field(default_factory=list)
|
||||
red_green: bytes = b""
|
||||
manifest: str = ""
|
||||
barcode: str = ""
|
||||
chip_type: str = ""
|
||||
position: str = ""
|
||||
beadset: str = ""
|
||||
sample_id: str = ""
|
||||
description: str = ""
|
||||
plate: str = ""
|
||||
well: str = ""
|
||||
vln: str = ""
|
||||
sample_count: int | None = None
|
||||
unknown_fields: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# -- derived metadata (IN-024) --------------------------------------------------------------------
|
||||
def scan_entry(self) -> RunInfoEntry | None:
|
||||
scans = [r for r in self.run_info if r.block_type.strip().lower() == "scan"]
|
||||
return scans[-1] if scans else None
|
||||
|
||||
@property
|
||||
def scan_time_raw(self) -> str:
|
||||
e = self.scan_entry()
|
||||
return e.run_time if e else ""
|
||||
|
||||
@property
|
||||
def scan_time_iso(self) -> str:
|
||||
return parse_run_time(self.scan_time_raw)
|
||||
|
||||
@property
|
||||
def scanner_software(self) -> str:
|
||||
e = self.scan_entry()
|
||||
return e.code_version if e else ""
|
||||
|
||||
@property
|
||||
def scanner_pars(self) -> str:
|
||||
e = self.scan_entry()
|
||||
return e.block_pars if e else ""
|
||||
|
||||
def metadata(self) -> dict[str, Any]:
|
||||
return {
|
||||
"path": self.path,
|
||||
"version": self.version,
|
||||
"n_addresses": self.n_addresses,
|
||||
"barcode": self.barcode,
|
||||
"position": self.position,
|
||||
"chip_type": self.chip_type,
|
||||
"manifest": self.manifest,
|
||||
"beadset": self.beadset,
|
||||
"sample_id": self.sample_id,
|
||||
"description": self.description,
|
||||
"plate": self.plate,
|
||||
"well": self.well,
|
||||
"sample_count": self.sample_count,
|
||||
"vln": self.vln,
|
||||
"red_green": self.red_green.hex(" "),
|
||||
"mid_block_count": None if self.mid_block is None else len(self.mid_block),
|
||||
"mid_block_itemsize": self.mid_block_itemsize,
|
||||
"scan_time": self.scan_time_iso,
|
||||
"scan_time_raw": self.scan_time_raw,
|
||||
"scanner_software": self.scanner_software,
|
||||
"toc_codes": sorted(self.toc),
|
||||
"unknown_codes": sorted(self.unknown_fields),
|
||||
"run_info": [r.__dict__ for r in self.run_info],
|
||||
}
|
||||
|
||||
|
||||
def parse_run_time(raw: str) -> str:
|
||||
"""Convert a RunInfo timestamp to ISO-8601 without consulting the process locale (EDGE-001).
|
||||
|
||||
Returns ``""`` if the value is empty or cannot be parsed.
|
||||
"""
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return ""
|
||||
for fmt in _DATE_FORMATS:
|
||||
try:
|
||||
return _dt.datetime.strptime(s, fmt).isoformat()
|
||||
except ValueError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def parse_idat_filename(path: str | Path) -> tuple[str, str, str] | None:
|
||||
"""``<barcode>_<RxxCxx>_<Grn|Red>.idat[.gz]`` → (barcode, POSITION, 'Grn'|'Red'); None if not an IDAT name."""
|
||||
m = _NAME_RE.match(Path(path).name)
|
||||
if not m:
|
||||
return None
|
||||
ch = "Grn" if m.group("channel").lower() == "grn" else "Red"
|
||||
return m.group("barcode"), m.group("position").upper(), ch
|
||||
|
||||
|
||||
def read_idat(path: str | Path, *, load_arrays: bool = True) -> Idat:
|
||||
"""Parse an IDAT file. Raises FormatError (E_IDAT_FORMAT / E_IDAT_ENCRYPTED)."""
|
||||
spath = str(path)
|
||||
buf = load_bytes(path)
|
||||
r = BinaryReader(buf, "E_IDAT_FORMAT", spath)
|
||||
if r.size < 16 or r.raw(4, "magic") != MAGIC:
|
||||
raise r.fail("bad magic (not an IDAT file)")
|
||||
version = r.i64("version")
|
||||
# encrypted files carry version 1 in the low 32 bits followed by ciphertext (illuminaio reads int32)
|
||||
if version & 0xFFFFFFFF == 1:
|
||||
raise FormatError(
|
||||
"E_IDAT_ENCRYPTED",
|
||||
f"{spath}: encrypted (version 1) IDAT files are not supported",
|
||||
path=spath,
|
||||
hint="convert with an external tool first",
|
||||
)
|
||||
if version != 3:
|
||||
raise r.fail(f"unsupported IDAT version {version}")
|
||||
n_fields = r.i32("TOC size")
|
||||
if n_fields <= 0 or n_fields > 4096:
|
||||
raise r.fail(f"implausible TOC size {n_fields}")
|
||||
toc: dict[int, int] = {}
|
||||
for _ in range(n_fields):
|
||||
code = r.u16("TOC code")
|
||||
off = r.i64("TOC offset")
|
||||
if off < 0 or off >= r.size:
|
||||
raise r.fail(f"TOC offset {off} for field {code} outside file (size {r.size})")
|
||||
if code in toc:
|
||||
raise r.fail(f"duplicate TOC field {code}")
|
||||
toc[code] = off
|
||||
|
||||
for required in (N_SNPS, ILLUMINA_ID, MEAN, SD, NBEADS):
|
||||
if required not in toc:
|
||||
raise r.fail(f"missing required field {required} ({FIELD_NAMES[required]})")
|
||||
|
||||
r.seek(toc[N_SNPS])
|
||||
n = r.i32("number of addresses")
|
||||
if n < 0:
|
||||
raise r.fail(f"negative address count {n}")
|
||||
|
||||
empty_i32 = np.empty(0, dtype="<i4")
|
||||
idat = Idat(
|
||||
path=spath,
|
||||
version=version,
|
||||
toc=dict(toc),
|
||||
n_addresses=n,
|
||||
address_ids=empty_i32,
|
||||
mean=np.empty(0, dtype="<u2"),
|
||||
sd=np.empty(0, dtype="<u2"),
|
||||
n_beads=np.empty(0, dtype="u1"),
|
||||
)
|
||||
|
||||
if load_arrays:
|
||||
r.seek(toc[ILLUMINA_ID])
|
||||
idat.address_ids = r.array("<i4", n, "address ids")
|
||||
r.seek(toc[SD])
|
||||
idat.sd = r.array("<u2", n, "SD")
|
||||
r.seek(toc[MEAN])
|
||||
idat.mean = r.array("<u2", n, "Mean")
|
||||
r.seek(toc[NBEADS])
|
||||
idat.n_beads = r.array("u1", n, "NBeads")
|
||||
if has_duplicates(idat.address_ids):
|
||||
raise r.fail("duplicate address IDs")
|
||||
|
||||
offsets_sorted = sorted(set(toc.values()) | {r.size})
|
||||
|
||||
def section_end(off: int) -> int:
|
||||
return next(o for o in offsets_sorted if o > off)
|
||||
|
||||
for code, off in sorted(toc.items(), key=lambda kv: kv[1]):
|
||||
r.seek(off)
|
||||
if code in _STRING_FIELDS:
|
||||
setattr(idat, _STRING_FIELDS[code], r.string(FIELD_NAMES[code]))
|
||||
elif code == RUN_INFO:
|
||||
m = r.i32("run info count")
|
||||
if m < 0 or m > 100_000:
|
||||
raise r.fail(f"implausible run info count {m}")
|
||||
idat.run_info = [RunInfoEntry(*(r.string("run info") for _ in range(5))) for _ in range(m)]
|
||||
elif code == RED_GREEN:
|
||||
idat.red_green = r.raw(4, "RedGreen")
|
||||
elif code == SAMPLE_COUNT:
|
||||
idat.sample_count = r.i32("sample count")
|
||||
elif code == MID_BLOCK:
|
||||
if not load_arrays:
|
||||
continue
|
||||
count = r.i32("mid block count")
|
||||
space = section_end(off) - off - 4
|
||||
if count < 0:
|
||||
raise r.fail(f"negative mid block count {count}")
|
||||
# element size inferred from the space before the next section (int32 vs uint8, plan §20 U2)
|
||||
if count == space and count * 4 != space:
|
||||
itemsize = 1
|
||||
elif count * 4 <= space:
|
||||
itemsize = 4
|
||||
elif count <= space:
|
||||
itemsize = 1
|
||||
else:
|
||||
raise r.fail(f"mid block of {count} entries does not fit in {space} bytes")
|
||||
idat.mid_block_itemsize = itemsize
|
||||
idat.mid_block = r.array("<i4" if itemsize == 4 else "u1", count, "mid block")
|
||||
elif code in (N_SNPS, ILLUMINA_ID, SD, MEAN, NBEADS):
|
||||
continue
|
||||
else:
|
||||
idat.unknown_fields[code] = off
|
||||
return idat
|
||||
|
||||
|
||||
def has_duplicates(ids: npt.NDArray[np.int32]) -> bool:
|
||||
"""Duplicate check; O(n) when IDs are already strictly increasing (the usual case)."""
|
||||
if len(ids) < 2:
|
||||
return False
|
||||
d = np.diff(ids)
|
||||
if (d > 0).all():
|
||||
return False
|
||||
s = np.sort(ids)
|
||||
return bool((s[1:] == s[:-1]).any())
|
||||
|
||||
|
||||
def channel_of(idat: Idat) -> str | None:
|
||||
parsed = parse_idat_filename(idat.path)
|
||||
return parsed[2] if parsed else None
|
||||
|
||||
|
||||
def check_pair(grn: Idat, red: Idat) -> None:
|
||||
"""Red/Grn consistency (IN-023): same N and the same address set. Raises E_IDAT_PAIR_MISMATCH."""
|
||||
if grn.n_addresses != red.n_addresses:
|
||||
raise FormatError(
|
||||
"E_IDAT_PAIR_MISMATCH",
|
||||
f"Grn has {grn.n_addresses} addresses, Red has {red.n_addresses}: {grn.path}, {red.path}",
|
||||
)
|
||||
if len(grn.address_ids) and not np.array_equal(np.sort(grn.address_ids), np.sort(red.address_ids)):
|
||||
raise FormatError("E_IDAT_PAIR_MISMATCH", f"address sets differ: {grn.path}, {red.path}")
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Sample-sheet parser and validator — spec IN-001 … IN-017 (plan §6.6).
|
||||
|
||||
A GenomeStudio-compatible CSV with optional ``[Header]``, ``[Manifests]`` (ignored) and ``[Data]`` sections.
|
||||
All column matching uses Unicode case folding (locale independent, EDGE-001).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from simurg.core.errors import SimurgError
|
||||
|
||||
MAX_LEN = 500
|
||||
KEY_PAIRS = (("SentrixBarcode_A", "SentrixPosition_A"), ("beadChipName", "sampleSectionName"))
|
||||
RECOGNISED = (
|
||||
"Sample_ID",
|
||||
"Sample_Name",
|
||||
"Sample_Plate",
|
||||
"Sample_Well",
|
||||
"Sample_Group",
|
||||
"Gender",
|
||||
"Sex",
|
||||
"demographicSex",
|
||||
"Parent1",
|
||||
"Parent2",
|
||||
"Replicate",
|
||||
)
|
||||
SEX_COLUMNS_PRIORITY = ("Sex", "Gender", "demographicSex")
|
||||
|
||||
# Output columns of sample_summary (OUT-060) and their compatibility spellings (OUT-066): a sheet column may
|
||||
# not collide with these case-insensitively (IN-005). Recognised input columns above are exempt.
|
||||
RESERVED_OUTPUT_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",
|
||||
"functional_status",
|
||||
"control_status",
|
||||
"control_flag_codes",
|
||||
"flags",
|
||||
"Sample ID",
|
||||
"Sample Name",
|
||||
"Sample Folder",
|
||||
"Autosomal Call Rate",
|
||||
"Call Rate",
|
||||
"Log R Ratio Std Dev",
|
||||
"Sex Estimate",
|
||||
)
|
||||
|
||||
_BARCODE_RE = re.compile(r"^[0-9]{10,14}$")
|
||||
_BARCODE_DECIMAL_RE = re.compile(r"^([0-9]{10,14})\.0+$")
|
||||
_BARCODE_SCI_RE = re.compile(r"^[0-9][.,][0-9]+[eE][+]?[0-9]+$")
|
||||
_POSITION_RE = re.compile(r"^[Rr][0-9]{2}[Cc][0-9]{2}$")
|
||||
_NORM_RE = re.compile(r".*_norm_r$", re.IGNORECASE)
|
||||
_NORM_COMPAT_RE = re.compile(r".* norm r$", re.IGNORECASE)
|
||||
|
||||
|
||||
def fold(s: str) -> str:
|
||||
return s.strip().casefold()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SheetRow:
|
||||
row_number: int # 1-based line number in the file
|
||||
barcode: str
|
||||
position: str
|
||||
values: dict[str, str] # original header -> value (empty-header columns dropped)
|
||||
sample_name: str | None = None
|
||||
reported_sex: str = "U"
|
||||
|
||||
@property
|
||||
def sample_id(self) -> str:
|
||||
return f"{self.barcode}_{self.position}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SampleSheet:
|
||||
path: str
|
||||
header: dict[str, str]
|
||||
root_folder: Path | None
|
||||
columns: list[str]
|
||||
rows: list[SheetRow]
|
||||
barcode_column: str
|
||||
position_column: str
|
||||
warnings: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_sample_name(self) -> bool:
|
||||
return any(fold(c) == fold("Sample_Name") for c in self.columns)
|
||||
|
||||
def passthrough_columns(self) -> list[str]:
|
||||
"""Every non-key column in sheet order (IN-013)."""
|
||||
keys = {fold(self.barcode_column), fold(self.position_column)}
|
||||
return [c for c in self.columns if fold(c) not in keys]
|
||||
|
||||
def by_sample_id(self) -> dict[str, SheetRow]:
|
||||
return {r.sample_id: r for r in self.rows}
|
||||
|
||||
|
||||
def _err(code: str, path: str, msg: str, hint: str | None = None) -> SimurgError:
|
||||
return SimurgError(code, f"{path}: {msg}", hint=hint, path=path)
|
||||
|
||||
|
||||
def normalise_sex(value: str) -> str:
|
||||
v = fold(value)
|
||||
if v in ("m", "male", "1"):
|
||||
return "M"
|
||||
if v in ("f", "female", "2"):
|
||||
return "F"
|
||||
return "U"
|
||||
|
||||
|
||||
def validate_barcode(value: str, where: str, warnings: list[tuple[str, str]], path: str) -> str:
|
||||
v = value.strip()
|
||||
if _BARCODE_RE.match(v):
|
||||
return v
|
||||
m = _BARCODE_DECIMAL_RE.match(v)
|
||||
if m:
|
||||
warnings.append(("W_SHEET_BARCODE_REPAIRED", f"{where}: barcode {v!r} repaired to {m.group(1)}"))
|
||||
return m.group(1)
|
||||
hint = None
|
||||
if _BARCODE_SCI_RE.match(v):
|
||||
hint = "format the SentrixBarcode_A column as a number with 0 decimals (or as text) before saving"
|
||||
raise _err("E_SHEET_BARCODE_FORMAT", path, f"{where}: invalid barcode {v!r}", hint)
|
||||
|
||||
|
||||
def validate_position(value: str, where: str, path: str) -> str:
|
||||
v = value.strip()
|
||||
if not _POSITION_RE.match(v):
|
||||
raise _err("E_SHEET_BARCODE_FORMAT", path, f"{where}: invalid position {v!r} (expected RxxCxx)")
|
||||
return v.upper()
|
||||
|
||||
|
||||
def _read_rows(path: Path) -> list[tuple[int, list[str]]]:
|
||||
raw = path.read_bytes()
|
||||
text = raw.decode("utf-8-sig", errors="replace")
|
||||
stripped = text.lstrip()
|
||||
if stripped.startswith("{") or (stripped.startswith("[") and stripped[1:].lstrip()[:1] in ("{", '"', "]")):
|
||||
raise _err(
|
||||
"E_SHEET_DELIMITER",
|
||||
str(path),
|
||||
"looks like JSON, not a CSV sample sheet",
|
||||
"export the sheet as comma-separated CSV",
|
||||
)
|
||||
rows: list[tuple[int, list[str]]] = []
|
||||
reader = csv.reader(io.StringIO(text, newline=""))
|
||||
for row in reader:
|
||||
rows.append((reader.line_num, row))
|
||||
return rows
|
||||
|
||||
|
||||
def parse_samplesheet(path: str | Path, *, on_duplicate_sample: str = "error") -> SampleSheet:
|
||||
"""Parse and validate a sample sheet. Raises SimurgError with an E_SHEET_* code."""
|
||||
p = Path(path)
|
||||
spath = str(p)
|
||||
warnings: list[tuple[str, str]] = []
|
||||
rows = _read_rows(p)
|
||||
|
||||
header_kv: dict[str, str] = {}
|
||||
data_rows: list[tuple[int, list[str]]] = []
|
||||
section: str | None = None
|
||||
saw_section = False
|
||||
for line_no, row in rows:
|
||||
if not row or all(not c.strip() for c in row):
|
||||
continue
|
||||
first = row[0].strip()
|
||||
if first.startswith("[") and first.endswith("]") and first[1:-1].strip().replace(" ", "").isalnum():
|
||||
section = fold(first[1:-1])
|
||||
saw_section = True
|
||||
continue
|
||||
if not saw_section or section == "data":
|
||||
data_rows.append((line_no, row))
|
||||
elif section == "header":
|
||||
header_kv[first] = row[1].strip() if len(row) > 1 else ""
|
||||
# [Manifests] and unknown sections are ignored
|
||||
|
||||
if not data_rows:
|
||||
raise _err("E_SHEET_EMPTY", spath, "no [Data] header row")
|
||||
head_line, head = data_rows[0]
|
||||
if len(head) == 1 and (";" in head[0] or "\t" in head[0]):
|
||||
raise _err(
|
||||
"E_SHEET_DELIMITER",
|
||||
spath,
|
||||
f"line {head_line}: header is not comma-delimited",
|
||||
"save the sheet as comma-separated CSV (not semicolon or tab)",
|
||||
)
|
||||
|
||||
# columns: drop empty headers (IN-004), check length (IN-006) and case-insensitive duplicates (IN-003)
|
||||
col_idx: list[tuple[int, str]] = []
|
||||
empty_cols = 0
|
||||
seen: dict[str, str] = {}
|
||||
for i, name in enumerate(head):
|
||||
nm = name.strip()
|
||||
if not nm:
|
||||
empty_cols += 1
|
||||
continue
|
||||
if len(nm) > MAX_LEN:
|
||||
raise _err(
|
||||
"E_SHEET_TOO_LONG", spath, f"column name at position {i + 1} has {len(nm)} characters (limit {MAX_LEN})"
|
||||
)
|
||||
k = fold(nm)
|
||||
if k in seen:
|
||||
raise _err(
|
||||
"E_SHEET_DUP_COLUMN",
|
||||
spath,
|
||||
f"Duplicate column '{nm}' (column names are case-insensitive). "
|
||||
f"Rename or remove one of: {seen[k]}, {nm}",
|
||||
)
|
||||
seen[k] = nm
|
||||
col_idx.append((i, nm))
|
||||
if empty_cols:
|
||||
warnings.append(("W_SHEET_EMPTY_COLUMN", f"{empty_cols} column(s) with an empty header ignored"))
|
||||
columns = [nm for _, nm in col_idx]
|
||||
folded = {fold(c): c for c in columns}
|
||||
|
||||
key: tuple[str, str] | None = None
|
||||
for bc, pc in KEY_PAIRS:
|
||||
if fold(bc) in folded and fold(pc) in folded:
|
||||
key = (folded[fold(bc)], folded[fold(pc)])
|
||||
break
|
||||
if key is None:
|
||||
raise _err(
|
||||
"E_SHEET_NO_KEY_COLUMNS",
|
||||
spath,
|
||||
"missing SentrixBarcode_A/SentrixPosition_A (or beadChipName/sampleSectionName) columns",
|
||||
)
|
||||
|
||||
recognised = {fold(c) for c in RECOGNISED} | {fold(key[0]), fold(key[1])}
|
||||
reserved = {fold(c) for c in RESERVED_OUTPUT_COLUMNS}
|
||||
for c in columns:
|
||||
k = fold(c)
|
||||
if k in recognised:
|
||||
continue
|
||||
if k in reserved or _NORM_RE.match(c) or _NORM_COMPAT_RE.match(c):
|
||||
raise _err(
|
||||
"E_SHEET_RESERVED",
|
||||
spath,
|
||||
f"column {c!r} is reserved for SimurgArray output",
|
||||
"rename the column (or use --namespace-sheet-columns)",
|
||||
)
|
||||
|
||||
out_rows: list[SheetRow] = []
|
||||
ids: dict[str, int] = {}
|
||||
sex_cols = [folded[fold(c)] for c in SEX_COLUMNS_PRIORITY if fold(c) in folded]
|
||||
name_col = folded.get(fold("Sample_Name"))
|
||||
for line_no, row in data_rows[1:]:
|
||||
values: dict[str, str] = {}
|
||||
for i, nm in col_idx:
|
||||
v = row[i] if i < len(row) else ""
|
||||
if len(v) > MAX_LEN:
|
||||
raise _err(
|
||||
"E_SHEET_TOO_LONG",
|
||||
spath,
|
||||
f"line {line_no}, column {nm!r}: value has {len(v)} characters (limit {MAX_LEN})",
|
||||
)
|
||||
values[nm] = v
|
||||
where = f"line {line_no}"
|
||||
barcode = validate_barcode(values[key[0]], where, warnings, spath)
|
||||
position = validate_position(values[key[1]], where, spath)
|
||||
sr = SheetRow(line_no, barcode, position, values)
|
||||
if name_col is not None:
|
||||
sr.sample_name = values[name_col].strip()
|
||||
sexes = [normalise_sex(values[c]) for c in sex_cols]
|
||||
known = [s for s in sexes if s != "U"]
|
||||
if len(set(known)) > 1:
|
||||
warnings.append(("W_SHEET_SEX_CONFLICT", f"{where}: reported-sex columns disagree; using {sex_cols[0]}"))
|
||||
sr.reported_sex = sexes[0] if sexes else "U"
|
||||
if sr.sample_id in ids:
|
||||
if on_duplicate_sample == "drop":
|
||||
warnings.append(
|
||||
(
|
||||
"E_SHEET_DUP_SAMPLE",
|
||||
f"{where}: duplicate sample {sr.sample_id} dropped (first at line {ids[sr.sample_id]})",
|
||||
)
|
||||
)
|
||||
continue
|
||||
raise _err(
|
||||
"E_SHEET_DUP_SAMPLE",
|
||||
spath,
|
||||
f"{where}: sample {sr.sample_id} already listed at line {ids[sr.sample_id]}",
|
||||
)
|
||||
ids[sr.sample_id] = line_no
|
||||
out_rows.append(sr)
|
||||
|
||||
if not out_rows:
|
||||
raise _err("E_SHEET_EMPTY", spath, "no data rows")
|
||||
|
||||
if name_col is not None:
|
||||
names: dict[str, int] = {}
|
||||
for sr in out_rows:
|
||||
if not sr.sample_name:
|
||||
raise _err(
|
||||
"E_SHEET_NAME_EMPTY",
|
||||
spath,
|
||||
f"line {sr.row_number}: Sample_Name is empty (it must be set in every row when the column exists)",
|
||||
)
|
||||
if sr.sample_name in ids and sr.sample_name != sr.sample_id:
|
||||
raise _err(
|
||||
"E_SHEET_NAME_COLLISION",
|
||||
spath,
|
||||
f"line {sr.row_number}: Sample_Name {sr.sample_name!r} equals the ID of another sample",
|
||||
)
|
||||
if sr.sample_name in names:
|
||||
raise _err(
|
||||
"E_SHEET_NAME_COLLISION",
|
||||
spath,
|
||||
f"line {sr.row_number}: Sample_Name "
|
||||
f"{sr.sample_name!r} already used at line {names[sr.sample_name]}",
|
||||
)
|
||||
names[sr.sample_name] = sr.row_number
|
||||
|
||||
root: Path | None = None
|
||||
for k, v in header_kv.items():
|
||||
if fold(k) == "rootfolder" and v:
|
||||
rp = Path(v)
|
||||
root = rp if rp.is_absolute() else (p.parent / rp)
|
||||
return SampleSheet(
|
||||
path=spath,
|
||||
header=header_kv,
|
||||
root_folder=root,
|
||||
columns=columns,
|
||||
rows=out_rows,
|
||||
barcode_column=key[0],
|
||||
position_column=key[1],
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
_STEM_BAD = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
def output_stem(name: str) -> str:
|
||||
"""File-name stem for a sample (IN-012)."""
|
||||
s = _STEM_BAD.sub("_", name).lstrip(".")
|
||||
return (s or "_")[:200]
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray genotype package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray harmonize package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray pgx package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray pipeline package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray qc package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray signal package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""SimurgArray synth package."""
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user