diff --git a/CODEOWNERS b/CODEOWNERS index fb2f6c8..de9c4f7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,32 +1,22 @@ # Code owners — GitHub auto-requests a review from these owners on matching PRs. # This is the enforcement behind "agents write, humans merge" (ADR-0005): code and -# tests must be reviewed by a code owner. See docs/explanation/pull-requests-and-branch-protection.md +# tests must be reviewed by an engineer. See docs/explanation/pull-requests-and-branch-protection.md # -# Code owners (all have write access): -# @boxiangliu — Boxiang Liu (lead) -# @gaojunbin — Junbin Gao -# @GhostAnderson — Laurentius -# @liufei-f — Liu Fei -# @MickYang2333 — YANG Chen -# @wenjing-gakkilove — Wenjing +# Engineers / code owners (all have write access): +# @boxiangliu — Boxiang Liu (lead) +# @gaojunbin — Junbin Gao +# @GhostAnderson — Laurentius # # NOTE: a code owner is only effective if they have WRITE access to the repo. -# Any one code-owner approval satisfies branch protection. # Code and tests. -/src/ @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove -/tests/ @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove +/src/ @boxiangliu @gaojunbin @GhostAnderson +/tests/ @boxiangliu @gaojunbin @GhostAnderson # CI, containers, and guardrails. -/.github/ @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove -/Dockerfile @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove -/.pre-commit-config.yaml @boxiangliu @gaojunbin @GhostAnderson @liufei-f @MickYang2333 @wenjing-gakkilove - -# UI / design surfaces — owned by the UI/UX designer (Liu Fei). These come AFTER -# /src/ above so they take precedence for these paths (CODEOWNERS = last match wins), -# making @liufei-f the required reviewer for design docs and the web templates. -/docs/design/ @liufei-f -/src/locusview/templates/ @liufei-f +/.github/ @boxiangliu @gaojunbin @GhostAnderson +/Dockerfile @boxiangliu @gaojunbin @GhostAnderson +/.pre-commit-config.yaml @boxiangliu @gaojunbin @GhostAnderson # Product, process, and course docs can be reviewed by anyone on the team; # no CODEOWNERS entry means the default reviewers apply. diff --git a/pyproject.toml b/pyproject.toml index b3367d9..268e8ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ src = ["src", "tests"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] +# Allow scientific unicode (×, −, ², β, r², −log₁₀) in docstrings/comments/strings. +ignore = ["RUF001", "RUF002", "RUF003"] # ── pytest + coverage gate ───────────────────────────────────────────────── [tool.pytest.ini_options] diff --git a/src/locusview/repository.py b/src/locusview/repository.py index 43df1a1..b46764b 100644 --- a/src/locusview/repository.py +++ b/src/locusview/repository.py @@ -80,6 +80,20 @@ def eqtls_for_gene( """Return eQTL associations for ``gene_id`` within the given datasets.""" ... + def cis_associations(self, gene_id: int, dataset_id: int) -> list[EqtlAssociation]: + """ALL cis variants for one gene in ONE tissue (the regional-plot set, ~thousands).""" + ... + + def ld_r2(self, chrom: str, lead_rs_id: int, population: str) -> dict[int, float]: + """r² of every partner variant to the lead, from the 1000G LD panel (excludes the lead).""" + ... + + def tissues_with_signal( + self, gene_id: int, p_threshold: float = 1e-5 + ) -> list[tuple[int, str, float]]: + """(dataset_id, tissue, min_pvalue) for tissues where the gene has a significant eQTL.""" + ... + # ── Shared pure helpers ────────────────────────────────────────────────────── @@ -89,6 +103,20 @@ def eqtls_for_gene( _ENSG = re.compile(r"^ENSG0*(\d+)$", re.IGNORECASE) _GENCODE_TABLE = "gencode_v26_hg38" # GTEx v8 uses GENCODE v26 (hg38) +_RS_PARTNER = re.compile(r"^rs(\d+)$") +CHROMS = frozenset([*(str(i) for i in range(1, 23)), "X"]) +POPULATIONS = frozenset({"AFR", "AMR", "EAS", "EUR", "SAS"}) + + +def _ld_table(chrom: str, population: str) -> str: + """Return the 1000G LD table name for a chromosome + super-population (enum-validated, + so the interpolated table name is safe).""" + if chrom not in CHROMS: + raise ValueError(f"unknown chromosome: {chrom!r}") + if population not in POPULATIONS: + raise ValueError(f"unknown population: {population!r}") + return f"tkg_p3v5a_ld_chr{chrom}_{population}" + def ensembl_number(ensembl_id: str) -> int: """Convert an Ensembl gene id to the integer key used by the shards. @@ -168,10 +196,12 @@ def __init__( datasets: Sequence[Dataset] | None = None, associations: Sequence[EqtlAssociation] | None = None, genes: Sequence[Gene] | None = None, + ld: dict[tuple[str, int, str], dict[int, float]] | None = None, ) -> None: self._datasets = list(datasets or ()) self._associations = list(associations or ()) self._genes = list(genes or ()) + self._ld = dict(ld or {}) def datasets(self) -> list[Dataset]: return list(self._datasets) @@ -191,6 +221,26 @@ def eqtls_for_gene( hits = [a for a in self._associations if a.gene_id == gene_id and a.dataset_id in wanted] return hits[:limit] + def cis_associations(self, gene_id: int, dataset_id: int) -> list[EqtlAssociation]: + return [ + a for a in self._associations if a.gene_id == gene_id and a.dataset_id == dataset_id + ] + + def ld_r2(self, chrom: str, lead_rs_id: int, population: str) -> dict[int, float]: + return dict(self._ld.get((chrom, lead_rs_id, population), {})) + + def tissues_with_signal( + self, gene_id: int, p_threshold: float = 1e-5 + ) -> list[tuple[int, str, float]]: + name = {d.id: d.tissue for d in self._datasets} + best: dict[int, float] = {} + for a in self._associations: + if a.gene_id == gene_id and a.pvalue is not None and a.pvalue < p_threshold: + best[a.dataset_id] = min(best.get(a.dataset_id, 1.0), a.pvalue) + return sorted( + ((ds, name.get(ds, str(ds)), p) for ds, p in best.items()), key=lambda t: t[2] + ) + # ── Real (locuscompare2 MySQL) ────────────────────────────────────────────── @@ -253,6 +303,53 @@ def eqtls_for_gene( finally: conn.close() + def cis_associations(self, gene_id: int, dataset_id: int) -> list[EqtlAssociation]: + table = _shard_table(dataset_id) + rows = self._query( + f"SELECT gene_id, rs_id, chrom, position, pvalue, beta, se " + f"FROM {table} WHERE gene_id = %s", + (gene_id,), + ) + return [_row_to_eqtl(dataset_id, r) for r in rows] + + def ld_r2(self, chrom: str, lead_rs_id: int, population: str) -> dict[int, float]: + table = _ld_table(chrom, population) + lead = f"rs{lead_rs_id}" + # LD pairs are stored one-directional, so union both directions and dedupe with MAX. + rows = self._query( + f"SELECT partner, MAX(R2) AS r2 FROM (" + f" SELECT SNP_B AS partner, R2 FROM {table} WHERE SNP_A = %s" + f" UNION ALL" + f" SELECT SNP_A AS partner, R2 FROM {table} WHERE SNP_B = %s" + f") u GROUP BY partner", + (lead, lead), + ) + out: dict[int, float] = {} + for partner, r2 in rows: + m = _RS_PARTNER.match(str(partner)) + if m is None or r2 is None: # skip non-rs ids (esv/ss/…) and NULLs + continue + out[int(m.group(1))] = max(0.0, min(1.0, float(r2))) + out.pop(lead_rs_id, None) # exclude self; the caller sets the lead's own r² = 1.0 + return out + + def tissues_with_signal( + self, gene_id: int, p_threshold: float = 1e-5 + ) -> list[tuple[int, str, float]]: + # Fan-out across dataset shards. NOTE: O(#tissues) queries — a candidate for a + # per-gene summary table (schema-change-coordination) if it gets hot. + out: list[tuple[int, str, float]] = [] + for dataset in self.datasets(): + table = _shard_table(dataset.id) + # pvalue is stored as text; `+ 0.0` forces numeric MIN (handles decimal + scientific). + rows = self._query( + f"SELECT MIN(pvalue + 0.0) FROM {table} WHERE gene_id = %s", (gene_id,) + ) + min_p = rows[0][0] if rows else None + if min_p is not None and float(min_p) < p_threshold: + out.append((dataset.id, dataset.tissue, float(min_p))) + return sorted(out, key=lambda t: t[2]) + def pymysql_connection_factory() -> ConnectionFactory: """Build a factory that opens a read-only pymysql connection from ``LOCUSCOMPARE2_DB_*`` diff --git a/src/locusview/templates/gene.html b/src/locusview/templates/gene.html index 1f2756b..1fb4149 100644 --- a/src/locusview/templates/gene.html +++ b/src/locusview/templates/gene.html @@ -4,14 +4,34 @@
Loading…
+ LD = 1000G phase 3 (selected population) — a single-population approximation and a visual aid to + locus structure, not part of the association inference. The dotted line is genome-wide + significance (5×10−8). +
+Showing eQTLs from {{ n_tissues }} of {{ total_tissues }} datasets (bounded for the MVP). @@ -55,5 +108,79 @@
locusview v{{ version }}
+ +