Files
SimurgArray/src/simurg/core/schemas.py
T
kemalandClaude Opus 5.5 314fdc40d1
ci / test (3.12) (push) Failing after 8s
ci / test (3.13) (push) Failing after 10s
P3: normalization fit (1.2.0 and 1.1.2), apply, polar, simurg normalize
- signal/normalization/numerics.py: vendor percentile, trimmean, MAD sigma
  (old/new), bisquare robust fits, float32/float64 typing and sequential
  sums reproduced exactly (ported from gtc2vcf, MIT)
- signal/normalization/fit.py: outlier removal, offset, rotation, shear,
  scale, nn12/rr12, single-channel model for small Infinium I bins,
  exact and binned nearest-neighbour sweeps; vendor quirks marked QUIRK
- signal/normalization/apply.py: affine transform and theta/R
- pipeline/normalize.py + CLI `simurg normalize`
- oracle GTCs rebuilt with -ffp-contract=off (aarch64 gcc fuses a*b+c by
  default, shifting the last bits); the old FMA oracle differed by <=4 ULP

Result: transforms bit-identical to the oracle for all 36 GSA samples x
48 bins, for both normalization versions (3456/3456 bins, 0 ULP).

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

192 lines
6.4 KiB
Python

"""Canonical columnar data model (plan §5) as Apache Arrow schemas, stored as Parquet.
Schemas carry a ``simurg.schema`` metadata key with a version, so readers can refuse incompatible files.
Parquet files are written without timestamps so identical inputs give byte-identical files (plan §15).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
from simurg.core.atomic import atomic_open
from simurg.formats.bpm import Manifest
LOCI_SCHEMA_ID = "simurg.loci/1"
INTENSITY_SCHEMA_ID = "simurg.sample_intensity/1"
CONTROLS_SCHEMA_ID = "simurg.sample_controls/1"
NORMALIZED_SCHEMA_ID = "simurg.sample_normalized/1"
TRANSFORMS_SCHEMA_ID = "simurg.sample_transforms/1"
LOCI_SCHEMA = pa.schema(
[
("locus_idx", pa.int32()),
("name", pa.string()),
("ilmn_id", pa.string()),
("chrom", pa.string()),
("pos", pa.int64()),
("snp", pa.string()),
("ilmn_strand", pa.string()),
("source_strand", pa.string()),
("ref_strand", pa.string()),
("address_a", pa.int32()),
("address_b", pa.int32()),
("assay_type", pa.uint8()),
("norm_id", pa.uint8()),
("norm_bin_id", pa.uint8()),
("norm_lookup", pa.int32()),
("exp_clusters", pa.int8()),
("intensity_only", pa.bool_()),
("ploidy", pa.string()),
("genome_build", pa.string()),
],
metadata={"simurg.schema": LOCI_SCHEMA_ID},
)
INTENSITY_SCHEMA = pa.schema(
[("raw_x", pa.uint16()), ("raw_y", pa.uint16()), ("missing", pa.bool_())],
metadata={"simurg.schema": INTENSITY_SCHEMA_ID},
)
CONTROLS_SCHEMA = pa.schema(
[("address", pa.int32()), ("red", pa.uint16()), ("green", pa.uint16())],
metadata={"simurg.schema": CONTROLS_SCHEMA_ID},
)
NORMALIZED_SCHEMA = pa.schema(
[("norm_x", pa.float32()), ("norm_y", pa.float32()), ("theta", pa.float32()), ("r", pa.float32())],
metadata={"simurg.schema": NORMALIZED_SCHEMA_ID},
)
TRANSFORMS_SCHEMA = pa.schema(
[
("bin_index", pa.int32()),
("norm_bin_id", pa.uint8()),
("n_loci", pa.int32()),
("version", pa.int32()),
("offset_x", pa.float32()),
("offset_y", pa.float32()),
("scale_x", pa.float32()),
("scale_y", pa.float32()),
("shear", pa.float32()),
("theta", pa.float32()),
("cvx", pa.float32()),
("cvy", pa.float32()),
("nn12", pa.float32()),
("rr12", pa.float32()),
("taa", pa.float32()),
("tbb", pa.float32()),
],
metadata={"simurg.schema": TRANSFORMS_SCHEMA_ID},
)
_WRITE_OPTS: dict[str, Any] = {"compression": "zstd", "use_dictionary": True, "write_statistics": True}
def _dict_cols(values: list[str]) -> pa.Array:
return pa.array(values, type=pa.string())
def loci_table(m: Manifest) -> pa.Table:
n = m.num_loci
cols = {
"locus_idx": pa.array(np.arange(n, dtype=np.int32)),
"name": _dict_cols(m.names),
"ilmn_id": _dict_cols(m.ilmn_ids),
"chrom": _dict_cols(m.chroms),
"pos": pa.array(m.map_infos.astype(np.int64)),
"snp": _dict_cols(m.snps),
"ilmn_strand": _dict_cols(m.ilmn_strands),
"source_strand": _dict_cols(m.source_strands),
"ref_strand": _dict_cols(m.ref_strands),
"address_a": pa.array(m.address_a.astype(np.int32)),
"address_b": pa.array(m.address_b.astype(np.int32)),
"assay_type": pa.array(m.assay_types.astype(np.uint8)),
"norm_id": pa.array(m.norm_ids.astype(np.uint8)),
"norm_bin_id": pa.array(m.norm_bin_ids()),
"norm_lookup": pa.array(m.normalization_lookups().astype(np.int32)),
"exp_clusters": pa.array(m.exp_clusters.astype(np.int8)),
"intensity_only": pa.array(m.intensity_only.astype(np.bool_)),
"ploidy": _dict_cols(m.ploidies),
"genome_build": _dict_cols(m.genome_builds),
}
meta = {"simurg.schema": LOCI_SCHEMA_ID, "manifest_name": m.manifest_name, "manifest_format": m.format}
return pa.table(cols, schema=LOCI_SCHEMA.with_metadata(meta))
def with_metadata(table: pa.Table, extra: dict[str, Any]) -> pa.Table:
meta = dict(table.schema.metadata or {})
meta.update(
{k.encode(): (v if isinstance(v, str) else json.dumps(v, sort_keys=True)).encode() for k, v in extra.items()}
)
return table.replace_schema_metadata(meta)
def intensity_table(raw_x: np.ndarray, raw_y: np.ndarray, missing: np.ndarray, meta: dict[str, Any]) -> pa.Table:
t = pa.table(
{
"raw_x": pa.array(raw_x.astype(np.uint16)),
"raw_y": pa.array(raw_y.astype(np.uint16)),
"missing": pa.array(missing.astype(np.bool_)),
},
schema=INTENSITY_SCHEMA,
)
return with_metadata(t, meta)
def controls_table(addresses: np.ndarray, red: np.ndarray, green: np.ndarray, meta: dict[str, Any]) -> pa.Table:
t = pa.table(
{
"address": pa.array(addresses.astype(np.int32)),
"red": pa.array(red.astype(np.uint16)),
"green": pa.array(green.astype(np.uint16)),
},
schema=CONTROLS_SCHEMA,
)
return with_metadata(t, meta)
def normalized_table(
nx: np.ndarray, ny: np.ndarray, theta: np.ndarray, r: np.ndarray, meta: dict[str, Any]
) -> pa.Table:
t = pa.table(
{
"norm_x": pa.array(nx, pa.float32()),
"norm_y": pa.array(ny, pa.float32()),
"theta": pa.array(theta, pa.float32()),
"r": pa.array(r, pa.float32()),
},
schema=NORMALIZED_SCHEMA,
)
return with_metadata(t, meta)
def transforms_table(rows: list[dict[str, Any]], meta: dict[str, Any]) -> pa.Table:
t = pa.Table.from_pylist(rows, schema=TRANSFORMS_SCHEMA)
return with_metadata(t, meta)
def write_parquet(table: pa.Table, path: Path) -> None:
"""Atomic, deterministic Parquet write."""
with atomic_open(Path(path), "wb") as fh:
pq.write_table(table, fh, **_WRITE_OPTS)
def read_metadata(path: Path) -> dict[str, str]:
md = pq.read_schema(path).metadata or {}
return {k.decode(): v.decode() for k, v in md.items()}
def read_table(path: Path, expect_schema: str | None = None) -> pa.Table:
t = pq.read_table(path)
if expect_schema is not None:
got = (t.schema.metadata or {}).get(b"simurg.schema", b"").decode()
if got != expect_schema:
raise ValueError(f"{path}: schema {got!r}, expected {expect_schema!r}")
return t