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.
Files changed (42) hide show
  1. gnomancer-0.4.0/PKG-INFO +92 -0
  2. gnomancer-0.4.0/README.md +67 -0
  3. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/__init__.py +1 -1
  4. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/cli.py +9 -2
  5. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/clinvar.py +10 -1
  6. gnomancer-0.4.0/gnomancer/data/marker_frequencies.tsv +113 -0
  7. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/mcp_server.py +14 -1
  8. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/panel.py +29 -2
  9. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/report.py +9 -3
  10. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/report_html.py +19 -9
  11. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/summary.py +5 -2
  12. gnomancer-0.4.0/gnomancer/watch.py +126 -0
  13. gnomancer-0.4.0/gnomancer.egg-info/PKG-INFO +92 -0
  14. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/SOURCES.txt +2 -0
  15. {gnomancer-0.3.2 → gnomancer-0.4.0}/pyproject.toml +1 -1
  16. {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_annotations.py +21 -0
  17. {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_markers.py +12 -0
  18. gnomancer-0.3.2/PKG-INFO +0 -211
  19. gnomancer-0.3.2/README.md +0 -186
  20. gnomancer-0.3.2/gnomancer.egg-info/PKG-INFO +0 -211
  21. {gnomancer-0.3.2 → gnomancer-0.4.0}/LICENSE +0 -0
  22. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/__main__.py +0 -0
  23. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/context.py +0 -0
  24. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/data/haplogroup_context.tsv +0 -0
  25. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/data/markers.tsv +0 -0
  26. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/data/rCRS_NC_012920.1.fasta +0 -0
  27. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/haplogroups.py +0 -0
  28. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/load.py +0 -0
  29. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/paths.py +0 -0
  30. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/pharmcat.py +0 -0
  31. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/qc.py +0 -0
  32. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/roh.py +0 -0
  33. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/setup_cmd.py +0 -0
  34. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer/yleaf.py +0 -0
  35. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/dependency_links.txt +0 -0
  36. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/entry_points.txt +0 -0
  37. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/requires.txt +0 -0
  38. {gnomancer-0.3.2 → gnomancer-0.4.0}/gnomancer.egg-info/top_level.txt +0 -0
  39. {gnomancer-0.3.2 → gnomancer-0.4.0}/setup.cfg +0 -0
  40. {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_mcp.py +0 -0
  41. {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_pipeline.py +0 -0
  42. {gnomancer-0.3.2 → gnomancer-0.4.0}/tests/test_summary.py +0 -0
@@ -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
+ ![demo report](docs/demo-report.png)
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
+ ![demo report](docs/demo-report.png)
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.3.2"
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
- ap = argparse.ArgumentParser(prog="gnomancer", description="Analyze a consumer raw DNA file locally. Subcommands: `gnomancer setup` (download tools), `gnomancer serve` (MCP server).")
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
- extra = dict(clinvar=cv, clinvar_text=cv_text, pharmcat=pc, pharmcat_text=pc_text, glance=glance, narrative=narrative)
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.3.2", instructions=INSTRUCTIONS, website_url="https://gnomancer.com")
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
- L.append("Effect-allele counts are on the GRCh37 forward strand. `not on array` means the vendor's chip does not type that site.\n")
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
- L.append(_md_table(sub, ["rsid", "gene", "label", "genotype", "effect_count", "interpretation"]))
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:#2a78d6;--accent-soft:#cde2fb;--accent-ink:#184f95;--good:#0ca30c;--warn:#fab219;--serious:#ec835a;--critical:#d03b3b;
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:#3987e5;--accent-soft:#0d366b;--accent-ink:#9ec5f4}}
32
- :root[data-theme=dark]{color-scheme:dark;--bg:#141413;--surface:#1a1a19;--line:#2e2e2c;--ink:#f0efec;--ink-2:#c3c2b7;--ink-3:#8f8e86;--accent:#3987e5;--accent-soft:#0d366b;--accent-ink:#9ec5f4}
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 _panel_table(sub: pd.DataFrame) -> str:
145
- h = ['<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>Reading</th></tr></thead><tbody>']
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><td>{interp}' + (f'<div class="caption">{note}</div>' if note else "") + "</td></tr>")
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
- H.append('<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. "Not on array" means this vendor\'s chip does not type the site.</p>'
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
- notable.append(f"{r.gene} {r.label}: {r.interpretation}.")
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 common risk allele at: " + ", ".join(f"{r.gene} ({r.label})" for _, r in hets.iterrows()) + ". Population-average territory, listed for completeness.")
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]}.")