genokeeper 0.1.0__py3-none-any.whl

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 (189) hide show
  1. genokeeper/__init__.py +5 -0
  2. genokeeper/align.py +164 -0
  3. genokeeper/ancestry/__init__.py +4 -0
  4. genokeeper/ancestry/admixture.py +95 -0
  5. genokeeper/ancestry/archaic.py +147 -0
  6. genokeeper/annotate/__init__.py +2 -0
  7. genokeeper/annotate/vep.py +648 -0
  8. genokeeper/bloodgroups.py +252 -0
  9. genokeeper/calibrate/__init__.py +84 -0
  10. genokeeper/calibrate/__main__.py +7 -0
  11. genokeeper/calibrate/build.py +2397 -0
  12. genokeeper/calibrate/classes.py +457 -0
  13. genokeeper/calibrate/deletions.py +290 -0
  14. genokeeper/calibrate/foundation.py +280 -0
  15. genokeeper/calibrate/hg002.py +887 -0
  16. genokeeper/calibrate/hg002_cells.py +213 -0
  17. genokeeper/calibrate/measure.py +429 -0
  18. genokeeper/calibrate/realign.py +233 -0
  19. genokeeper/calibrate/records.py +254 -0
  20. genokeeper/calibrate/repeat_rules.py +397 -0
  21. genokeeper/calibrate/sample.py +347 -0
  22. genokeeper/calibrate/slice3d.py +654 -0
  23. genokeeper/calibrate/slice3f.py +586 -0
  24. genokeeper/calibrate/spikein.py +241 -0
  25. genokeeper/calibrate/stage3.py +473 -0
  26. genokeeper/calibrate/table.py +421 -0
  27. genokeeper/calibrate/tables.py +258 -0
  28. genokeeper/call/__init__.py +1 -0
  29. genokeeper/call/deepvariant.py +186 -0
  30. genokeeper/carrier.py +562 -0
  31. genokeeper/changes.py +367 -0
  32. genokeeper/check.py +875 -0
  33. genokeeper/checksum.py +78 -0
  34. genokeeper/cli.py +70 -0
  35. genokeeper/cnv/__init__.py +6 -0
  36. genokeeper/cnv/callers.py +193 -0
  37. genokeeper/cnv/screen.py +457 -0
  38. genokeeper/config.py +871 -0
  39. genokeeper/containers.py +440 -0
  40. genokeeper/coverage.py +233 -0
  41. genokeeper/dashboard.py +542 -0
  42. genokeeper/deletions/__init__.py +8 -0
  43. genokeeper/deletions/callers.py +385 -0
  44. genokeeper/deletions/exons.py +364 -0
  45. genokeeper/deletions/files.py +57 -0
  46. genokeeper/deletions/screen.py +433 -0
  47. genokeeper/deletions/truvari.py +200 -0
  48. genokeeper/env.py +15 -0
  49. genokeeper/evidence/__init__.py +1 -0
  50. genokeeper/evidence/cgd.py +56 -0
  51. genokeeper/evidence/clingen.py +272 -0
  52. genokeeper/evidence/clinvar.py +476 -0
  53. genokeeper/evidence/disease.py +55 -0
  54. genokeeper/evidence/frequency.py +709 -0
  55. genokeeper/evidence/gencc.py +125 -0
  56. genokeeper/evidence/gwas.py +300 -0
  57. genokeeper/evidence/medlineplus.py +123 -0
  58. genokeeper/evidence/pgx.py +167 -0
  59. genokeeper/evidence/pharmgkb.py +294 -0
  60. genokeeper/evidence/predicted.py +432 -0
  61. genokeeper/evidence/publications.py +171 -0
  62. genokeeper/explain.py +557 -0
  63. genokeeper/genelists.py +196 -0
  64. genokeeper/genotype/__init__.py +1 -0
  65. genokeeper/genotype/clonal.py +165 -0
  66. genokeeper/genotype/gvcf.py +415 -0
  67. genokeeper/genotype/reliability.py +341 -0
  68. genokeeper/genotype/sites.py +98 -0
  69. genokeeper/haplocheck.py +80 -0
  70. genokeeper/haplogroup.py +119 -0
  71. genokeeper/hhv6.py +155 -0
  72. genokeeper/history.py +656 -0
  73. genokeeper/identity.py +40 -0
  74. genokeeper/insertions.py +238 -0
  75. genokeeper/lang.py +28 -0
  76. genokeeper/masking.py +346 -0
  77. genokeeper/mito.py +264 -0
  78. genokeeper/panel/__init__.py +1 -0
  79. genokeeper/panel/build.py +328 -0
  80. genokeeper/parallel.py +41 -0
  81. genokeeper/paralogs.py +330 -0
  82. genokeeper/pgx/__init__.py +2 -0
  83. genokeeper/pgx/cyp2b6.py +43 -0
  84. genokeeper/pgx/cyp2d6.py +262 -0
  85. genokeeper/pgx/hla.py +312 -0
  86. genokeeper/pgx/hla_typing.py +267 -0
  87. genokeeper/pgx/mtrnr1.py +86 -0
  88. genokeeper/pgx/outside.py +117 -0
  89. genokeeper/pgx/pharmcat.py +375 -0
  90. genokeeper/pgx/stellarpgx.py +210 -0
  91. genokeeper/phasing.py +196 -0
  92. genokeeper/pipeline.py +5693 -0
  93. genokeeper/prs/__init__.py +2 -0
  94. genokeeper/prs/pgsc.py +648 -0
  95. genokeeper/prs/scores.py +194 -0
  96. genokeeper/qc.py +220 -0
  97. genokeeper/query.py +821 -0
  98. genokeeper/refresh.py +720 -0
  99. genokeeper/refresh_log.py +163 -0
  100. genokeeper/refresh_plan.py +170 -0
  101. genokeeper/regions.py +195 -0
  102. genokeeper/repeats/__init__.py +2 -0
  103. genokeeper/repeats/expansionhunter.py +305 -0
  104. genokeeper/repeats/loci.py +115 -0
  105. genokeeper/repeats/not_covered.py +117 -0
  106. genokeeper/repeats/rfc1.py +47 -0
  107. genokeeper/resources/af_guard/SOURCE.md +18 -0
  108. genokeeper/resources/af_guard/clonal_spans.GRCh38.tsv +14 -0
  109. genokeeper/resources/ba1_exceptions.tsv +16 -0
  110. genokeeper/resources/bloodgroups/rbceq2_2.4.4_positions.tsv +1127 -0
  111. genokeeper/resources/bs1_gene_spans.tsv +88 -0
  112. genokeeper/resources/cnv/named.tsv +6 -0
  113. genokeeper/resources/coverage/SOURCE.md +20 -0
  114. genokeeper/resources/coverage/cds.GRCh38.tsv +4729 -0
  115. genokeeper/resources/cspec_versions.tsv +40 -0
  116. genokeeper/resources/dashboard/dashboard.css +72 -0
  117. genokeeper/resources/dashboard/dashboard.js +775 -0
  118. genokeeper/resources/dashboard/messages.json +296 -0
  119. genokeeper/resources/dashboard/page.html +46 -0
  120. genokeeper/resources/deletions/SOURCE.md +21 -0
  121. genokeeper/resources/deletions/exons.GRCh38.tsv +4488 -0
  122. genokeeper/resources/genelists/acmg_carrier_tier3_2021.tsv +127 -0
  123. genokeeper/resources/genelists/acmg_carrier_tier4.tsv +1315 -0
  124. genokeeper/resources/genelists/acmg_sf_v3.3.tsv +99 -0
  125. genokeeper/resources/genelists/screen_genes.tsv +6 -0
  126. genokeeper/resources/hla/alleles.tsv +33 -0
  127. genokeeper/resources/hla/dq_tags.tsv +14 -0
  128. genokeeper/resources/hla/hg002_truth.tsv +14 -0
  129. genokeeper/resources/mechanism_not_assessed.tsv +14 -0
  130. genokeeper/resources/mito/mitomap_confirmed.tsv +128 -0
  131. genokeeper/resources/paralogs/SOURCE.md +60 -0
  132. genokeeper/resources/paralogs/genes.GRCh38.tsv +25 -0
  133. genokeeper/resources/paralogs/hg002_truth.tsv +11 -0
  134. genokeeper/resources/pgs/efo_topics.tsv +50 -0
  135. genokeeper/resources/pgs/scores.toml +944 -0
  136. genokeeper/resources/reference/SOURCE.md +62 -0
  137. genokeeper/resources/reference/false_duplications_correct_copy.GRCh38.tsv +16 -0
  138. genokeeper/resources/reference/masked_intervals.GRCh38.tsv +16 -0
  139. genokeeper/resources/repeats/SOURCE.md +92 -0
  140. genokeeper/resources/repeats/added.GRCh38.json +42 -0
  141. genokeeper/resources/repeats/stage3a.GRCh38.json +222 -0
  142. genokeeper/resources/traits/traits.tsv +43 -0
  143. genokeeper/roh.py +257 -0
  144. genokeeper/sample_identity.py +167 -0
  145. genokeeper/schedule.py +275 -0
  146. genokeeper/sex.py +251 -0
  147. genokeeper/smn.py +231 -0
  148. genokeeper/sources/__init__.py +126 -0
  149. genokeeper/sources/ancestry.py +128 -0
  150. genokeeper/sources/bloodgroups.py +197 -0
  151. genokeeper/sources/cnv.py +337 -0
  152. genokeeper/sources/deletions.py +410 -0
  153. genokeeper/sources/dosage.py +80 -0
  154. genokeeper/sources/gwas.py +129 -0
  155. genokeeper/sources/hhv6.py +104 -0
  156. genokeeper/sources/hla.py +348 -0
  157. genokeeper/sources/mito.py +92 -0
  158. genokeeper/sources/mitomap.py +168 -0
  159. genokeeper/sources/panel.py +234 -0
  160. genokeeper/sources/panelapp.py +344 -0
  161. genokeeper/sources/paralogs.py +527 -0
  162. genokeeper/sources/pgx.py +204 -0
  163. genokeeper/sources/pharmgkb.py +119 -0
  164. genokeeper/sources/predicted.py +172 -0
  165. genokeeper/sources/promote.py +165 -0
  166. genokeeper/sources/prs.py +432 -0
  167. genokeeper/sources/repeats.py +919 -0
  168. genokeeper/sources/research.py +27 -0
  169. genokeeper/sources/roh.py +108 -0
  170. genokeeper/sources/smn.py +167 -0
  171. genokeeper/sources/traits.py +290 -0
  172. genokeeper/sources/withheld.py +93 -0
  173. genokeeper/sources/yleaf.py +102 -0
  174. genokeeper/store/__init__.py +1 -0
  175. genokeeper/store/db.py +286 -0
  176. genokeeper/store/schema.sql +149 -0
  177. genokeeper/store/writer.py +198 -0
  178. genokeeper/timings.py +70 -0
  179. genokeeper/tools.py +80 -0
  180. genokeeper/typing_files.py +286 -0
  181. genokeeper/variants/__init__.py +1 -0
  182. genokeeper/variants/key.py +21 -0
  183. genokeeper/yhaplogroup.py +77 -0
  184. genokeeper-0.1.0.dist-info/METADATA +50 -0
  185. genokeeper-0.1.0.dist-info/RECORD +189 -0
  186. genokeeper-0.1.0.dist-info/WHEEL +4 -0
  187. genokeeper-0.1.0.dist-info/entry_points.txt +3 -0
  188. genokeeper-0.1.0.dist-info/licenses/LICENSE +661 -0
  189. genokeeper-0.1.0.dist-info/licenses/NOTICE +158 -0
genokeeper/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """genokeeper: findings from the user's own whole-genome data, kept on this machine."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("genokeeper")
genokeeper/align.py ADDED
@@ -0,0 +1,164 @@
1
+ """Stage 2a alignment: the platform gate on the delivered FASTQ, and bwa mem to a
2
+ coordinate-sorted, duplicate-marked CRAM on the GRCh38 no-alt FASTA.
3
+
4
+ The platform gate stops reads the T7 checkpoint was not trained on: a DNBSEQ-G400 run
5
+ (flowcell `V...`) or a DNBSEQ-T7+ run (flowcell `ML...`, base qualities binned to four
6
+ levels). Its anchor comes from the FASTQ itself, the flowcell ID the sequencer writes into
7
+ every read name and the quality characters of every base, which a mislabelled delivery or a
8
+ file mix-up cannot move because no one downstream of the instrument rewrites them. A
9
+ sequence archive (SRA, ENA, DDBJ) puts its run accession first and keeps the instrument's
10
+ name as the second field; the gate reads that one.
11
+ """
12
+
13
+ import gzip
14
+ import os
15
+ import re
16
+ import secrets
17
+ import shlex
18
+ import shutil
19
+ import subprocess
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from genokeeper.tools import ToolFailed, gated, require
25
+
26
+ # The original DNBSEQ-T7 names its flowcells E...: the platform the T7 checkpoint wants.
27
+ T7_FLOWCELL = "E"
28
+ # T7+ bins base qualities to four levels; an unbinned T7 run writes dozens.
29
+ MIN_QUALITY_LEVELS = 5
30
+ _FLOWCELL = re.compile(r"^@([A-Z]+)\d")
31
+ _ARCHIVED = re.compile(r"^@[SED]RR\d+\.\d+\s+(?=[A-Z]+\d)")
32
+ _KNOWN = {"E": "DNBSEQ-T7", "V": "DNBSEQ-G400", "ML": "DNBSEQ-T7+"}
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Platform:
37
+ """What the FASTQ's first reads show: the flowcell's letter prefix and how many distinct
38
+ base quality values occur."""
39
+
40
+ flowcell_prefix: str
41
+ quality_levels: int
42
+
43
+
44
+ class PlatformRefused(RuntimeError):
45
+ """The FASTQ is not from the platform the T7 checkpoint was trained on."""
46
+
47
+
48
+ def _records(fastq: Path) -> Iterator[tuple[str, str]]:
49
+ """(name line, quality line) of each record, gzipped or plain."""
50
+ with fastq.open("rb") as raw:
51
+ gzipped = raw.read(2) == b"\x1f\x8b"
52
+ opener = gzip.open if gzipped else open
53
+ with opener(fastq, "rt") as f:
54
+ while name := f.readline():
55
+ f.readline()
56
+ f.readline()
57
+ yield name.rstrip("\n"), f.readline().rstrip("\n")
58
+
59
+
60
+ def check_platform(
61
+ fastq1: Path, *, reads: int = 100_000, names: bool = True
62
+ ) -> Platform:
63
+ """The platform of the first `reads` records of `fastq1`; raises `PlatformRefused` unless
64
+ every read name carries an E... flowcell and the qualities take at least
65
+ `MIN_QUALITY_LEVELS` values. Only aggregates leave this function.
66
+
67
+ `names=False` skips the flowcell tests and reports the first alignment's `E`: a FASTQ
68
+ written back from a calibration CRAM carries ENA's accession alone (bwa kept the
69
+ name's first word), and its reads passed the full gate when that CRAM was aligned. The
70
+ quality test runs as before."""
71
+ prefixes: set[str] = set()
72
+ qualities: set[str] = set()
73
+ seen = 0
74
+ for name, quality in _records(fastq1):
75
+ match = _FLOWCELL.match(_ARCHIVED.sub("@", name, count=1))
76
+ prefixes.add(match.group(1) if match else "?")
77
+ qualities.update(quality)
78
+ seen += 1
79
+ if seen == reads:
80
+ break
81
+ if not seen:
82
+ raise PlatformRefused(f"{fastq1}: no reads")
83
+ if not names:
84
+ prefixes = {T7_FLOWCELL}
85
+ if len(prefixes) > 1:
86
+ raise PlatformRefused(
87
+ f"{fastq1}: reads from flowcells {' and '.join(sorted(prefixes))}; one run's"
88
+ " FASTQ comes from one kind of flowcell"
89
+ )
90
+ prefix = prefixes.pop()
91
+ if prefix != T7_FLOWCELL:
92
+ kind = _KNOWN.get(prefix, "an unknown platform")
93
+ raise PlatformRefused(
94
+ f"{fastq1}: flowcell prefix {prefix} ({kind}); the T7 checkpoint is for the"
95
+ f" original DNBSEQ-T7 ({T7_FLOWCELL}...)"
96
+ )
97
+ if len(qualities) < MIN_QUALITY_LEVELS:
98
+ raise PlatformRefused(
99
+ f"{fastq1}: {len(qualities)} base quality levels (binned, as DNBSEQ-T7+ writes"
100
+ f" them); the T7 checkpoint was trained on unbinned qualities"
101
+ )
102
+ return Platform(flowcell_prefix=prefix, quality_levels=len(qualities))
103
+
104
+
105
+ def align(
106
+ fastq1: Path, fastq2: Path, fasta: Path, out: Path, *, sample: str, threads: int
107
+ ) -> Path:
108
+ """`bwa mem | samtools fixmate -m | samtools sort | samtools markdup -O cram` into `out`,
109
+ indexed, as one unit behind the core gate (`dna:align`). bwa finds its index beside
110
+ `fasta` (`<fasta>.bwt` and the rest, as the bootstrap unpacks it).
111
+
112
+ Every stage's exit status is checked: the first that failed raises `ToolFailed` with its
113
+ stderr. The CRAM and its index are written under temp names and renamed only when every
114
+ stage succeeded, so a failed or killed run leaves no `out`."""
115
+ require("bwa", "samtools")
116
+ work = out.parent / f".align-{secrets.token_hex(4)}"
117
+ work.mkdir()
118
+ tmp = out.with_name(f".{out.name}.{secrets.token_hex(4)}.cram")
119
+ t = str(threads)
120
+ group = f"@RG\\tID:{sample}\\tSM:{sample}\\tPL:DNBSEQ"
121
+ stages = [
122
+ # -K: a fixed batch size makes the output independent of the thread count.
123
+ ["bwa", "mem", "-t", t, "-K", "100000000", "-Y", "-R", group, str(fasta)]
124
+ + [str(fastq1), str(fastq2)],
125
+ ["samtools", "fixmate", "-m", "-u", "-@", t, "-", "-"],
126
+ ["samtools", "sort", "-u", "-@", t, "-T", str(work / "sort"), "-"],
127
+ ["samtools", "markdup", "-@", t, "-T", str(work / "markdup")]
128
+ + ["-O", "cram", "--reference", str(fasta), "-", str(tmp)],
129
+ ["samtools", "index", "-@", t, str(tmp)],
130
+ ]
131
+ status = work / "status"
132
+ pipe = " | ".join(
133
+ f"{shlex.join(stage)} 2> {shlex.quote(str(work / f'err{i}'))}"
134
+ for i, stage in enumerate(stages[:-1])
135
+ )
136
+ index = f"{shlex.join(stages[-1])} 2> {shlex.quote(str(work / 'err4'))}"
137
+ script = (
138
+ f'{pipe}\ns=("${{PIPESTATUS[@]}}")\n'
139
+ f'if [ "${{s[*]}}" = "0 0 0 0" ]; then {index}; s+=($?); fi\n'
140
+ f'echo "${{s[*]}}" > {shlex.quote(str(status))}\n'
141
+ )
142
+ try:
143
+ ran = subprocess.run(
144
+ gated("dna:align", ["bash", "-c", script]),
145
+ check=False, # each stage's status is read from the status file below
146
+ stdin=subprocess.DEVNULL,
147
+ capture_output=True,
148
+ text=True,
149
+ errors="replace",
150
+ )
151
+ codes = [int(c) for c in status.read_text().split()] if status.exists() else []
152
+ if not codes:
153
+ raise ToolFailed(ran.returncode, ["bash", "-c", script], None, ran.stderr)
154
+ for i, code in enumerate(codes):
155
+ if code:
156
+ stderr = (work / f"err{i}").read_text(errors="replace")
157
+ raise ToolFailed(code, stages[i], None, stderr)
158
+ os.replace(f"{tmp}.crai", f"{out}.crai")
159
+ os.replace(tmp, out)
160
+ finally:
161
+ tmp.unlink(missing_ok=True)
162
+ Path(f"{tmp}.crai").unlink(missing_ok=True)
163
+ shutil.rmtree(work, ignore_errors=True)
164
+ return out
@@ -0,0 +1,4 @@
1
+ """Ancestry beyond pgsc_calc's similarity (slice 3f, "Admixture"): supervised admixture
2
+ proportions against the panel's superpopulations (`admixture`) and the archaic share by
3
+ hmmix (`archaic`). Both read the user's genotypes in their containers, offline, behind the
4
+ core gate as `dna:ancestry`."""
@@ -0,0 +1,95 @@
1
+ """Supervised admixture proportions by fastmixture 1.3.0 (slice 3f, "Admixture").
2
+
3
+ The panel's reference (`typing_files.build_panel_reference`) is its unrelated samples'
4
+ LD-pruned SNPs (`pruned.bim`), fastmixture's supervised P file with the superpopulations as
5
+ labels 1..K (AMR unlabelled) and `groups.json` naming each label. The user's genotypes at
6
+ the pruned SNPs (`pruned_alleles`) are written by `pgsc.write_target_vcf` (reference rows
7
+ explicit, an unknown call `./.`, one row per `pruned.bim` row, where a position can hold two
8
+ or three: the projection needs every SNP of the P file), turned into a bed by one plink2 call in pgsc_calc's image (`to_bed`; variant IDs `chrom:pos:ref:alt`, as
9
+ the panel's), and projected on the P file (`run_projection`). Both containers run offline,
10
+ gated `dna:ancestry`.
11
+ """
12
+
13
+ import json
14
+ from collections.abc import Sequence
15
+ from pathlib import Path
16
+
17
+ from genokeeper.containers import PLATFORMS, run_container
18
+ from genokeeper.genotype.gvcf import Allele
19
+ from genokeeper.typing_files import LABEL, SEED
20
+
21
+ PROJECTION = "projection"
22
+
23
+
24
+ def pruned_alleles(bim: Path) -> list[Allele]:
25
+ """The pruned SNPs of the panel's `pruned.bim` in its order, as `(chr<N>, pos, REF,
26
+ ALT)`: plink2 writes ALT as A1 and REF as A2."""
27
+ alleles: list[Allele] = []
28
+ for line in bim.read_text().splitlines():
29
+ chrom, _id, _cm, pos, a1, a2 = line.split("\t")
30
+ alleles.append((f"chr{chrom.removeprefix('chr')}", int(pos), a2, a1))
31
+ return alleles
32
+
33
+
34
+ def read_groups(path: Path) -> tuple[str, ...]:
35
+ """`groups.json`'s labelled groups in label order (1..K)."""
36
+ groups: dict[str, int] = json.loads(path.read_text())["groups"]
37
+ return tuple(sorted(groups, key=groups.__getitem__))
38
+
39
+
40
+ def read_q(path: Path, groups: Sequence[str]) -> dict[str, float]:
41
+ """fastmixture's one-sample Q file, column k the k-th of `groups`; `ValueError` for
42
+ other than one row of `len(groups)` columns."""
43
+ rows = [line.split() for line in path.read_text().splitlines() if line.strip()]
44
+ if len(rows) != 1 or len(rows[0]) != len(groups):
45
+ width = len(rows[0]) if rows else 0
46
+ raise ValueError(
47
+ f"{path}: {len(rows)} rows of {width} columns, not one of {len(groups)}"
48
+ )
49
+ return dict(zip(groups, (float(v) for v in rows[0]), strict=True))
50
+
51
+
52
+ def to_bed(vcf: Path, out: Path) -> Path:
53
+ """`vcf` (the user's calls at `pruned_alleles`) as the PLINK bed `out` with its bim
54
+ and fam, by plink2 in pgsc_calc's image; `out`."""
55
+ out.parent.mkdir(parents=True, exist_ok=True)
56
+ run_container(
57
+ "pgsc_plink2",
58
+ [
59
+ "plink2", "--vcf", f"/in/{vcf.name}", "--set-all-var-ids", "@:#:$r:$a",
60
+ "--make-bed", "--out", f"/out/{out.stem}",
61
+ ],
62
+ mounts=[(vcf.parent, "/in", True), (out.parent, "/out", False)],
63
+ platform=PLATFORMS["pgsc_plink2"],
64
+ label=LABEL,
65
+ ) # fmt: skip
66
+ if not out.is_file():
67
+ raise RuntimeError(f"plink2 wrote no {out.name}")
68
+ return out
69
+
70
+
71
+ def run_projection(bed: Path, p_file: Path, k: int, out: Path) -> dict[str, float]:
72
+ """The user's `bed` projected on the supervised `p_file` (K `k`, seed `SEED`) by
73
+ fastmixture, its Q file written into `out`; the proportions labelled by the
74
+ `groups.json` beside `p_file`. `ValueError` when `k` is not that file's K."""
75
+ groups = read_groups(p_file.parent / "groups.json")
76
+ if len(groups) != k:
77
+ raise ValueError(
78
+ f"K {k}, but {p_file.parent / 'groups.json'} has {len(groups)}"
79
+ )
80
+ out.mkdir(parents=True, exist_ok=True)
81
+ run_container(
82
+ "fastmixture",
83
+ [
84
+ "fastmixture", "--bfile", f"/in/{bed.stem}", "--K", str(k),
85
+ "--seed", str(SEED), "--threads", "1",
86
+ "--projection", f"/ref/{p_file.name}", "--out", f"/out/{PROJECTION}",
87
+ ],
88
+ mounts=[(bed.parent, "/in", True), (p_file.parent, "/ref", True), (out, "/out", False)],
89
+ platform=PLATFORMS["fastmixture"],
90
+ label=LABEL,
91
+ ) # fmt: skip
92
+ q = out / f"{PROJECTION}.K{k}.s{SEED}.Q"
93
+ if not q.is_file():
94
+ raise RuntimeError(f"fastmixture wrote no {q.name}")
95
+ return read_q(q, groups)
@@ -0,0 +1,147 @@
1
+ """The archaic share by hmmix 0.9.2 (slice 3f, "Admixture"; ruling R5).
2
+
3
+ `run_hmmix` runs, in one hmmix container (offline, gated `dna:ancestry`), the steps Task 1
4
+ recorded: the user's PASS calls split one VCF per autosome (hmmix pairs each with its
5
+ ancestral FASTA by the varying part of the names), `create_ingroup` against the 1000G+HGDP
6
+ outgroup on the strict callability mask (refused when the observations come back nearly
7
+ empty, as they do silently without vcftools), `train`, and `decode -admixpop` with the four
8
+ high-coverage archaic genomes, which writes `decoded.diploid.txt`.
9
+
10
+ A decoded `Archaic` segment is Neanderthal when the most derived SNPs it shares with any of
11
+ the three Neanderthals (Altai, Vindija, Chagyrskaya) outnumber those it shares with
12
+ Denisova, Denisovan when Denisova's are more, and unclassified on a tie (`Segment.origin`;
13
+ hmmix's README reads its example segment this way, and the published anchor kept fragments
14
+ that share more with a Neanderthal than with Denisova). `archaic_share` takes the
15
+ Neanderthal segments' raw span over `ARCHAIC_GENOME_BP` (R15: the anchor and the statement
16
+ are the Neanderthal share; the Denisovan span is kept beside it), the published
17
+ anchor's fixed denominator (Coll Macià et al. 2026, Mol Biol Evol 43:msag134: 2 ×
18
+ 2,550,757,832 bp, autosomes, both haplotypes), not the strict mask's sum: the unphased
19
+ segments' span stands for both haplotypes', as archaic segments rarely overlap between them.
20
+ """
21
+
22
+ import csv
23
+ from collections.abc import Sequence
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Literal
27
+
28
+ from genokeeper.containers import PLATFORMS, run_container
29
+ from genokeeper.typing_files import LABEL
30
+
31
+ ARCHAIC_GENOME_BP = 2 * 2_550_757_832
32
+ DECODED = "decoded.diploid.txt"
33
+ NEANDERTHALS = ("AltaiNeandertal", "Vindija33.19", "Chagyrskaya-Phalanx")
34
+ DENISOVA = "Denisova"
35
+ # Fewer observation rows than this means create_ingroup failed silently (Task 5's HG002
36
+ # truth gave 133,532).
37
+ MIN_OBS_ROWS = 1000
38
+
39
+ _SCRIPT = """\
40
+ set -euo pipefail
41
+ vcf="$1"; work="$2"; min_obs="$3"
42
+ cd "$work"
43
+ mkdir -p anc vcf
44
+ chroms=$(seq 1 22)
45
+ tar -xzf /h/hg38_ancestral.tar.gz -C anc \
46
+ $(for c in $chroms; do printf 'hg38_ancestral/homo_sapiens_ancestor_%s.fa ' "$c"; done)
47
+ ind=$(bcftools query -l "$vcf" | head -n 1)
48
+ for c in $chroms; do
49
+ bcftools view -f PASS -r "chr$c" "$vcf" -Oz -o "vcf/owner.chr$c.vcf.gz"
50
+ bcftools index -f "vcf/owner.chr$c.vcf.gz"
51
+ done
52
+ join() { local IFS=,; echo "$*"; }
53
+ vcfs=$(join $(for c in $chroms; do echo "vcf/owner.chr$c.vcf.gz"; done))
54
+ ancs=$(join $(for c in $chroms; do echo "anc/hg38_ancestral/homo_sapiens_ancestor_$c.fa"; done))
55
+ arch=$(join $(for c in $chroms; do echo "/h/archaic/individuals_highcov.$c.bcf"; done))
56
+ hmmix create_ingroup -ind="$ind" -vcf="$vcfs" -weights=/h/hg38_strick_callability_mask.bed \
57
+ -outgroup=/h/hg38_Outgroup_1000g_HGDP.txt -ancestral="$ancs" -out=obs
58
+ obs="obs.$ind.txt"
59
+ [ "$(wc -l < "$obs")" -gt "$min_obs" ] || { echo "hmmix: $obs nearly empty" >&2; exit 3; }
60
+ hmmix train -obs="$obs" -weights=/h/hg38_strick_callability_mask.bed -mutrates=/h/hg38_mutationrate.bed \
61
+ -out=trained.json
62
+ hmmix decode -obs="$obs" -weights=/h/hg38_strick_callability_mask.bed -mutrates=/h/hg38_mutationrate.bed \
63
+ -param=trained.json -admixpop="$arch" -out=decoded
64
+ rm -rf anc vcf
65
+ """
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Segment:
70
+ """One decoded segment: its span (0-based, end exclusive), state (`Human` or
71
+ `Archaic`), and the most derived SNPs it shares with one Neanderthal and with
72
+ Denisova."""
73
+
74
+ chrom: str
75
+ start: int
76
+ end: int
77
+ state: str
78
+ neanderthal: int
79
+ denisova: int
80
+
81
+ @property
82
+ def origin(self) -> Literal["neanderthal", "denisovan"] | None:
83
+ """Which archaic group an `Archaic` segment shares more derived SNPs with; None for
84
+ a tie or a `Human` segment."""
85
+ if self.state != "Archaic" or self.neanderthal == self.denisova:
86
+ return None
87
+ return "neanderthal" if self.neanderthal > self.denisova else "denisovan"
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ArchaicShare:
92
+ """The Neanderthal and Denisovan segments' summed spans, and the Neanderthal span's
93
+ share of the denominator."""
94
+
95
+ neanderthal_bp: int
96
+ denisovan_bp: int
97
+ share: float
98
+
99
+
100
+ def run_hmmix(vcf: Path, files: Path, out: Path) -> Path:
101
+ """hmmix on the user's indexed `vcf` with its helper files `files`
102
+ (`typing_files.TypingFiles.hmmix`), its work and `DECODED` in `out`; the decoded file.
103
+ `RuntimeError` when it exits 0 without it, and what `run_container` raises."""
104
+ out.mkdir(parents=True, exist_ok=True)
105
+ run_container(
106
+ "hmmix",
107
+ ["bash", "-c", _SCRIPT, "hmmix", f"/in/{vcf.name}", "/w", str(MIN_OBS_ROWS)],
108
+ mounts=[(vcf.parent, "/in", True), (files, "/h", True), (out, "/w", False)],
109
+ platform=PLATFORMS["hmmix"],
110
+ label=LABEL,
111
+ )
112
+ decoded = out / DECODED
113
+ if not decoded.is_file():
114
+ raise RuntimeError(f"hmmix exited 0 without {DECODED}")
115
+ return decoded
116
+
117
+
118
+ def read_segments(path: Path) -> list[Segment]:
119
+ """Each segment of hmmix's `decode -admixpop` output."""
120
+ with path.open(newline="", encoding="utf-8") as f:
121
+ return [
122
+ Segment(
123
+ chrom=row["chrom"],
124
+ start=int(row["start"]),
125
+ end=int(row["end"]),
126
+ state=row["state"],
127
+ neanderthal=max(int(row[name]) for name in NEANDERTHALS),
128
+ denisova=int(row[DENISOVA]),
129
+ )
130
+ for row in csv.DictReader(f, delimiter="\t")
131
+ ]
132
+
133
+
134
+ def archaic_share(
135
+ segments: Sequence[Segment], genome_bp: int = ARCHAIC_GENOME_BP
136
+ ) -> ArchaicShare:
137
+ """The Neanderthal and Denisovan segments' raw spans, and the Neanderthal span over
138
+ `genome_bp`; unclassified segments count for neither."""
139
+ spans = {"neanderthal": 0, "denisovan": 0}
140
+ for s in segments:
141
+ if s.origin is not None:
142
+ spans[s.origin] += s.end - s.start
143
+ return ArchaicShare(
144
+ neanderthal_bp=spans["neanderthal"],
145
+ denisovan_bp=spans["denisovan"],
146
+ share=spans["neanderthal"] / genome_bp,
147
+ )
@@ -0,0 +1,2 @@
1
+ """Variant annotation for slice 1e: Ensembl VEP with its plugins, and SpliceAI
2
+ (`genokeeper.annotate.vep`)."""