allelix 2.0.2__tar.gz → 2.1.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.
- {allelix-2.0.2 → allelix-2.1.0}/PKG-INFO +1 -1
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/clinvar.py +14 -4
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/gwas.py +44 -6
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/pharmgkb.py +21 -5
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/snpedia.py +3 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/models.py +27 -1
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/vcf.py +1 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/_pipeline.py +39 -58
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/diff.py +1 -1
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/json_report.py +6 -2
- allelix-2.1.0/allelix/utils/allele.py +211 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/PKG-INFO +1 -1
- {allelix-2.0.2 → allelix-2.1.0}/pyproject.toml +1 -1
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_models.py +27 -0
- allelix-2.0.2/allelix/utils/allele.py +0 -89
- {allelix-2.0.2 → allelix-2.1.0}/LICENSE +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/README.md +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/alphamissense.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/base.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/cadd.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/gnomad.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/_helpers.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/_options.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/analyze.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/config.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/db.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/focused.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/utility.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/compare.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/config.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/data/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/data/high_value_snps.yaml +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/_versions.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/alphamissense_loader.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/cadd_loader.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/cpic_loader.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/gnomad_loader.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/gwas_loader.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/loader_utils.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/manager.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/pharmgkb_loader.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/schema.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/snpedia_loader.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/snpedia_parser.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/exporters/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/exporters/plink.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/_helpers.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/ancestrydna.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/base.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/ftdna.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/ftdna_illumina.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/livingdna.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/myhappygenes.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/myheritage.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/twentythreeandme.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/py.typed +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/high_value.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/html.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/methylation.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/terminal.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/utils/__init__.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix/utils/build_detect.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/SOURCES.txt +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/dependency_links.txt +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/entry_points.txt +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/requires.txt +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/top_level.txt +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/setup.cfg +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_cli.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_cli_helpers.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_compare.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_config.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_end_to_end.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_mock_data_invariants.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_registry.py +0 -0
- {allelix-2.0.2 → allelix-2.1.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
|
|
5
5
|
Author: Allelix
|
|
6
6
|
Maintainer-email: dial481 <dial481@users.noreply.github.com>
|
|
@@ -30,6 +30,7 @@ from allelix.databases.manager import (
|
|
|
30
30
|
verify_file_hash,
|
|
31
31
|
)
|
|
32
32
|
from allelix.models import Annotation
|
|
33
|
+
from allelix.utils.allele import strand_aware_carrier_match
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
35
36
|
from collections.abc import Iterable, Iterator
|
|
@@ -384,7 +385,6 @@ class ClinVarAnnotator(Annotator):
|
|
|
384
385
|
(variant.rsid,),
|
|
385
386
|
).fetchall()
|
|
386
387
|
annotations: list[Annotation] = []
|
|
387
|
-
carrier_alleles = {variant.allele1, variant.allele2}
|
|
388
388
|
user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
|
|
389
389
|
# ADR-0023: report the user's actual diploid call consistently
|
|
390
390
|
# across annotators, not the matched ALT base alone.
|
|
@@ -404,7 +404,14 @@ class ClinVarAnnotator(Annotator):
|
|
|
404
404
|
clinvar_is_indel = len(ref) > 1 or len(alt) > 1
|
|
405
405
|
if clinvar_is_indel and not user_is_multibase:
|
|
406
406
|
continue
|
|
407
|
-
|
|
407
|
+
# ADR-0035 PR 4: strand-aware carrier match. Direct {ref, alt}
|
|
408
|
+
# membership still covers forward-stranded inputs; the
|
|
409
|
+
# complement-fallback path fires only when Variant.ref confirms
|
|
410
|
+
# reverse orientation AND the site isn't palindromic — preserving
|
|
411
|
+
# the v2.0.1 #18 multi-allelic safety invariant.
|
|
412
|
+
if not strand_aware_carrier_match(
|
|
413
|
+
variant.ref, variant.allele1, variant.allele2, ref, alt
|
|
414
|
+
):
|
|
408
415
|
continue
|
|
409
416
|
sig_label = _normalize_clnsig(clnsig) if clnsig else "unknown"
|
|
410
417
|
if not self._include_benign and sig_label in _BENIGN_CLNSIGS:
|
|
@@ -562,7 +569,6 @@ class ClinVarAnnotator(Annotator):
|
|
|
562
569
|
rows = rows_by_build_rsid.get((variant.build, variant.rsid))
|
|
563
570
|
if not rows:
|
|
564
571
|
continue
|
|
565
|
-
carrier_alleles = {variant.allele1, variant.allele2}
|
|
566
572
|
user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
|
|
567
573
|
user_diploid = _user_diploid(variant)
|
|
568
574
|
for row in rows:
|
|
@@ -580,7 +586,11 @@ class ClinVarAnnotator(Annotator):
|
|
|
580
586
|
clinvar_is_indel = len(ref) > 1 or len(alt) > 1
|
|
581
587
|
if clinvar_is_indel and not user_is_multibase:
|
|
582
588
|
continue
|
|
583
|
-
|
|
589
|
+
# ADR-0035 PR 4: strand-aware carrier match. See annotate() for
|
|
590
|
+
# the safety rationale (#18 multi-allelic invariant preserved).
|
|
591
|
+
if not strand_aware_carrier_match(
|
|
592
|
+
variant.ref, variant.allele1, variant.allele2, ref, alt
|
|
593
|
+
):
|
|
584
594
|
continue
|
|
585
595
|
sig_label = _normalize_clnsig(clnsig) if clnsig else "unknown"
|
|
586
596
|
if not self._include_benign and sig_label in _BENIGN_CLNSIGS:
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import contextlib
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
import sqlite3
|
|
10
11
|
import zipfile
|
|
11
12
|
from typing import TYPE_CHECKING, ClassVar
|
|
@@ -56,6 +57,27 @@ logger = logging.getLogger(__name__)
|
|
|
56
57
|
|
|
57
58
|
_BATCH_CHUNK = 500 # SQLite default SQLITE_MAX_VARIABLE_NUMBER is 999
|
|
58
59
|
|
|
60
|
+
# ADR-0035 PR 3: PheCode often arrives embedded in the upstream DISEASE/TRAIT
|
|
61
|
+
# field (e.g. ``"Type 2 diabetes (PheCode 250.2)"``). The annotator extracts it
|
|
62
|
+
# at the boundary so downstream code reads ``Annotation.phecode`` as a
|
|
63
|
+
# structured field instead of regex-parsing prose. The trimmed trait flows into
|
|
64
|
+
# ``Annotation.trait``; ``description`` is rebuilt below from the structured
|
|
65
|
+
# parts so display surfaces still show the PheCode label.
|
|
66
|
+
_PHECODE_SUFFIX_RE = re.compile(r"\s*\(PheCode\s+([\d.]+)\)\s*$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_trait_and_phecode(raw_trait: str) -> tuple[str, str]:
|
|
70
|
+
"""Split an upstream trait into (clean_trait, phecode).
|
|
71
|
+
|
|
72
|
+
PheCode (when present) is returned as a bare numeric string like
|
|
73
|
+
``"250.2"``. Returns ``""`` for phecode when no PheCode suffix is found.
|
|
74
|
+
The clean trait is stripped of trailing whitespace.
|
|
75
|
+
"""
|
|
76
|
+
m = _PHECODE_SUFFIX_RE.search(raw_trait)
|
|
77
|
+
if m is None:
|
|
78
|
+
return raw_trait.strip(), ""
|
|
79
|
+
return raw_trait[: m.start()].rstrip(), m.group(1)
|
|
80
|
+
|
|
59
81
|
|
|
60
82
|
def _magnitude(p_value: float | None, or_beta: float | None) -> float:
|
|
61
83
|
"""Derive magnitude from p-value and optional effect size.
|
|
@@ -288,9 +310,14 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
288
310
|
mag = min(_magnitude(p_value, or_beta), _UNKNOWN_RISK_ALLELE_MAG_CAP)
|
|
289
311
|
risk_note = " (risk allele not specified in study)"
|
|
290
312
|
|
|
313
|
+
clean_trait, phecode = _split_trait_and_phecode(trait)
|
|
314
|
+
phecode_suffix = f" (PheCode {phecode})" if phecode else ""
|
|
291
315
|
p_str = f"p={p_value:.1e}" if p_value is not None else "p=N/A"
|
|
292
316
|
gene_str = gene or "—"
|
|
293
|
-
description =
|
|
317
|
+
description = (
|
|
318
|
+
f"GWAS Catalog: {clean_trait}{phecode_suffix} "
|
|
319
|
+
f"({p_str}, gene: {gene_str}){risk_note}"
|
|
320
|
+
)
|
|
294
321
|
|
|
295
322
|
references: list[str] = []
|
|
296
323
|
if pubmed_id:
|
|
@@ -309,10 +336,13 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
309
336
|
attribution=self.attribution,
|
|
310
337
|
genotype_match=user_diploid,
|
|
311
338
|
references=references,
|
|
312
|
-
condition=
|
|
339
|
+
condition=clean_trait,
|
|
313
340
|
gene=gene or "",
|
|
314
|
-
alt="",
|
|
341
|
+
alt=risk_allele or "",
|
|
315
342
|
is_must_include=variant.rsid in _MUST_INCLUDE_RSIDS,
|
|
343
|
+
trait=clean_trait,
|
|
344
|
+
p_value=p_value,
|
|
345
|
+
phecode=phecode,
|
|
316
346
|
)
|
|
317
347
|
)
|
|
318
348
|
return annotations
|
|
@@ -377,9 +407,14 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
377
407
|
mag = min(_magnitude(p_value, or_beta), _UNKNOWN_RISK_ALLELE_MAG_CAP)
|
|
378
408
|
risk_note = " (risk allele not specified in study)"
|
|
379
409
|
|
|
410
|
+
clean_trait, phecode = _split_trait_and_phecode(trait)
|
|
411
|
+
phecode_suffix = f" (PheCode {phecode})" if phecode else ""
|
|
380
412
|
p_str = f"p={p_value:.1e}" if p_value is not None else "p=N/A"
|
|
381
413
|
gene_str = gene or "—"
|
|
382
|
-
description =
|
|
414
|
+
description = (
|
|
415
|
+
f"GWAS Catalog: {clean_trait}{phecode_suffix} "
|
|
416
|
+
f"({p_str}, gene: {gene_str}){risk_note}"
|
|
417
|
+
)
|
|
383
418
|
|
|
384
419
|
references: list[str] = []
|
|
385
420
|
if pubmed_id:
|
|
@@ -397,10 +432,13 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
397
432
|
attribution=self.attribution,
|
|
398
433
|
genotype_match=user_diploid,
|
|
399
434
|
references=references,
|
|
400
|
-
condition=
|
|
435
|
+
condition=clean_trait,
|
|
401
436
|
gene=gene or "",
|
|
402
|
-
alt="",
|
|
437
|
+
alt=risk_allele or "",
|
|
403
438
|
is_must_include=variant.rsid in _MUST_INCLUDE_RSIDS,
|
|
439
|
+
trait=clean_trait,
|
|
440
|
+
p_value=p_value,
|
|
441
|
+
phecode=phecode,
|
|
404
442
|
)
|
|
405
443
|
|
|
406
444
|
|
|
@@ -30,6 +30,7 @@ from allelix.databases.pharmgkb_loader import (
|
|
|
30
30
|
schema_is_current,
|
|
31
31
|
)
|
|
32
32
|
from allelix.models import Annotation
|
|
33
|
+
from allelix.utils.allele import derive_alt_from_diploid, strand_aware_genotype_match
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
35
36
|
from collections.abc import Callable, Iterable, Iterator
|
|
@@ -260,20 +261,25 @@ class PharmGKBAnnotator(Annotator):
|
|
|
260
261
|
if is_clinvar_homref(variant, self._clinvar_ref_provider):
|
|
261
262
|
return []
|
|
262
263
|
|
|
264
|
+
# ADR-0035 PR 4: fetch all rows for the rsid (typically <5) and apply
|
|
265
|
+
# strand-aware genotype match in Python. Forward-stranded users hit
|
|
266
|
+
# the same set of rows as the v0.7-era ``WHERE genotype = ?`` SQL;
|
|
267
|
+
# reverse-stranded users now also match when Variant.ref confirms
|
|
268
|
+
# orientation AND the source genotype is non-palindromic.
|
|
263
269
|
sql = (
|
|
264
270
|
"SELECT genotype, gene, drugs, phenotype, phenotype_category, "
|
|
265
271
|
"annotation_text, level_of_evidence, score, pgkb_annotation_id "
|
|
266
272
|
"FROM pharmgkb_annotations "
|
|
267
|
-
"WHERE rsid = ? AND
|
|
273
|
+
"WHERE rsid = ? AND is_nonfinding = 0"
|
|
268
274
|
)
|
|
269
|
-
params = (variant.rsid,
|
|
275
|
+
params = (variant.rsid,)
|
|
270
276
|
|
|
271
277
|
rows = self._connection().execute(sql, params).fetchall()
|
|
272
278
|
annotations: list[Annotation] = []
|
|
273
279
|
user_diploid = _user_diploid(variant)
|
|
274
280
|
for row in rows:
|
|
275
281
|
(
|
|
276
|
-
|
|
282
|
+
geno,
|
|
277
283
|
gene,
|
|
278
284
|
drugs,
|
|
279
285
|
phenotype,
|
|
@@ -283,6 +289,10 @@ class PharmGKBAnnotator(Annotator):
|
|
|
283
289
|
_score,
|
|
284
290
|
pgkb_annotation_id,
|
|
285
291
|
) = row
|
|
292
|
+
if not strand_aware_genotype_match(
|
|
293
|
+
variant.allele1, variant.allele2, geno, variant.ref
|
|
294
|
+
):
|
|
295
|
+
continue
|
|
286
296
|
sig_label = level_of_evidence.strip().lower() or "unknown"
|
|
287
297
|
description_parts = [f"ClinPGx: {drugs}"] if drugs else ["ClinPGx"]
|
|
288
298
|
if phenotype:
|
|
@@ -306,6 +316,7 @@ class PharmGKBAnnotator(Annotator):
|
|
|
306
316
|
references=references,
|
|
307
317
|
condition=phenotype or "",
|
|
308
318
|
gene=gene or "",
|
|
319
|
+
alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
|
|
309
320
|
)
|
|
310
321
|
)
|
|
311
322
|
return annotations
|
|
@@ -352,7 +363,7 @@ class PharmGKBAnnotator(Annotator):
|
|
|
352
363
|
for row in cursor:
|
|
353
364
|
rows_by_rsid.setdefault(row[0], []).append(row[1:])
|
|
354
365
|
|
|
355
|
-
for variant,
|
|
366
|
+
for variant, _user_geno in candidates:
|
|
356
367
|
rows = rows_by_rsid.get(variant.rsid)
|
|
357
368
|
if not rows:
|
|
358
369
|
continue
|
|
@@ -369,7 +380,11 @@ class PharmGKBAnnotator(Annotator):
|
|
|
369
380
|
_score,
|
|
370
381
|
pgkb_annotation_id,
|
|
371
382
|
) = row
|
|
372
|
-
|
|
383
|
+
# ADR-0035 PR 4: strand-aware genotype match. See annotate() for
|
|
384
|
+
# the safety rationale (palindrome guard + variant.ref orientation).
|
|
385
|
+
if not strand_aware_genotype_match(
|
|
386
|
+
variant.allele1, variant.allele2, geno, variant.ref
|
|
387
|
+
):
|
|
373
388
|
continue
|
|
374
389
|
sig_label = level_of_evidence.strip().lower() or "unknown"
|
|
375
390
|
description_parts = [f"ClinPGx: {drugs}"] if drugs else ["ClinPGx"]
|
|
@@ -393,6 +408,7 @@ class PharmGKBAnnotator(Annotator):
|
|
|
393
408
|
references=references,
|
|
394
409
|
condition=phenotype or "",
|
|
395
410
|
gene=gene or "",
|
|
411
|
+
alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
|
|
396
412
|
)
|
|
397
413
|
|
|
398
414
|
|
|
@@ -29,6 +29,7 @@ from allelix.databases.snpedia_loader import (
|
|
|
29
29
|
install_prebuilt_cache,
|
|
30
30
|
)
|
|
31
31
|
from allelix.models import Annotation
|
|
32
|
+
from allelix.utils.allele import derive_alt_from_diploid
|
|
32
33
|
|
|
33
34
|
if TYPE_CHECKING:
|
|
34
35
|
from collections.abc import Callable, Iterable, Iterator
|
|
@@ -272,6 +273,7 @@ class SNPediaAnnotator(Annotator):
|
|
|
272
273
|
genotype_match=genotype_match,
|
|
273
274
|
references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
|
|
274
275
|
gene=gene or "",
|
|
276
|
+
alt=derive_alt_from_diploid(variant.ref, allele1, allele2),
|
|
275
277
|
)
|
|
276
278
|
)
|
|
277
279
|
|
|
@@ -356,4 +358,5 @@ class SNPediaAnnotator(Annotator):
|
|
|
356
358
|
genotype_match=genotype_match,
|
|
357
359
|
references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
|
|
358
360
|
gene=gene or "",
|
|
361
|
+
alt=derive_alt_from_diploid(variant.ref, allele1, allele2),
|
|
359
362
|
)
|
|
@@ -31,6 +31,13 @@ class Variant:
|
|
|
31
31
|
allele1: First observed allele. A/T/G/C, multi-base for indels, or "-" for no-call.
|
|
32
32
|
allele2: Second observed allele. Same encoding as allele1.
|
|
33
33
|
build: Reference genome build. "GRCh37" (hg19) or "GRCh38" (hg38).
|
|
34
|
+
ref: Reference allele on the forward strand at this position, when
|
|
35
|
+
known. VCF parsers populate from the REF column; array parsers
|
|
36
|
+
currently leave it None. Required by strand-aware carrier matching
|
|
37
|
+
(ADR-0035) and downstream consumers that need to identify the risk
|
|
38
|
+
allele within the user's pair. None means "cannot resolve" —
|
|
39
|
+
consumers degrade gracefully (return ambiguous, skip strand check,
|
|
40
|
+
etc.) rather than guess.
|
|
34
41
|
"""
|
|
35
42
|
|
|
36
43
|
rsid: str
|
|
@@ -39,9 +46,10 @@ class Variant:
|
|
|
39
46
|
allele1: str
|
|
40
47
|
allele2: str
|
|
41
48
|
build: str = DEFAULT_BUILD
|
|
49
|
+
ref: str | None = None
|
|
42
50
|
|
|
43
51
|
def __post_init__(self) -> None:
|
|
44
|
-
"""Normalize allele case at construction (GH #14).
|
|
52
|
+
"""Normalize allele case at construction (GH #14, ADR-0035).
|
|
45
53
|
|
|
46
54
|
Reference databases (ClinVar, gnomAD, ClinPGx, etc.) all ship
|
|
47
55
|
uppercase alleles, and carrier matching is raw set membership
|
|
@@ -53,11 +61,20 @@ class Variant:
|
|
|
53
61
|
at the model boundary so the invariant is impossible to
|
|
54
62
|
violate downstream. The no-call marker is left as-is;
|
|
55
63
|
multi-base alleles (indels) are uppercased in place.
|
|
64
|
+
|
|
65
|
+
``ref`` (ADR-0035) is a sibling allele field feeding the same
|
|
66
|
+
downstream matching path (PR 4 strand-aware carrier matching
|
|
67
|
+
compares ``ref`` against ``{allele1, allele2}`` and against
|
|
68
|
+
gnomAD's uppercased REF). VCFs derived from soft-masked
|
|
69
|
+
references emit lowercase bases in the REF column, so the
|
|
70
|
+
same normalization applies here for the same reason.
|
|
56
71
|
"""
|
|
57
72
|
if self.allele1 and self.allele1 != NO_CALL_MARKER:
|
|
58
73
|
self.allele1 = self.allele1.upper()
|
|
59
74
|
if self.allele2 and self.allele2 != NO_CALL_MARKER:
|
|
60
75
|
self.allele2 = self.allele2.upper()
|
|
76
|
+
if self.ref is not None and self.ref != NO_CALL_MARKER:
|
|
77
|
+
self.ref = self.ref.upper()
|
|
61
78
|
|
|
62
79
|
@property
|
|
63
80
|
def is_heterozygous(self) -> bool:
|
|
@@ -126,6 +143,15 @@ class Annotation:
|
|
|
126
143
|
am_pathogenicity: float | None = None
|
|
127
144
|
am_class: str = ""
|
|
128
145
|
cadd_phred: float | None = None
|
|
146
|
+
# ADR-0035 cluster manifest (PR 3): structured GWAS fields promoted out of
|
|
147
|
+
# the rendered ``description`` prose. `description` still carries the same
|
|
148
|
+
# rendered text for HTML / terminal display, but downstream code (rollup,
|
|
149
|
+
# diff, future consumers) reads the structured fields directly instead of
|
|
150
|
+
# regex-parsing prose. Populated by the GWAS annotator; empty / None on
|
|
151
|
+
# non-GWAS rows.
|
|
152
|
+
trait: str = ""
|
|
153
|
+
p_value: float | None = None
|
|
154
|
+
phecode: str = ""
|
|
129
155
|
|
|
130
156
|
@property
|
|
131
157
|
def zygosity(self) -> str:
|
|
@@ -167,58 +167,26 @@ class AnalysisResult:
|
|
|
167
167
|
return out
|
|
168
168
|
|
|
169
169
|
|
|
170
|
-
# GH #24:
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _gwas_base_trait(description: str) -> str | None:
|
|
181
|
-
"""Extract trait text from a GWAS description, stripping MTAG suffix and PheCode label."""
|
|
182
|
-
marker = "GWAS Catalog: "
|
|
183
|
-
if marker not in description:
|
|
184
|
-
return None
|
|
185
|
-
s = description.split(marker, 1)[1]
|
|
186
|
-
s = s.split(" (p=", 1)[0]
|
|
187
|
-
if s.endswith(" (MTAG)"):
|
|
188
|
-
s = s[: -len(" (MTAG)")]
|
|
189
|
-
s = s.split(_PHECODE_DELIM, 1)[0]
|
|
190
|
-
return s.strip().lower()
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _gwas_phecode_parent(description: str) -> str | None:
|
|
194
|
-
"""Extract PheCode parent (numeric prefix before the dot), or None."""
|
|
195
|
-
idx = description.find(_PHECODE_DELIM)
|
|
196
|
-
if idx == -1:
|
|
197
|
-
return None
|
|
198
|
-
rest = description[idx + len(_PHECODE_DELIM) :]
|
|
199
|
-
end = rest.find(")")
|
|
200
|
-
if end == -1:
|
|
201
|
-
return None
|
|
202
|
-
code = rest[:end].strip()
|
|
203
|
-
parent = code.split(".", 1)[0]
|
|
204
|
-
return parent if parent.isdigit() else None
|
|
170
|
+
# ADR-0035 PR 3 closed the GH #24 structural-half: GWAS rollup now reads
|
|
171
|
+
# trait / phecode / p_value from structured Annotation fields instead of
|
|
172
|
+
# regex-parsing the rendered ``description`` prose. The previous
|
|
173
|
+
# ``_gwas_base_trait`` / ``_gwas_phecode_parent`` / ``_gwas_p_value`` helpers
|
|
174
|
+
# and the ``_PHECODE_DELIM`` shared-delimiter constant (the v2.0.1 suppress-
|
|
175
|
+
# half) are gone — rollup keys directly off ``a.trait``, ``a.phecode``,
|
|
176
|
+
# ``a.p_value``. MTAG suffix still lives in ``description`` for display and
|
|
177
|
+
# is detected with ``"(MTAG)" in a.description``; promoting that to a
|
|
178
|
+
# structured flag is outside the ADR-0035 cluster manifest.
|
|
205
179
|
|
|
206
180
|
|
|
207
|
-
def
|
|
208
|
-
"""
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if end == -1:
|
|
217
|
-
return float("inf")
|
|
218
|
-
try:
|
|
219
|
-
return float(rest[:end].strip())
|
|
220
|
-
except ValueError:
|
|
221
|
-
return float("inf")
|
|
181
|
+
def _gwas_phecode_parent(phecode: str) -> str | None:
|
|
182
|
+
"""Return the integer parent of a structured PheCode (e.g., ``"411.4"`` → ``"411"``).
|
|
183
|
+
|
|
184
|
+
Returns None when no PheCode is set or the prefix isn't an integer.
|
|
185
|
+
"""
|
|
186
|
+
if not phecode:
|
|
187
|
+
return None
|
|
188
|
+
parent = phecode.split(".", 1)[0]
|
|
189
|
+
return parent if parent.isdigit() else None
|
|
222
190
|
|
|
223
191
|
|
|
224
192
|
def rollup_gwas_duplicates(annotations: list[Annotation]) -> list[Annotation]:
|
|
@@ -238,29 +206,25 @@ def rollup_gwas_duplicates(annotations: list[Annotation]) -> list[Annotation]:
|
|
|
238
206
|
if not gwas_rows:
|
|
239
207
|
return annotations
|
|
240
208
|
|
|
241
|
-
plain_keys = {
|
|
242
|
-
(a.rsid, _gwas_base_trait(a.description))
|
|
243
|
-
for a in gwas_rows
|
|
244
|
-
if "(MTAG)" not in a.description
|
|
245
|
-
}
|
|
209
|
+
plain_keys = {(a.rsid, a.trait.lower()) for a in gwas_rows if "(MTAG)" not in a.description}
|
|
246
210
|
after_mtag = [
|
|
247
211
|
a
|
|
248
212
|
for a in gwas_rows
|
|
249
213
|
if a.is_must_include
|
|
250
214
|
or "(MTAG)" not in a.description
|
|
251
|
-
or (a.rsid,
|
|
215
|
+
or (a.rsid, a.trait.lower()) not in plain_keys
|
|
252
216
|
]
|
|
253
217
|
|
|
254
218
|
by_parent: dict[tuple[str, str], list[Annotation]] = {}
|
|
255
219
|
no_phecode: list[Annotation] = []
|
|
256
220
|
for a in after_mtag:
|
|
257
|
-
parent = _gwas_phecode_parent(a.
|
|
221
|
+
parent = _gwas_phecode_parent(a.phecode)
|
|
258
222
|
if parent is None or a.is_must_include:
|
|
259
223
|
no_phecode.append(a)
|
|
260
224
|
else:
|
|
261
225
|
by_parent.setdefault((a.rsid, parent), []).append(a)
|
|
262
226
|
for group in by_parent.values():
|
|
263
|
-
winner = min(group, key=lambda x:
|
|
227
|
+
winner = min(group, key=lambda x: x.p_value if x.p_value is not None else float("inf"))
|
|
264
228
|
no_phecode.append(winner)
|
|
265
229
|
|
|
266
230
|
survivors.extend(no_phecode)
|
|
@@ -438,6 +402,23 @@ def run_analysis(
|
|
|
438
402
|
resolution = clinvar_resolver.bulk_resolve_rsids(rsidless)
|
|
439
403
|
for (chrom, pos, ref, alt), rsid in resolution.items():
|
|
440
404
|
resolved_coords[rsid] = (chrom, pos, ref, alt)
|
|
405
|
+
# ADR-0035 PR 4: populate Variant.ref for array data via gnomAD's
|
|
406
|
+
# rsid → forward-REF map. VCF inputs already carry ref (PR 1).
|
|
407
|
+
# Annotators downstream (ClinVar / PharmGKB strand-aware carrier
|
|
408
|
+
# match; SNPedia / ClinPGx per-row alt threading from PR 2)
|
|
409
|
+
# consume this. Variants without rsIDs (post-resolution) or
|
|
410
|
+
# whose rsID isn't in gnomAD keep ref=None and degrade
|
|
411
|
+
# gracefully — direct-match-only with no strand-flip.
|
|
412
|
+
if gnomad is not None and gnomad.is_ready():
|
|
413
|
+
need_ref = {v.rsid for v in batch_buf if v.ref is None and v.rsid.startswith("rs")}
|
|
414
|
+
if need_ref:
|
|
415
|
+
coord_map = gnomad.bulk_resolve_coordinates(need_ref)
|
|
416
|
+
for v in batch_buf:
|
|
417
|
+
if v.ref is None and v.rsid in coord_map:
|
|
418
|
+
entries = coord_map[v.rsid]
|
|
419
|
+
# All multi-allelic rows at a position share the
|
|
420
|
+
# same REF; first entry's REF is canonical.
|
|
421
|
+
v.ref = entries[0][2] if entries else None
|
|
441
422
|
# High-value rsID match runs AFTER resolution so a variant
|
|
442
423
|
# entering as ID=. that resolves to a high-value rsID is still
|
|
443
424
|
# caught. GH #11.
|
|
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
|
|
26
26
|
|
|
27
27
|
from allelix.models import Annotation
|
|
28
28
|
|
|
29
|
-
_SUPPORTED_SCHEMA_VERSIONS = {"1", "2", "3", "4"}
|
|
29
|
+
_SUPPORTED_SCHEMA_VERSIONS = {"1", "2", "3", "4", "5"}
|
|
30
30
|
|
|
31
31
|
# GH #25: magnitude comparison tolerance. Magnitudes are scored on a
|
|
32
32
|
# 0-10 scale; representation noise from JSON round-tripping (e.g. 7.5
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Output schema (versioned via `schema_version`):
|
|
6
6
|
|
|
7
7
|
{
|
|
8
|
-
"schema_version": "
|
|
8
|
+
"schema_version": "5",
|
|
9
9
|
"allelix_version": "1.1.0",
|
|
10
10
|
"generated_at": "2026-05-11T12:34:56+00:00",
|
|
11
11
|
"regulatory_notice": "...",
|
|
@@ -52,7 +52,11 @@ if TYPE_CHECKING:
|
|
|
52
52
|
from allelix.reports.diff import DiffResult
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
SCHEMA_VERSION = "
|
|
55
|
+
SCHEMA_VERSION = "5"
|
|
56
|
+
# v4 → v5 bump: enters the ADR-0035 cluster contract. Incoming cluster fields
|
|
57
|
+
# (variant.ref via Variant model, annotation.trait / .p_value / .phecode via
|
|
58
|
+
# Annotation model) land across PRs 1-4 of the v2.1 Cluster B work. v4
|
|
59
|
+
# consumers reading v5 output still succeed — every v4 field is unchanged.
|
|
56
60
|
|
|
57
61
|
|
|
58
62
|
def _annotation_dict(a: Annotation) -> dict:
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Allelix
|
|
3
|
+
"""Strand flipping, complement logic, and ambiguous-SNP detection.
|
|
4
|
+
|
|
5
|
+
A SNP read on the reverse strand has its alleles complemented (A↔T, C↔G).
|
|
6
|
+
Two databases reporting the "same" variant on opposite strands will list
|
|
7
|
+
opposite allele letters. For most SNPs this is unambiguous and reversible.
|
|
8
|
+
For A/T and C/G SNPs (palindromic), the complement equals the alternative —
|
|
9
|
+
so a strand-flip is undetectable from sequence alone and is best handled by
|
|
10
|
+
extra information (allele frequency, surrounding context).
|
|
11
|
+
|
|
12
|
+
ADR-0010 documents the design.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from allelix.models import NO_CALL_MARKER
|
|
18
|
+
|
|
19
|
+
_COMPLEMENT: dict[str, str] = {"A": "T", "T": "A", "C": "G", "G": "C"}
|
|
20
|
+
|
|
21
|
+
# A/T and C/G SNPs are palindromic; their complement equals the alternative,
|
|
22
|
+
# so strand orientation cannot be inferred from the alleles alone.
|
|
23
|
+
_AMBIGUOUS_PAIRS: frozenset[frozenset[str]] = frozenset(
|
|
24
|
+
{frozenset({"A", "T"}), frozenset({"C", "G"})}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def complement(allele: str) -> str:
|
|
29
|
+
"""Return the reverse-complement of a single allele string.
|
|
30
|
+
|
|
31
|
+
A → T, T → A, C → G, G → C. The no-call marker `-` and any unrecognized
|
|
32
|
+
character are returned unchanged. Handles indels (multi-base alleles) by
|
|
33
|
+
complementing each base in reverse order.
|
|
34
|
+
"""
|
|
35
|
+
if allele == NO_CALL_MARKER or not allele:
|
|
36
|
+
return allele
|
|
37
|
+
if len(allele) == 1:
|
|
38
|
+
return _COMPLEMENT.get(allele, allele)
|
|
39
|
+
return "".join(_COMPLEMENT.get(b, b) for b in reversed(allele))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def flip_genotype(allele1: str, allele2: str) -> tuple[str, str]:
|
|
43
|
+
"""Return both alleles complemented (the reverse-strand reading)."""
|
|
44
|
+
return complement(allele1), complement(allele2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_strand(user_allele: str, gnomad_ref: str, gnomad_alt: str) -> str | None:
|
|
48
|
+
"""Return reference-forward allele, or None if not directly present.
|
|
49
|
+
|
|
50
|
+
Maps an array-reported allele to its reference-forward equivalent
|
|
51
|
+
using gnomAD's ref/alt as the ground truth. If the user allele
|
|
52
|
+
matches ref or alt directly, it's already forward and is returned.
|
|
53
|
+
|
|
54
|
+
GH #18: previously, when the user allele did NOT directly match
|
|
55
|
+
ref or alt, this function fell back to a complement check
|
|
56
|
+
(``complement(user_allele) ∈ {ref, alt}`` → return the complement)
|
|
57
|
+
on the assumption that the array was read on the minus strand. At
|
|
58
|
+
multi-allelic sites that fallback is unsound — the complement of
|
|
59
|
+
the user's true forward allele can coincidentally equal a different
|
|
60
|
+
alt at the same position, returning an allele the user does not
|
|
61
|
+
carry. The downstream CADD enrichment path then stamps a wrong-
|
|
62
|
+
allele score onto the annotation. The complement fallback is
|
|
63
|
+
removed: if the user allele is not in ``{ref, alt}``, return None
|
|
64
|
+
and let the caller skip enrichment rather than risk a coincidental
|
|
65
|
+
match. (ADR-0010 already defers proper strand handling; this makes
|
|
66
|
+
the deferral explicit at the resolution layer.)
|
|
67
|
+
|
|
68
|
+
Only operates on single-base alleles. Multi-base alleles (indels)
|
|
69
|
+
pass through as-is — array indels are rare and not minus-strand
|
|
70
|
+
reported.
|
|
71
|
+
"""
|
|
72
|
+
if len(user_allele) != 1:
|
|
73
|
+
return user_allele
|
|
74
|
+
if user_allele in (gnomad_ref, gnomad_alt):
|
|
75
|
+
return user_allele
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def strand_aware_carrier_match(
|
|
80
|
+
variant_ref: str | None,
|
|
81
|
+
allele1: str,
|
|
82
|
+
allele2: str,
|
|
83
|
+
source_ref: str,
|
|
84
|
+
source_alt: str,
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""Return True if the user carries ``source_alt`` directly or via strand-flip.
|
|
87
|
+
|
|
88
|
+
Multi-allelic safety (the v2.0.1 #18 audit): only fires strand-flip when the
|
|
89
|
+
``variant_ref`` context confirms the user is reverse-stranded relative to the
|
|
90
|
+
source's ref. Without that context — ``variant_ref`` is None, or it agrees
|
|
91
|
+
with ``source_ref`` (forward) — strand-flip never fires and the carrier
|
|
92
|
+
match falls back to direct membership only. Skips palindromic ``(ref, alt)``
|
|
93
|
+
pairs (A/T, C/G) where strand cannot be inferred from sequence alone
|
|
94
|
+
(ADR-0010 documents the limitation).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
variant_ref: User's claimed REF at the variant locus. ``None`` means
|
|
98
|
+
the parser couldn't supply one (array data prior to gnomAD-backed
|
|
99
|
+
population, or the source didn't carry a REF column).
|
|
100
|
+
allele1: First user allele (upper-cased at ``Variant`` construction).
|
|
101
|
+
allele2: Second user allele.
|
|
102
|
+
source_ref: REF from the annotator's source row (forward strand).
|
|
103
|
+
source_alt: ALT from the annotator's source row (forward strand).
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True when the user carries ``source_alt`` on either strand. False
|
|
107
|
+
otherwise — including the abstain cases (palindromic site, no
|
|
108
|
+
``variant_ref`` context with no direct match, ref disagreement that
|
|
109
|
+
isn't a clean complement).
|
|
110
|
+
"""
|
|
111
|
+
user = {allele1, allele2}
|
|
112
|
+
if source_alt in user:
|
|
113
|
+
return True
|
|
114
|
+
if variant_ref is None:
|
|
115
|
+
return False
|
|
116
|
+
if is_strand_ambiguous(source_ref, source_alt):
|
|
117
|
+
return False
|
|
118
|
+
if variant_ref == source_ref:
|
|
119
|
+
return False
|
|
120
|
+
if variant_ref == complement(source_ref):
|
|
121
|
+
return complement(source_alt) in user
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def strand_aware_genotype_match(
|
|
126
|
+
allele1: str,
|
|
127
|
+
allele2: str,
|
|
128
|
+
source_geno: str,
|
|
129
|
+
variant_ref: str | None,
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""Per-genotype carrier match with strand-flip support for ClinPGx / SNPedia.
|
|
132
|
+
|
|
133
|
+
PharmGKB / ClinPGx and SNPedia store the user's full diploid (sorted SNV
|
|
134
|
+
pair like ``"AG"``) rather than per-allele rows. Direct equality remains
|
|
135
|
+
the primary match. Strand-flip fires only when ``variant_ref`` confirms
|
|
136
|
+
reverse orientation against the source diploid AND the source diploid is
|
|
137
|
+
not palindromic (A/T or C/G).
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
allele1: First user allele (upper-cased single base for SNVs).
|
|
141
|
+
allele2: Second user allele.
|
|
142
|
+
source_geno: Source row's sorted diploid string (e.g. ``"AG"``).
|
|
143
|
+
variant_ref: User's claimed REF at this locus. ``None`` means no
|
|
144
|
+
reference context; strand-flip is skipped (direct match only).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True when the user matches ``source_geno`` directly or via a
|
|
148
|
+
complement reading. False otherwise.
|
|
149
|
+
"""
|
|
150
|
+
if len(allele1) != 1 or len(allele2) != 1 or len(source_geno) != 2:
|
|
151
|
+
return False
|
|
152
|
+
user_normalized = "".join(sorted((allele1, allele2)))
|
|
153
|
+
if user_normalized == source_geno:
|
|
154
|
+
return True
|
|
155
|
+
if variant_ref is None:
|
|
156
|
+
return False
|
|
157
|
+
user_complement_pair = sorted((complement(allele1), complement(allele2)))
|
|
158
|
+
if "".join(user_complement_pair) != source_geno:
|
|
159
|
+
return False
|
|
160
|
+
if variant_ref in source_geno:
|
|
161
|
+
return False
|
|
162
|
+
if complement(variant_ref) not in source_geno:
|
|
163
|
+
return False
|
|
164
|
+
distinct = set(source_geno)
|
|
165
|
+
return distinct != {"A", "T"} and distinct != {"C", "G"}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def derive_alt_from_diploid(ref: str | None, allele1: str, allele2: str) -> str:
|
|
169
|
+
"""Derive the user's carried alt from a matched diploid given a known REF.
|
|
170
|
+
|
|
171
|
+
For per-genotype annotators (SNPedia, ClinPGx) the matched row describes
|
|
172
|
+
the user's full diploid pair; the alt the user carries is whichever side
|
|
173
|
+
of the pair does not equal REF. Returns the empty string when alt cannot
|
|
174
|
+
be cleanly identified:
|
|
175
|
+
|
|
176
|
+
- ``ref`` is None — no reference context yet (e.g., array data prior to
|
|
177
|
+
strand-aware ref population, ADR-0035 PR 4).
|
|
178
|
+
- Both alleles equal ref — hom-ref; should be filtered earlier
|
|
179
|
+
(ADR-0023 for ClinPGx; SNPedia rarely matches hom-ref) but defensive.
|
|
180
|
+
- Neither allele equals ref — multi-allelic het where the matched row's
|
|
181
|
+
alleles disagree with the reference (rare for SNPs); conservatively
|
|
182
|
+
skip rather than guess.
|
|
183
|
+
|
|
184
|
+
Conservative-on-uncertainty mirrors the v2.0.1 #23 suppress-half: when
|
|
185
|
+
the alt can't be cleanly identified, return "" rather than a guess that
|
|
186
|
+
could stamp the wrong allele's gnomAD / AlphaMissense / CADD value.
|
|
187
|
+
"""
|
|
188
|
+
if ref is None:
|
|
189
|
+
return ""
|
|
190
|
+
if allele1 == ref and allele2 == ref:
|
|
191
|
+
return ""
|
|
192
|
+
if allele1 == ref:
|
|
193
|
+
return allele2
|
|
194
|
+
if allele2 == ref:
|
|
195
|
+
return allele1
|
|
196
|
+
if allele1 == allele2:
|
|
197
|
+
return allele1
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def is_strand_ambiguous(ref: str, alt: str) -> bool:
|
|
202
|
+
"""True if (ref, alt) is an A/T or C/G pair — strand cannot be inferred.
|
|
203
|
+
|
|
204
|
+
Multi-base indels and any allele containing a no-call or unknown letter
|
|
205
|
+
are reported as not ambiguous (they have other ways to disambiguate).
|
|
206
|
+
"""
|
|
207
|
+
if len(ref) != 1 or len(alt) != 1:
|
|
208
|
+
return False
|
|
209
|
+
if ref not in _COMPLEMENT or alt not in _COMPLEMENT:
|
|
210
|
+
return False
|
|
211
|
+
return frozenset({ref, alt}) in _AMBIGUOUS_PAIRS
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
|
|
5
5
|
Author: Allelix
|
|
6
6
|
Maintainer-email: dial481 <dial481@users.noreply.github.com>
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "allelix"
|
|
7
|
-
version = "2.0
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -38,6 +38,33 @@ class TestVariant:
|
|
|
38
38
|
v = Variant("rs1", "1", 100, "A", "A", build="GRCh38")
|
|
39
39
|
assert v.build == "GRCh38"
|
|
40
40
|
|
|
41
|
+
def test_ref_defaults_to_none(self):
|
|
42
|
+
"""ADR-0035: Variant.ref is optional; array parsers leave it unset."""
|
|
43
|
+
v = Variant("rs1", "1", 100, "A", "A")
|
|
44
|
+
assert v.ref is None
|
|
45
|
+
|
|
46
|
+
def test_ref_explicit(self):
|
|
47
|
+
"""ADR-0035: VCF parsers populate ref from the REF column."""
|
|
48
|
+
v = Variant("rs1", "1", 100, "A", "G", ref="A")
|
|
49
|
+
assert v.ref == "A"
|
|
50
|
+
|
|
51
|
+
def test_ref_lowercase_normalized_to_uppercase(self):
|
|
52
|
+
"""ADR-0035 + GH #14: soft-masked references emit lowercase REF.
|
|
53
|
+
|
|
54
|
+
Without normalization the strand-aware carrier match (PR 4) would
|
|
55
|
+
compare a lowercase user ref against uppercase gnomAD / annotator
|
|
56
|
+
alleles and silently mismatch — exactly the #14 failure mode for
|
|
57
|
+
the new field. Normalize at the model boundary so the invariant
|
|
58
|
+
is impossible to violate downstream.
|
|
59
|
+
"""
|
|
60
|
+
v = Variant("rs1", "1", 100, "A", "G", ref="a")
|
|
61
|
+
assert v.ref == "A"
|
|
62
|
+
|
|
63
|
+
def test_ref_lowercase_indel_normalized(self):
|
|
64
|
+
"""Multi-base ref (indel locus) normalized in place."""
|
|
65
|
+
v = Variant("rs1", "7", 117199644, "CTT", "C", ref="cttg")
|
|
66
|
+
assert v.ref == "CTTG"
|
|
67
|
+
|
|
41
68
|
def test_indel_genotype(self):
|
|
42
69
|
v = Variant("rs1", "7", 117199644, "CTT", "C")
|
|
43
70
|
assert v.is_heterozygous
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
# Copyright (C) 2026 Allelix
|
|
3
|
-
"""Strand flipping, complement logic, and ambiguous-SNP detection.
|
|
4
|
-
|
|
5
|
-
A SNP read on the reverse strand has its alleles complemented (A↔T, C↔G).
|
|
6
|
-
Two databases reporting the "same" variant on opposite strands will list
|
|
7
|
-
opposite allele letters. For most SNPs this is unambiguous and reversible.
|
|
8
|
-
For A/T and C/G SNPs (palindromic), the complement equals the alternative —
|
|
9
|
-
so a strand-flip is undetectable from sequence alone and is best handled by
|
|
10
|
-
extra information (allele frequency, surrounding context).
|
|
11
|
-
|
|
12
|
-
ADR-0010 documents the design.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
from allelix.models import NO_CALL_MARKER
|
|
18
|
-
|
|
19
|
-
_COMPLEMENT: dict[str, str] = {"A": "T", "T": "A", "C": "G", "G": "C"}
|
|
20
|
-
|
|
21
|
-
# A/T and C/G SNPs are palindromic; their complement equals the alternative,
|
|
22
|
-
# so strand orientation cannot be inferred from the alleles alone.
|
|
23
|
-
_AMBIGUOUS_PAIRS: frozenset[frozenset[str]] = frozenset(
|
|
24
|
-
{frozenset({"A", "T"}), frozenset({"C", "G"})}
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def complement(allele: str) -> str:
|
|
29
|
-
"""Return the reverse-complement of a single allele string.
|
|
30
|
-
|
|
31
|
-
A → T, T → A, C → G, G → C. The no-call marker `-` and any unrecognized
|
|
32
|
-
character are returned unchanged. Handles indels (multi-base alleles) by
|
|
33
|
-
complementing each base in reverse order.
|
|
34
|
-
"""
|
|
35
|
-
if allele == NO_CALL_MARKER or not allele:
|
|
36
|
-
return allele
|
|
37
|
-
if len(allele) == 1:
|
|
38
|
-
return _COMPLEMENT.get(allele, allele)
|
|
39
|
-
return "".join(_COMPLEMENT.get(b, b) for b in reversed(allele))
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def flip_genotype(allele1: str, allele2: str) -> tuple[str, str]:
|
|
43
|
-
"""Return both alleles complemented (the reverse-strand reading)."""
|
|
44
|
-
return complement(allele1), complement(allele2)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def resolve_strand(user_allele: str, gnomad_ref: str, gnomad_alt: str) -> str | None:
|
|
48
|
-
"""Return reference-forward allele, or None if not directly present.
|
|
49
|
-
|
|
50
|
-
Maps an array-reported allele to its reference-forward equivalent
|
|
51
|
-
using gnomAD's ref/alt as the ground truth. If the user allele
|
|
52
|
-
matches ref or alt directly, it's already forward and is returned.
|
|
53
|
-
|
|
54
|
-
GH #18: previously, when the user allele did NOT directly match
|
|
55
|
-
ref or alt, this function fell back to a complement check
|
|
56
|
-
(``complement(user_allele) ∈ {ref, alt}`` → return the complement)
|
|
57
|
-
on the assumption that the array was read on the minus strand. At
|
|
58
|
-
multi-allelic sites that fallback is unsound — the complement of
|
|
59
|
-
the user's true forward allele can coincidentally equal a different
|
|
60
|
-
alt at the same position, returning an allele the user does not
|
|
61
|
-
carry. The downstream CADD enrichment path then stamps a wrong-
|
|
62
|
-
allele score onto the annotation. The complement fallback is
|
|
63
|
-
removed: if the user allele is not in ``{ref, alt}``, return None
|
|
64
|
-
and let the caller skip enrichment rather than risk a coincidental
|
|
65
|
-
match. (ADR-0010 already defers proper strand handling; this makes
|
|
66
|
-
the deferral explicit at the resolution layer.)
|
|
67
|
-
|
|
68
|
-
Only operates on single-base alleles. Multi-base alleles (indels)
|
|
69
|
-
pass through as-is — array indels are rare and not minus-strand
|
|
70
|
-
reported.
|
|
71
|
-
"""
|
|
72
|
-
if len(user_allele) != 1:
|
|
73
|
-
return user_allele
|
|
74
|
-
if user_allele in (gnomad_ref, gnomad_alt):
|
|
75
|
-
return user_allele
|
|
76
|
-
return None
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def is_strand_ambiguous(ref: str, alt: str) -> bool:
|
|
80
|
-
"""True if (ref, alt) is an A/T or C/G pair — strand cannot be inferred.
|
|
81
|
-
|
|
82
|
-
Multi-base indels and any allele containing a no-call or unknown letter
|
|
83
|
-
are reported as not ambiguous (they have other ways to disambiguate).
|
|
84
|
-
"""
|
|
85
|
-
if len(ref) != 1 or len(alt) != 1:
|
|
86
|
-
return False
|
|
87
|
-
if ref not in _COMPLEMENT or alt not in _COMPLEMENT:
|
|
88
|
-
return False
|
|
89
|
-
return frozenset({ref, alt}) in _AMBIGUOUS_PAIRS
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|