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

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

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

No files matched your search

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