gnomancer 0.3.2__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gnomancer-0.4.0/PKG-INFO +92 -0
- gnomancer-0.4.0/README.md +67 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/__init__.py +1 -1
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/cli.py +9 -2
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/clinvar.py +10 -1
- gnomancer-0.4.0/gnomancer/data/marker_frequencies.tsv +113 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/mcp_server.py +14 -1
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/panel.py +29 -2
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/report.py +9 -3
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/report_html.py +19 -9
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/summary.py +5 -2
- gnomancer-0.4.0/gnomancer/watch.py +126 -0
- gnomancer-0.4.0/gnomancer.egg-info/PKG-INFO +92 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/SOURCES.txt +2 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/pyproject.toml +1 -1
- {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_annotations.py +21 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_markers.py +12 -0
- gnomancer-0.3.2/PKG-INFO +0 -211
- gnomancer-0.3.2/README.md +0 -186
- gnomancer-0.3.2/gnomancer.egg-info/PKG-INFO +0 -211
- {gnomancer-0.3.2 → gnomancer-0.4.0}/LICENSE +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/__main__.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/context.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/data/haplogroup_context.tsv +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/data/markers.tsv +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/data/rCRS_NC_012920.1.fasta +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/haplogroups.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/load.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/paths.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/pharmcat.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/qc.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/roh.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/setup_cmd.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/yleaf.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/dependency_links.txt +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/entry_points.txt +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/requires.txt +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/top_level.txt +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/setup.cfg +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_mcp.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_pipeline.py +0 -0
- {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_summary.py +0 -0
gnomancer-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gnomancer
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Local, private deep dive into a consumer raw DNA file (AncestryDNA, 23andMe, MyHeritage, FTDNA, LivingDNA, VCF): QC, haplogroups, traits, pharmacogenomics, one HTML report.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/codebase/gnomancer
|
|
7
|
+
Project-URL: Issues, https://github.com/codebase/gnomancer/issues
|
|
8
|
+
Keywords: dna,genetics,23andme,ancestrydna,haplogroup,pharmacogenomics,clinvar,mcp
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: snps>=2.9
|
|
17
|
+
Requires-Dist: pandas>=2.0
|
|
18
|
+
Requires-Dist: numpy>=1.24
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: mcp>=2; extra == "dev"
|
|
22
|
+
Provides-Extra: mcp
|
|
23
|
+
Requires-Dist: mcp>=2; extra == "mcp"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
<h1 align="center">
|
|
27
|
+
<img src="docs/logo.png" width="160" alt=""><br>
|
|
28
|
+
gnomancer
|
|
29
|
+
</h1>
|
|
30
|
+
|
|
31
|
+
<p align="center"><strong>Your DNA, decoded on your own machine.</strong><br>
|
|
32
|
+
<a href="https://gnomancer.com">gnomancer.com</a> ·
|
|
33
|
+
<a href="https://gnomancer.com/get-your-data/">get your raw data</a> ·
|
|
34
|
+
<a href="https://gnomancer.com/install/">install</a> ·
|
|
35
|
+
<a href="https://pypi.org/project/gnomancer/">PyPI</a> ·
|
|
36
|
+
<a href="https://registry.modelcontextprotocol.io">MCP Registry</a> ·
|
|
37
|
+
<a href="https://discord.gg/aGjqzqErQH">Discord</a></p>
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<a href="https://pypi.org/project/gnomancer/"><img src="https://img.shields.io/pypi/v/gnomancer?color=7c3aed" alt="PyPI"></a>
|
|
41
|
+
<a href="https://github.com/codebase/gnomancer/actions/workflows/ci.yml"><img src="https://github.com/codebase/gnomancer/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
42
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-7c3aed" alt="MIT"></a>
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
Take the raw file you already downloaded from **AncestryDNA, 23andMe, MyHeritage, FamilyTreeDNA or LivingDNA** and turn it into one readable report. Everything runs on your computer. Nothing is uploaded.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pipx install gnomancer # Python 3.10+; Java 11+ for the haplogroup and PharmCAT steps
|
|
51
|
+
gnomancer setup # fetches the open-source tools and reference files once (~250 MB)
|
|
52
|
+
gnomancer ~/Downloads/AncestryDNA.zip
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Open `AncestryDNA_analysis/REPORT.html`. No Python? `curl -fsSL https://gnomancer.com/install.sh | bash` does it all. Windows: [via WSL](https://gnomancer.com/install/).
|
|
56
|
+
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
## What you get
|
|
60
|
+
|
|
61
|
+
- **Plain language first.** An at-a-glance digest, then a narrative your own AI assistant can write from a prompt the run gives you.
|
|
62
|
+
- **Lineages.** Y haplogroup (yhaplo, plus Yleaf on current YFull/FTDNA/ISOGG trees) and mtDNA haplogroup (Haplogrep3), each with population context.
|
|
63
|
+
- **Traits and ancestry markers.** About 110 well-replicated sites: pigmentation, lactose, caffeine, bitter taste, blood group and more.
|
|
64
|
+
- **Health, carefully.** Carrier sites, common risk variants, a ClinVar scan of every typed site with plausibility scoring, and PharmCAT pharmacogenomics. All collapsed by default so nobody sees a result they didn't ask for.
|
|
65
|
+
- **Stays current.** `gnomancer watch` re-checks ClinVar each month and tells you only what was reclassified at sites you carry.
|
|
66
|
+
- **Honest limits.** Every section says what the chip can and cannot tell you.
|
|
67
|
+
|
|
68
|
+
## Use it from your AI assistant
|
|
69
|
+
|
|
70
|
+
gnomancer is also an MCP server. Connect it and ask "what does my report say about caffeine?"
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
claude mcp add gnomancer -- uvx gnomancer serve # Claude Code; Claude Desktop/Cursor: command "uvx", args ["gnomancer","serve"]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Only derived results reach the assistant, never the raw file. Health tables are separate tools fetched only on request. The repo also works as a Claude plugin (`skills/gnomancer/SKILL.md`).
|
|
77
|
+
|
|
78
|
+
## Privacy
|
|
79
|
+
|
|
80
|
+
Your genotype file never leaves your machine. The only network traffic is the one-time download of public tools and references. The optional `--ai` flag sends the structured results (not the file) to the Anthropic API to write the narrative, and only when you ask.
|
|
81
|
+
|
|
82
|
+
## Not medical advice
|
|
83
|
+
|
|
84
|
+
A genotyping array reads about 0.02% of the genome at pre-chosen sites, and its calls at rare sites are wrong often enough that a clinical lab found [40% did not confirm](https://doi.org/10.1038/gim.2018.38). gnomancer tells you what the chip says and how far to trust it. Confirm anything that matters with a clinical-grade test and a clinician or genetic counselor.
|
|
85
|
+
|
|
86
|
+
## More
|
|
87
|
+
|
|
88
|
+
[Discord community](https://discord.gg/aGjqzqErQH) · [CLI options](docs/cli.md) · [vendors and quirks](docs/vendors.md) · [the marker panel](docs/marker-panel.md) · [tools under the hood](docs/tools.md) · [development](docs/development.md) · [MCP registry](docs/mcp-registry.md) · [contributing](CONTRIBUTING.md)
|
|
89
|
+
|
|
90
|
+
MIT licensed. The bundled tools keep their own licenses.
|
|
91
|
+
|
|
92
|
+
<sub>mcp-name: com.gnomancer/gnomancer</sub>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<img src="docs/logo.png" width="160" alt=""><br>
|
|
3
|
+
gnomancer
|
|
4
|
+
</h1>
|
|
5
|
+
|
|
6
|
+
<p align="center"><strong>Your DNA, decoded on your own machine.</strong><br>
|
|
7
|
+
<a href="https://gnomancer.com">gnomancer.com</a> ·
|
|
8
|
+
<a href="https://gnomancer.com/get-your-data/">get your raw data</a> ·
|
|
9
|
+
<a href="https://gnomancer.com/install/">install</a> ·
|
|
10
|
+
<a href="https://pypi.org/project/gnomancer/">PyPI</a> ·
|
|
11
|
+
<a href="https://registry.modelcontextprotocol.io">MCP Registry</a> ·
|
|
12
|
+
<a href="https://discord.gg/aGjqzqErQH">Discord</a></p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://pypi.org/project/gnomancer/"><img src="https://img.shields.io/pypi/v/gnomancer?color=7c3aed" alt="PyPI"></a>
|
|
16
|
+
<a href="https://github.com/codebase/gnomancer/actions/workflows/ci.yml"><img src="https://github.com/codebase/gnomancer/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
17
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-7c3aed" alt="MIT"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
Take the raw file you already downloaded from **AncestryDNA, 23andMe, MyHeritage, FamilyTreeDNA or LivingDNA** and turn it into one readable report. Everything runs on your computer. Nothing is uploaded.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install gnomancer # Python 3.10+; Java 11+ for the haplogroup and PharmCAT steps
|
|
26
|
+
gnomancer setup # fetches the open-source tools and reference files once (~250 MB)
|
|
27
|
+
gnomancer ~/Downloads/AncestryDNA.zip
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Open `AncestryDNA_analysis/REPORT.html`. No Python? `curl -fsSL https://gnomancer.com/install.sh | bash` does it all. Windows: [via WSL](https://gnomancer.com/install/).
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
## What you get
|
|
35
|
+
|
|
36
|
+
- **Plain language first.** An at-a-glance digest, then a narrative your own AI assistant can write from a prompt the run gives you.
|
|
37
|
+
- **Lineages.** Y haplogroup (yhaplo, plus Yleaf on current YFull/FTDNA/ISOGG trees) and mtDNA haplogroup (Haplogrep3), each with population context.
|
|
38
|
+
- **Traits and ancestry markers.** About 110 well-replicated sites: pigmentation, lactose, caffeine, bitter taste, blood group and more.
|
|
39
|
+
- **Health, carefully.** Carrier sites, common risk variants, a ClinVar scan of every typed site with plausibility scoring, and PharmCAT pharmacogenomics. All collapsed by default so nobody sees a result they didn't ask for.
|
|
40
|
+
- **Stays current.** `gnomancer watch` re-checks ClinVar each month and tells you only what was reclassified at sites you carry.
|
|
41
|
+
- **Honest limits.** Every section says what the chip can and cannot tell you.
|
|
42
|
+
|
|
43
|
+
## Use it from your AI assistant
|
|
44
|
+
|
|
45
|
+
gnomancer is also an MCP server. Connect it and ask "what does my report say about caffeine?"
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
claude mcp add gnomancer -- uvx gnomancer serve # Claude Code; Claude Desktop/Cursor: command "uvx", args ["gnomancer","serve"]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Only derived results reach the assistant, never the raw file. Health tables are separate tools fetched only on request. The repo also works as a Claude plugin (`skills/gnomancer/SKILL.md`).
|
|
52
|
+
|
|
53
|
+
## Privacy
|
|
54
|
+
|
|
55
|
+
Your genotype file never leaves your machine. The only network traffic is the one-time download of public tools and references. The optional `--ai` flag sends the structured results (not the file) to the Anthropic API to write the narrative, and only when you ask.
|
|
56
|
+
|
|
57
|
+
## Not medical advice
|
|
58
|
+
|
|
59
|
+
A genotyping array reads about 0.02% of the genome at pre-chosen sites, and its calls at rare sites are wrong often enough that a clinical lab found [40% did not confirm](https://doi.org/10.1038/gim.2018.38). gnomancer tells you what the chip says and how far to trust it. Confirm anything that matters with a clinical-grade test and a clinician or genetic counselor.
|
|
60
|
+
|
|
61
|
+
## More
|
|
62
|
+
|
|
63
|
+
[Discord community](https://discord.gg/aGjqzqErQH) · [CLI options](docs/cli.md) · [vendors and quirks](docs/vendors.md) · [the marker panel](docs/marker-panel.md) · [tools under the hood](docs/tools.md) · [development](docs/development.md) · [MCP registry](docs/mcp-registry.md) · [contributing](CONTRIBUTING.md)
|
|
64
|
+
|
|
65
|
+
MIT licensed. The bundled tools keep their own licenses.
|
|
66
|
+
|
|
67
|
+
<sub>mcp-name: com.gnomancer/gnomancer</sub>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""gnomancer: vendor-agnostic analysis of consumer raw DNA files."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.4.0"
|
|
@@ -48,7 +48,10 @@ def main(argv=None):
|
|
|
48
48
|
if argv[:1] == ["setup"]:
|
|
49
49
|
from .setup_cmd import run as setup
|
|
50
50
|
return setup(argv[1:])
|
|
51
|
-
|
|
51
|
+
if argv[:1] == ["watch"]:
|
|
52
|
+
from .watch import run as watch
|
|
53
|
+
return watch(argv[1:])
|
|
54
|
+
ap = argparse.ArgumentParser(prog="gnomancer", description="Analyze a consumer raw DNA file locally. Subcommands: `gnomancer setup` (download tools), `gnomancer serve` (MCP server), `gnomancer watch REPORT_DIR` (ClinVar reclassification check).")
|
|
52
55
|
ap.add_argument("input", help="raw data file (.txt/.csv/.tsv/.vcf, optionally .zip/.gz)")
|
|
53
56
|
ap.add_argument("--out", default=None, help="output directory (default: <input>_analysis)")
|
|
54
57
|
ap.add_argument("--markers", default=str(DATA / "markers.tsv"), help="marker panel TSV")
|
|
@@ -108,6 +111,9 @@ def main(argv=None):
|
|
|
108
111
|
cv = clinvar_mod.scan(df)
|
|
109
112
|
if cv["available"] and cv["hits"] is not None and not cv["hits"].empty:
|
|
110
113
|
cv["hits"].to_csv(out / "clinvar-hits.tsv", sep="\t", index=False)
|
|
114
|
+
if cv["available"]:
|
|
115
|
+
from .watch import write_snapshot
|
|
116
|
+
write_snapshot(out, cv, str(inp.resolve()))
|
|
111
117
|
cv_text = clinvar_mod.interpret(cv) if not a.no_clinvar else cv["note"]
|
|
112
118
|
pc = dict(available=False, calls=None, error="skipped (--no-pharmcat)", stats={})
|
|
113
119
|
if not a.no_pharmcat:
|
|
@@ -134,7 +140,8 @@ def main(argv=None):
|
|
|
134
140
|
(out / "summary.md").write_text(narrative + "\n")
|
|
135
141
|
except Exception as e: # noqa: BLE001
|
|
136
142
|
loaded.notes.append(f"AI summary failed: {e}")
|
|
137
|
-
|
|
143
|
+
pop = (ancestry or {}).get("superpopulation_code") or "EUR"
|
|
144
|
+
extra = dict(clinvar=cv, clinvar_text=cv_text, pharmcat=pc, pharmcat_text=pc_text, glance=glance, narrative=narrative, pop=pop)
|
|
138
145
|
rep = write(out / "REPORT.md", loaded, qc, ct, roh, roh_text, panel, combo, y, mt, ancestry, extra)
|
|
139
146
|
html = write_html(out / "REPORT.html", loaded, qc, ct, roh, roh_text, panel, combo, y, mt, ancestry, input_name=inp.name, extra=extra)
|
|
140
147
|
json.dump(dict(input=str(inp.resolve()), args=[str(Path(x).resolve()) if Path(x).exists() else x for x in run_args],
|
|
@@ -45,7 +45,8 @@ def _info(s: str) -> dict:
|
|
|
45
45
|
|
|
46
46
|
def scan(df: pd.DataFrame, vcf: Path = DEFAULT_VCF) -> dict:
|
|
47
47
|
"""df: normalized SNP table (chrom, pos, genotype). Returns dict with hits table and coverage stats."""
|
|
48
|
-
res = dict(available=vcf.exists(), vcf=str(vcf), sites_in_clinvar=0, pathogenic_sites_typed=0, hits=pd.DataFrame(), note=""
|
|
48
|
+
res = dict(available=vcf.exists(), vcf=str(vcf), sites_in_clinvar=0, pathogenic_sites_typed=0, hits=pd.DataFrame(), note="",
|
|
49
|
+
carried={}, release=None)
|
|
49
50
|
if not vcf.exists():
|
|
50
51
|
res["note"] = "ClinVar VCF not found; run `gnomancer setup` (downloads ~200 MB from NCBI)."
|
|
51
52
|
return res
|
|
@@ -54,10 +55,13 @@ def scan(df: pd.DataFrame, vcf: Path = DEFAULT_VCF) -> dict:
|
|
|
54
55
|
for chrom, pos, gt in zip(called.chrom, called.pos, called.genotype):
|
|
55
56
|
key[(chrom, int(pos))] = gt
|
|
56
57
|
hits = []
|
|
58
|
+
carried_sites = {}
|
|
57
59
|
n_in = n_path = 0
|
|
58
60
|
with gzip.open(vcf, "rt") as f:
|
|
59
61
|
for line in f:
|
|
60
62
|
if line[0] == "#":
|
|
63
|
+
if line.startswith("##fileDate="):
|
|
64
|
+
res["release"] = line.strip().split("=", 1)[1]
|
|
61
65
|
continue
|
|
62
66
|
p = line.rstrip("\n").split("\t", 8)
|
|
63
67
|
chrom = p[0]
|
|
@@ -70,6 +74,10 @@ def scan(df: pd.DataFrame, vcf: Path = DEFAULT_VCF) -> dict:
|
|
|
70
74
|
n_in += 1
|
|
71
75
|
info = _info(p[7])
|
|
72
76
|
sig = info.get("CLNSIG", "")
|
|
77
|
+
ref_, alts_ = p[3], p[4].split(",")
|
|
78
|
+
if len(ref_) == 1 and all(len(a) == 1 for a in alts_) and any(a in alts_ for a in gt) and all(a in alts_ or a == ref_ for a in gt):
|
|
79
|
+
carried_sites[f"{chrom}:{p[1]}:{p[4]}"] = dict(rsid=("rs" + info["RS"]) if info.get("RS") else p[2], gene=info.get("GENEINFO", "").split(":")[0],
|
|
80
|
+
sig=sig, rev=info.get("CLNREVSTAT", ""), gt=gt)
|
|
73
81
|
if not any(s in sig for s in PATHOGENIC) or sig.startswith("Benign") or sig.startswith("Likely_benign"):
|
|
74
82
|
continue
|
|
75
83
|
n_path += 1
|
|
@@ -109,6 +117,7 @@ def scan(df: pd.DataFrame, vcf: Path = DEFAULT_VCF) -> dict:
|
|
|
109
117
|
condition=_conditions(info.get("CLNDN", "")),
|
|
110
118
|
variant=info.get("CLNHGVS", ""), alt_af=af, confidence=confidence, flags="; ".join(flags)))
|
|
111
119
|
res["sites_in_clinvar"], res["pathogenic_sites_typed"] = n_in, n_path
|
|
120
|
+
res["carried"] = carried_sites
|
|
112
121
|
out = pd.DataFrame(hits)
|
|
113
122
|
if not out.empty:
|
|
114
123
|
out["conf_rank"] = out.confidence.map({"high": 0, "medium": 1, "low": 2})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Effect-allele frequency per 1000 Genomes phase 3 super-population (Ensembl GRCh37 REST). Regenerate with examples/fetch_frequencies.py
|
|
2
|
+
rsid AFR AMR EAS EUR SAS note
|
|
3
|
+
rs1426654 0.0741 0.5893 0.0119 0.9970 0.6851 direct
|
|
4
|
+
rs16891982 0.0356 0.4640 0.0060 0.9384 0.0593 direct
|
|
5
|
+
rs3827760 0.0030 0.3919 0.8730 0.0109 0.0133 direct
|
|
6
|
+
rs2814778 0.9637 0.0778 0.0000 0.0060 0.0000 direct
|
|
7
|
+
rs4988235 0.0272 0.2161 0.0000 0.5080 0.1135 direct
|
|
8
|
+
rs182549 0.0272 0.2205 0.0000 0.5089 0.1196 direct
|
|
9
|
+
rs671 0.0015 0.0029 0.1736 0.0000 0.0000 direct
|
|
10
|
+
rs1229984 0.0015 0.0576 0.6974 0.0288 0.0204 direct
|
|
11
|
+
rs12913832 0.0280 0.2017 0.0020 0.6362 0.0706 direct
|
|
12
|
+
rs1800407 0.0015 0.0317 0.0010 0.0755 0.0266 direct
|
|
13
|
+
rs12896399 0.0212 0.2522 0.3472 0.4304 0.3272 direct
|
|
14
|
+
rs1042602 0.0121 0.2378 0.0010 0.3718 0.0634 direct
|
|
15
|
+
rs1393350 0.0083 0.1225 0.0030 0.2435 0.0542 direct
|
|
16
|
+
rs12203592 0.0083 0.0720 0.0000 0.1163 0.0061 direct
|
|
17
|
+
rs1805007 0.0030 0.0159 0.0010 0.0716 0.0051 direct
|
|
18
|
+
rs1805008 0.0038 0.0029 0.0000 0.0616 0.0041 direct
|
|
19
|
+
rs1805009 0.0015 0.0101 0.0000 0.0080 0.0000 direct
|
|
20
|
+
rs1805006 0.0008 0.0014 0.0000 0.0099 0.0010 direct
|
|
21
|
+
rs11547464 0.0008 0.0058 0.0000 0.0089 0.0000 direct
|
|
22
|
+
rs1110400 0.0000 0.0086 0.0000 0.0080 0.0020 direct
|
|
23
|
+
rs885479 0.0068 0.3156 0.6161 0.0696 0.0399 direct
|
|
24
|
+
rs11803731 0.9955 0.9063 0.9990 0.7714 0.9366 direct
|
|
25
|
+
rs17822931 0.0121 0.1398 0.7798 0.1362 0.4816 direct
|
|
26
|
+
rs4481887 0.0998 0.2176 0.1379 0.2773 0.1022 direct
|
|
27
|
+
rs10427255 0.4130 0.3732 0.4415 0.4950 0.4489 direct
|
|
28
|
+
rs1815739 0.1150 0.5720 0.4435 0.4344 0.5869 direct
|
|
29
|
+
rs762551 0.4380 0.2421 0.3274 0.3201 0.4652 direct
|
|
30
|
+
rs2472297 0.0144 0.0893 0.0000 0.2147 0.0327 direct
|
|
31
|
+
rs4410790 0.4402 0.3732 0.4137 0.6153 0.4724 direct
|
|
32
|
+
rs713598 no 1000G data
|
|
33
|
+
rs1726866 no 1000G data
|
|
34
|
+
rs10246939 no 1000G data
|
|
35
|
+
rs72921001 0.7927 0.6268 0.6865 0.6431 0.5746 direct
|
|
36
|
+
rs9939609 0.4939 0.2622 0.1687 0.4135 0.2883 direct
|
|
37
|
+
rs17782313 0.2776 0.1340 0.1865 0.2396 0.3200 direct
|
|
38
|
+
rs16969968 0.0234 0.2089 0.0268 0.3658 0.1820 direct
|
|
39
|
+
rs4680 0.2806 0.3775 0.2798 0.5000 0.4407 direct
|
|
40
|
+
rs6265 0.0106 0.1527 0.4881 0.1968 0.2025 direct
|
|
41
|
+
rs1799971 0.0091 0.2003 0.3929 0.1620 0.4182 direct
|
|
42
|
+
rs601338 0.4909 0.3415 0.0040 0.4414 0.2832 direct
|
|
43
|
+
rs2180439 0.4463 0.5317 0.4067 0.4175 0.5307 direct
|
|
44
|
+
rs53576 0.1936 0.3559 0.6498 0.3509 0.4489 direct
|
|
45
|
+
rs1800497 0.3850 0.3112 0.4058 0.1879 0.3149 direct
|
|
46
|
+
rs8176719 0.7057 0.7680 0.6200 0.6054 0.5992 indel
|
|
47
|
+
rs8176746 no 1000G data
|
|
48
|
+
rs1050828 no 1000G data
|
|
49
|
+
rs429358 0.2678 0.1037 0.0863 0.1551 0.0869 direct
|
|
50
|
+
rs7412 0.1029 0.0476 0.1002 0.0626 0.0440 direct
|
|
51
|
+
rs6025 0.0000 0.0101 0.0000 0.0119 0.0112 direct
|
|
52
|
+
rs1799963 0.0000 0.0144 0.0000 0.0080 0.0000 direct
|
|
53
|
+
rs1800562 0.0023 0.0216 0.0000 0.0427 0.0020 direct
|
|
54
|
+
rs1799945 0.0113 0.1153 0.0288 0.1720 0.0706 direct
|
|
55
|
+
rs1800730 0.0000 0.0043 0.0000 0.0159 0.0010 direct
|
|
56
|
+
rs1801133 0.0900 0.4741 0.2956 0.3648 0.1186 direct
|
|
57
|
+
rs1801131 0.1513 0.1513 0.2192 0.3131 0.4172 direct
|
|
58
|
+
rs2187668 0.0537 0.1182 0.0516 0.1064 0.0787 direct
|
|
59
|
+
rs7454108 no 1000G data
|
|
60
|
+
rs3135388 0.0272 0.0504 0.0060 0.1203 0.0174 direct
|
|
61
|
+
rs2476601 0.0030 0.0360 0.0000 0.0944 0.0133 direct
|
|
62
|
+
rs2066844 0.0023 0.0245 0.0000 0.0507 0.0010 direct
|
|
63
|
+
rs2066845 0.0000 0.0130 0.0000 0.0099 0.0041 direct
|
|
64
|
+
rs7903146 0.2602 0.2349 0.0228 0.3171 0.2986 direct
|
|
65
|
+
rs1061170 0.3623 0.2334 0.0486 0.3618 0.2873 direct
|
|
66
|
+
rs10490924 0.2458 0.2478 0.4038 0.1948 0.3425 direct
|
|
67
|
+
rs1333049 0.2133 0.4553 0.5367 0.4722 0.4908 direct
|
|
68
|
+
rs10455872 0.0023 0.0331 0.0050 0.0736 0.0061 direct
|
|
69
|
+
rs3798220 0.0045 0.2161 0.0883 0.0099 0.0020 direct
|
|
70
|
+
rs11136000 0.5741 0.3573 0.2192 0.3877 0.2822 direct
|
|
71
|
+
rs75932628 0.0000 0.0043 0.0000 0.0050 0.0020 direct
|
|
72
|
+
rs76763715 no 1000G data
|
|
73
|
+
rs34637584 0.0000 0.0014 0.0000 0.0000 0.0000 direct
|
|
74
|
+
rs2802292 0.8290 0.3905 0.3056 0.4334 0.5613 direct
|
|
75
|
+
rs1800629 0.1195 0.0692 0.0585 0.1342 0.0532 direct
|
|
76
|
+
rs113993960 indel alleles not comparable
|
|
77
|
+
rs333 indel alleles not comparable
|
|
78
|
+
rs80357906 no 1000G data
|
|
79
|
+
rs80359550 no 1000G data
|
|
80
|
+
rs11571833 0.0008 0.0029 0.0000 0.0109 0.0082 direct
|
|
81
|
+
rs334 0.0998 0.0072 0.0000 0.0000 0.0000 direct
|
|
82
|
+
rs33930165 0.0129 0.0000 0.0000 0.0000 0.0000 direct
|
|
83
|
+
rs33950507 0.0000 0.0000 0.0079 0.0000 0.0061 direct
|
|
84
|
+
rs1799853 0.0083 0.0994 0.0010 0.1243 0.0348 direct
|
|
85
|
+
rs1057910 0.0023 0.0375 0.0337 0.0726 0.1094 direct
|
|
86
|
+
rs28371686 0.0166 0.0014 0.0000 0.0000 0.0000 direct
|
|
87
|
+
rs9332131 indel alleles not comparable
|
|
88
|
+
rs28371685 0.0242 0.0014 0.0000 0.0020 0.0010 direct
|
|
89
|
+
rs4244285 0.1702 0.1052 0.3125 0.1451 0.3579 direct
|
|
90
|
+
rs4986893 0.0023 0.0000 0.0556 0.0000 0.0123 direct
|
|
91
|
+
rs12248560 0.2352 0.1196 0.0149 0.2237 0.1360 direct
|
|
92
|
+
rs9923231 0.0545 0.4107 0.8849 0.3877 0.1452 direct
|
|
93
|
+
rs9934438 0.0545 0.4121 0.8849 0.3877 0.1452 direct
|
|
94
|
+
rs2108622 0.0825 0.2378 0.2143 0.2903 0.4131 direct
|
|
95
|
+
rs3892097 no 1000G data
|
|
96
|
+
rs1065852 no 1000G data
|
|
97
|
+
rs28371725 no 1000G data
|
|
98
|
+
rs776746 0.8200 0.2032 0.2867 0.0567 0.3323 direct
|
|
99
|
+
rs3745274 0.3744 0.3732 0.2153 0.2356 0.3814 direct
|
|
100
|
+
rs4149056 0.0136 0.1340 0.1230 0.1610 0.0429 direct
|
|
101
|
+
rs2231142 0.0129 0.1412 0.2907 0.0944 0.0971 direct
|
|
102
|
+
rs3918290 0.0008 0.0014 0.0000 0.0050 0.0082 direct
|
|
103
|
+
rs67376798 0.0008 0.0029 0.0000 0.0070 0.0010 direct
|
|
104
|
+
rs887829 0.4932 0.3790 0.1300 0.2982 0.4366 direct
|
|
105
|
+
rs4148323 0.0008 0.0115 0.1379 0.0070 0.0174 direct
|
|
106
|
+
rs1801280 0.2920 0.3617 0.0377 0.4493 0.3466 direct
|
|
107
|
+
rs1799930 0.2375 0.1715 0.2560 0.2823 0.3599 direct
|
|
108
|
+
rs1799931 0.0287 0.1124 0.1796 0.0229 0.0685 direct
|
|
109
|
+
rs1495741 0.4191 0.3501 0.5159 0.2435 0.2209 direct
|
|
110
|
+
rs1800460 0.0030 0.0403 0.0000 0.0278 0.0041 direct
|
|
111
|
+
rs1142345 0.0666 0.0576 0.0218 0.0288 0.0174 direct
|
|
112
|
+
rs2395029 0.0045 0.0202 0.0119 0.0437 0.0930 direct
|
|
113
|
+
rs1800566 0.1755 0.3329 0.4187 0.2107 0.3579 direct
|
|
@@ -33,7 +33,7 @@ Rules: the file never leaves the machine and you must not paste genotype rows an
|
|
|
33
33
|
rare-site calls need clinical confirmation. Never diagnose. If the user has no file yet, read the
|
|
34
34
|
gnomancer://guide/get-your-data resource."""
|
|
35
35
|
|
|
36
|
-
server = MCPServer("gnomancer", version="0.
|
|
36
|
+
server = MCPServer("gnomancer", version="0.4.0", instructions=INSTRUCTIONS, website_url="https://gnomancer.com")
|
|
37
37
|
_state: dict = {"report_dir": os.environ.get("GNOMANCER_REPORT"), "df_cache": {}}
|
|
38
38
|
|
|
39
39
|
|
|
@@ -150,6 +150,19 @@ def pharmacogenomics(report_dir: str | None = None) -> dict:
|
|
|
150
150
|
return s.get("pharmcat") or {}
|
|
151
151
|
|
|
152
152
|
|
|
153
|
+
@server.tool()
|
|
154
|
+
def clinvar_watch(report_dir: str | None = None, force: bool = False) -> str:
|
|
155
|
+
"""Re-check ClinVar for this report: downloads a new ClinVar release if NCBI published one, rescans the user's
|
|
156
|
+
sites and reports only classification changes (newly pathogenic, downgraded, review-status changes). The first
|
|
157
|
+
run writes a baseline. Health-sensitive: run when the user asks."""
|
|
158
|
+
d = _report_dir(report_dir)
|
|
159
|
+
args = ["watch", str(d)] + (["--force"] if force else [])
|
|
160
|
+
p = _cli(args)
|
|
161
|
+
if p.returncode != 0:
|
|
162
|
+
raise RuntimeError((p.stderr or p.stdout)[-1200:])
|
|
163
|
+
return (p.stdout or "").strip() or (p.stderr or "").strip()
|
|
164
|
+
|
|
165
|
+
|
|
153
166
|
@server.tool()
|
|
154
167
|
def narrative_prompt(report_dir: str | None = None) -> str:
|
|
155
168
|
"""The prompt for writing the plain-language narrative that goes at the top of the report. Follow it exactly,
|
|
@@ -17,6 +17,30 @@ import pandas as pd
|
|
|
17
17
|
|
|
18
18
|
COMP = {"A": "T", "T": "A", "C": "G", "G": "C", "I": "I", "D": "D"}
|
|
19
19
|
CATEGORY_ORDER = ["ancestry", "trait", "health", "carrier", "pgx"]
|
|
20
|
+
POPS = ("AFR", "AMR", "EAS", "EUR", "SAS")
|
|
21
|
+
POP_LABEL = dict(AFR="African", AMR="Admixed American", EAS="East Asian", EUR="European", SAS="South Asian")
|
|
22
|
+
FREQ_FILE = Path(__file__).resolve().parent / "data" / "marker_frequencies.tsv"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_frequencies(path: Path = FREQ_FILE) -> dict:
|
|
26
|
+
"""rsid -> {pop: effect-allele frequency} from the bundled 1000 Genomes table."""
|
|
27
|
+
out = {}
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return out
|
|
30
|
+
with open(path, newline="") as f:
|
|
31
|
+
lines = [l for l in f if l.strip() and not l.startswith("#")]
|
|
32
|
+
for r in csv.DictReader(lines, delimiter="\t"):
|
|
33
|
+
d = {p: float(r[p]) for p in POPS if r.get(p)}
|
|
34
|
+
if d:
|
|
35
|
+
out[r["rsid"]] = d
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def genotype_frequency(effect_count: int, ploidy: int, p: float) -> float:
|
|
40
|
+
"""Share of a population expected to carry this genotype (Hardy-Weinberg)."""
|
|
41
|
+
if ploidy == 1:
|
|
42
|
+
return p if effect_count == 1 else 1 - p
|
|
43
|
+
return {0: (1 - p) ** 2, 1: 2 * p * (1 - p), 2: p * p}[effect_count]
|
|
20
44
|
|
|
21
45
|
|
|
22
46
|
def load_markers(path: Path) -> list[dict]:
|
|
@@ -37,13 +61,14 @@ def _lookup(df: pd.DataFrame, rsid: str):
|
|
|
37
61
|
return None
|
|
38
62
|
|
|
39
63
|
|
|
40
|
-
def evaluate(df: pd.DataFrame, markers: list[dict]) -> pd.DataFrame:
|
|
64
|
+
def evaluate(df: pd.DataFrame, markers: list[dict], frequencies: dict | None = None) -> pd.DataFrame:
|
|
65
|
+
frequencies = load_frequencies() if frequencies is None else frequencies
|
|
41
66
|
out = []
|
|
42
67
|
for m in markers:
|
|
43
68
|
row = _lookup(df, m["rsid"])
|
|
44
69
|
rec = dict(rsid=m["rsid"], category=m["category"], gene=m["gene"], label=m["label"],
|
|
45
70
|
ref=m["ref"], effect=m["effect"], chrom="", pos="", genotype="", effect_count="",
|
|
46
|
-
interpretation="", note=m.get("note", ""))
|
|
71
|
+
interpretation="", note=m.get("note", ""), **{f"gf_{p}": None for p in POPS})
|
|
47
72
|
if row is None:
|
|
48
73
|
rec["genotype"] = "not on array"
|
|
49
74
|
out.append(rec)
|
|
@@ -71,6 +96,8 @@ def evaluate(df: pd.DataFrame, markers: list[dict]) -> pd.DataFrame:
|
|
|
71
96
|
n = g.count(m["effect"])
|
|
72
97
|
ploidy = len(g)
|
|
73
98
|
rec["effect_count"] = f"{n}/{ploidy}"
|
|
99
|
+
for pop, af in (frequencies.get(m["rsid"]) or {}).items():
|
|
100
|
+
rec[f"gf_{pop}"] = round(genotype_frequency(n, ploidy, af), 4)
|
|
74
101
|
key = {0: "interp0", 1: "interp1", 2: "interp2"}[n if ploidy == 2 else (2 if n == 1 else 0)]
|
|
75
102
|
text = m.get(key) or f"{n} copy(ies) of effect allele {m['effect']}"
|
|
76
103
|
if ploidy == 1:
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import pandas as pd
|
|
7
7
|
|
|
8
|
-
from .panel import CATEGORY_ORDER
|
|
8
|
+
from .panel import CATEGORY_ORDER, POPS, POP_LABEL
|
|
9
9
|
|
|
10
10
|
DISCLAIMER = (
|
|
11
11
|
"Computed locally from a consumer genotyping array. The array reports a fixed list of common sites, "
|
|
@@ -104,7 +104,10 @@ def write(out: Path, loaded, qc: dict, chr_table: pd.DataFrame, roh: pd.DataFram
|
|
|
104
104
|
for name, val, note in combos:
|
|
105
105
|
L.append(f"- **{name}:** {val}" + (f" — {note}" if note else ""))
|
|
106
106
|
L.append("\n## 8. Marker panel\n")
|
|
107
|
-
|
|
107
|
+
pop = extra.get("pop") or "EUR"
|
|
108
|
+
L.append(f"Effect-allele counts are on the GRCh37 forward strand. `not on array` means the vendor's chip does not type that site. "
|
|
109
|
+
f"`share` is the fraction of the {POP_LABEL.get(pop, pop)} 1000 Genomes reference population expected to carry the same genotype "
|
|
110
|
+
f"(Hardy-Weinberg from the effect-allele frequency); the TSV has all five super-populations.\n")
|
|
108
111
|
titles = dict(ancestry="Ancestry-informative markers", trait="Traits", health="Health-associated common variants",
|
|
109
112
|
carrier="Carrier / rare pathogenic sites typed on arrays", pgx="Pharmacogenomics")
|
|
110
113
|
for cat in CATEGORY_ORDER:
|
|
@@ -112,7 +115,10 @@ def write(out: Path, loaded, qc: dict, chr_table: pd.DataFrame, roh: pd.DataFram
|
|
|
112
115
|
if sub.empty:
|
|
113
116
|
continue
|
|
114
117
|
L.append(f"\n### {titles.get(cat, cat)}\n")
|
|
115
|
-
|
|
118
|
+
sub = sub.copy()
|
|
119
|
+
col = f"gf_{pop}"
|
|
120
|
+
sub["share"] = sub[col].map(lambda v: f"{v:.0%}" if isinstance(v, float) else "") if col in sub else ""
|
|
121
|
+
L.append(_md_table(sub, ["rsid", "gene", "label", "genotype", "effect_count", "share", "interpretation"]))
|
|
116
122
|
cv = extra.get("clinvar") or {}
|
|
117
123
|
L.append("\n## 9. ClinVar scan of every typed site\n")
|
|
118
124
|
L.append(extra.get("clinvar_text", "not run"))
|
|
@@ -10,7 +10,7 @@ import pandas as pd
|
|
|
10
10
|
|
|
11
11
|
from . import __version__
|
|
12
12
|
from .context import lookup
|
|
13
|
-
from .panel import CATEGORY_ORDER
|
|
13
|
+
from .panel import CATEGORY_ORDER, POPS, POP_LABEL
|
|
14
14
|
from .report import DISCLAIMER
|
|
15
15
|
|
|
16
16
|
CAT_TITLES = dict(ancestry="Ancestry-informative markers", trait="Traits",
|
|
@@ -26,10 +26,10 @@ SENSITIVE = {"health", "carrier"}
|
|
|
26
26
|
|
|
27
27
|
CSS = """
|
|
28
28
|
:root{color-scheme:light;--bg:#fcfcfb;--surface:#ffffff;--line:#e6e4df;--ink:#0b0b0b;--ink-2:#52514e;--ink-3:#8a8781;
|
|
29
|
-
--accent:#
|
|
29
|
+
--accent:#7c3aed;--accent-soft:#efe9fd;--accent-ink:#5b21b6;--good:#0ca30c;--warn:#fab219;--serious:#ec835a;--critical:#d03b3b;
|
|
30
30
|
--radius:12px;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif}
|
|
31
|
-
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#141413;--surface:#1a1a19;--line:#2e2e2c;--ink:#f0efec;--ink-2:#c3c2b7;--ink-3:#8f8e86;--accent:#
|
|
32
|
-
:root[data-theme=dark]{color-scheme:dark;--bg:#141413;--surface:#1a1a19;--line:#2e2e2c;--ink:#f0efec;--ink-2:#c3c2b7;--ink-3:#8f8e86;--accent:#
|
|
31
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#141413;--surface:#1a1a19;--line:#2e2e2c;--ink:#f0efec;--ink-2:#c3c2b7;--ink-3:#8f8e86;--accent:#a78bfa;--accent-soft:#2e1a5e;--accent-ink:#c4b5fd}}
|
|
32
|
+
:root[data-theme=dark]{color-scheme:dark;--bg:#141413;--surface:#1a1a19;--line:#2e2e2c;--ink:#f0efec;--ink-2:#c3c2b7;--ink-3:#8f8e86;--accent:#a78bfa;--accent-soft:#2e1a5e;--accent-ink:#c4b5fd}
|
|
33
33
|
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
34
34
|
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 var(--sans)}
|
|
35
35
|
.wrap{max-width:1040px;margin:0 auto;padding:0 16px 64px}
|
|
@@ -60,6 +60,7 @@ tr:first-child td{border-top:0}
|
|
|
60
60
|
td.gt,td.num{font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
61
61
|
.badge{display:inline-block;min-width:34px;text-align:center;padding:1px 8px;border-radius:999px;font-size:12.5px;font-family:var(--mono);border:1px solid var(--line);color:var(--ink-2)}
|
|
62
62
|
.badge.n1{border-color:var(--accent);color:var(--accent-ink)}.badge.n2{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
63
|
+
.bar{fill:var(--accent)}
|
|
63
64
|
.badge.na{color:var(--ink-3);border-style:dashed}
|
|
64
65
|
.readings{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
|
|
65
66
|
.readings .card .value{font-size:18px;font-weight:600;margin:2px 0 4px}.readings .card .note{font-size:13.5px;color:var(--ink-2)}
|
|
@@ -141,8 +142,16 @@ def _het_chart(ct: pd.DataFrame) -> str:
|
|
|
141
142
|
'X near zero with Y present indicates a male sample; MT is always homozygous.</p>'
|
|
142
143
|
|
|
143
144
|
|
|
144
|
-
def
|
|
145
|
-
|
|
145
|
+
def _share_cell(r, pop: str) -> str:
|
|
146
|
+
v = r.get(f"gf_{pop}")
|
|
147
|
+
if not isinstance(v, float):
|
|
148
|
+
return '<td class="num"><span class="caption">—</span></td>'
|
|
149
|
+
parts = [f"{POP_LABEL[p]}: {r.get(f'gf_{p}'):.0%}" for p in POPS if isinstance(r.get(f"gf_{p}"), float)]
|
|
150
|
+
return f'<td class="num" title="{e("; ".join(parts))}">{v:.0%}</td>'
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _panel_table(sub: pd.DataFrame, pop: str = "EUR") -> str:
|
|
154
|
+
h = [f'<div class="tablewrap"><table class="panel"><thead><tr><th>Marker</th><th>Gene</th><th>What it tags</th><th>Genotype</th><th>Effect allele</th><th title="Share of the {e(POP_LABEL.get(pop, pop))} 1000 Genomes reference population expected to carry the same genotype. Hover for all five super-populations.">How common ({e(pop)})</th><th>Reading</th></tr></thead><tbody>']
|
|
146
155
|
for _, r in sub.iterrows():
|
|
147
156
|
gt = r["genotype"]
|
|
148
157
|
ec = str(r["effect_count"])
|
|
@@ -155,7 +164,7 @@ def _panel_table(sub: pd.DataFrame) -> str:
|
|
|
155
164
|
gtc = e(gt)
|
|
156
165
|
interp = e(str(r["interpretation"]))
|
|
157
166
|
note = e(str(r.get("note", "") or ""))
|
|
158
|
-
h.append(f'<tr><td class="num">{e(r["rsid"])}</td><td>{e(r["gene"])}</td><td>{e(r["label"])}</td><td class="gt">{gtc}</td><td>{badge}</td
|
|
167
|
+
h.append(f'<tr><td class="num">{e(r["rsid"])}</td><td>{e(r["gene"])}</td><td>{e(r["label"])}</td><td class="gt">{gtc}</td><td>{badge}</td>{_share_cell(r, pop)}<td>{interp}' + (f'<div class="caption">{note}</div>' if note else "") + "</td></tr>")
|
|
159
168
|
h.append("</tbody></table></div>")
|
|
160
169
|
return "".join(h)
|
|
161
170
|
|
|
@@ -271,13 +280,14 @@ def write_html(out: Path, loaded, qc: dict, ct: pd.DataFrame, roh: pd.DataFrame,
|
|
|
271
280
|
f'<div class="readings"><div class="card"><h3>APOE</h3><div class="value">{e(v)}</div><div class="note">{e(n)}</div></div></div></details>')
|
|
272
281
|
H.append("</section>")
|
|
273
282
|
# Panel
|
|
274
|
-
|
|
283
|
+
pop = extra.get("pop") or "EUR"
|
|
284
|
+
H.append(f'<section id="panel"><h2>Marker panel</h2><p class="lead">Each row is one well-studied site. The badge counts how many copies of the <em>effect allele</em> you carry (0, 1 or 2) on the GRCh37 forward strand. The share column says what fraction of the {e(POP_LABEL.get(pop, pop))} reference population (1000 Genomes) is expected to carry the same genotype; hover it for all five world regions. "Not on array" means this vendor\'s chip does not type the site.</p>'
|
|
275
285
|
'<div class="filter"><input id="q" type="search" placeholder="Filter by gene, rsID or keyword…" aria-label="Filter markers"></div>')
|
|
276
286
|
for cat in CATEGORY_ORDER:
|
|
277
287
|
sub = panel[panel.category == cat]
|
|
278
288
|
if sub.empty:
|
|
279
289
|
continue
|
|
280
|
-
block = f'<div class="catblock"><h3>{e(CAT_TITLES.get(cat, cat))}</h3><p class="lead">{e(CAT_BLURB.get(cat, ""))}</p>{_panel_table(sub)}</div>'
|
|
290
|
+
block = f'<div class="catblock"><h3>{e(CAT_TITLES.get(cat, cat))}</h3><p class="lead">{e(CAT_BLURB.get(cat, ""))}</p>{_panel_table(sub, pop)}</div>'
|
|
281
291
|
if cat in SENSITIVE:
|
|
282
292
|
H.append(f'<details class="sensitive"><summary>{e(CAT_TITLES[cat])}<small>Collapsed on purpose: contains disease-risk and carrier results. Open when you are ready, or use "Reveal health sections" above.</small></summary>{block}</details>')
|
|
283
293
|
else:
|
|
@@ -76,14 +76,17 @@ def at_a_glance(loaded, qc, roh, roh_text, combos, y, mt, ancestry, panel: pd.Da
|
|
|
76
76
|
for _, r in p[p.category.isin(["health", "carrier"])].iterrows():
|
|
77
77
|
n = str(r.effect_count).split("/")[0]
|
|
78
78
|
if n == "2" and r.category == "health":
|
|
79
|
-
|
|
79
|
+
if "protective" in str(r.interpretation).lower():
|
|
80
|
+
reassuring.append(f"{r.gene}: {r.interpretation}.")
|
|
81
|
+
else:
|
|
82
|
+
notable.append(f"{r.gene} {r.label}: {r.interpretation}.")
|
|
80
83
|
elif n == "1" and r.category == "carrier":
|
|
81
84
|
notable.append(f"{r.gene} {r.label}: carrier ({r.interpretation}).")
|
|
82
85
|
elif n == "2" and r.category == "carrier":
|
|
83
86
|
notable.append(f"{r.gene} {r.label}: two copies ({r.interpretation}).")
|
|
84
87
|
hets = p[(p.category == "health") & (p.effect_count.astype(str).str.startswith("1")) & ~p.interpretation.str.contains("benign", case=False)]
|
|
85
88
|
if not hets.empty:
|
|
86
|
-
notable.append("One copy of a
|
|
89
|
+
notable.append("One copy of a risk allele at: " + ", ".join(f"{r.gene} ({r.label})" for _, r in hets.iterrows()) + ". Population-average territory, listed for completeness.")
|
|
87
90
|
if "APOE" in combo:
|
|
88
91
|
v = combo["APOE"][0].split(" (")[0]
|
|
89
92
|
(reassuring if v == "e3/e3" else notable).append(f"APOE {v}: {combo['APOE'][1]}.")
|