allelign 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.
allelign/alleles.py ADDED
@@ -0,0 +1,90 @@
1
+ """Allele and chromosome primitives shared by every module.
2
+
3
+ The rules here decide whether two records describe the same variant and which
4
+ way round its effect points, so they are kept small, explicit and in one place.
5
+ Ported unchanged from ldpred3's harmonisation module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = ["normalize_chrom", "reverse_complement", "is_ambiguous",
11
+ "allele_orientation", "CHROM_ALIASES", "AUTOSOMES"]
12
+
13
+ _COMP = {"A": "T", "T": "A", "C": "G", "G": "C"}
14
+
15
+ #: PLINK numeric sex/mitochondrial codes and spellings -> canonical labels.
16
+ #: ``25`` (PLINK's pseudoautosomal code) and ``XY`` both map to ``X``: PAR
17
+ #: variants sit on X coordinates in PLINK filesets while GWAS summary
18
+ #: statistics label the same loci X, and matching is by (normalised chrom,
19
+ #: pos), so a separate XY label left every PAR variant silently unmatched.
20
+ CHROM_ALIASES = {"23": "X", "24": "Y", "25": "X", "26": "MT", "M": "MT",
21
+ "XY": "X"}
22
+
23
+ AUTOSOMES = frozenset(str(i) for i in range(1, 23))
24
+
25
+ _PALINDROMIC = frozenset((("A", "T"), ("T", "A"), ("C", "G"), ("G", "C")))
26
+
27
+
28
+ def normalize_chrom(label):
29
+ """Canonical chromosome label.
30
+
31
+ Drops a ``chr`` prefix (any case) and zero padding (``01`` -> ``1``), and
32
+ maps sex/mitochondrial codes (``23``/``25``/``XY`` -> ``X``, ``24`` -> ``Y``,
33
+ ``26``/``M`` -> ``MT``). A ``chr1``/``1`` or ``X``/``23`` labelling clash is
34
+ one of the commonest reasons a real-data run silently matches nothing.
35
+ Sumstats that label PAR loci ``Y`` are not supported.
36
+ """
37
+ s = str(label).strip()
38
+ if s[:3].lower() == "chr":
39
+ s = s[3:]
40
+ s = s.upper()
41
+ if s.isdecimal():
42
+ s = str(int(s))
43
+ return CHROM_ALIASES.get(s, s)
44
+
45
+
46
+ def reverse_complement(allele):
47
+ """Reverse complement of a (single- or multi-base) allele; ``None`` if not ACGT."""
48
+ try:
49
+ return "".join(_COMP[b] for b in reversed(allele))
50
+ except KeyError:
51
+ return None
52
+
53
+
54
+ def is_ambiguous(ea, oa):
55
+ """True when a strand change exchanges the two alleles.
56
+
57
+ Such a pair cannot be oriented from alleles alone, because changing strand
58
+ also changes the effect sign. The single-base A/T and C/G case is the
59
+ common one; the general rule also catches multi-base pairs such as an
60
+ indel ``AA``/``TT``. Alleles that are individually self reverse
61
+ complementary (``AT``/``CG``) are safe: a strand change preserves their
62
+ order and therefore the sign. Inputs are expected upper-cased.
63
+ """
64
+ if (ea, oa) in _PALINDROMIC:
65
+ return True
66
+ if len(ea) == 1 and len(oa) == 1:
67
+ return False
68
+ cea, coa = reverse_complement(ea), reverse_complement(oa)
69
+ return cea is not None and coa is not None and cea == oa and coa == ea
70
+
71
+
72
+ def allele_orientation(ea, oa, a1, a2):
73
+ """How ``(ea, oa)`` maps onto ``(a1, a2)``.
74
+
75
+ Returns ``(flip, strand)``: ``flip`` is True when the effect must be
76
+ negated to count ``a1``, ``strand`` is True when the match needed the
77
+ reverse complement. ``(None, False)`` means the allele pairs disagree.
78
+ Inputs are expected upper-cased.
79
+ """
80
+ if ea == a1 and oa == a2:
81
+ return False, False
82
+ if ea == a2 and oa == a1:
83
+ return True, False
84
+ cea, coa = reverse_complement(ea), reverse_complement(oa)
85
+ if cea is not None and coa is not None:
86
+ if cea == a1 and coa == a2:
87
+ return False, True
88
+ if cea == a2 and coa == a1:
89
+ return True, True
90
+ return None, False
allelign/build.py ADDED
@@ -0,0 +1,57 @@
1
+ """Genome-build labels and the declared-build check.
2
+
3
+ A build mismatch does not look like an error: a GRCh38 file against a GRCh37
4
+ reference keeps about 1% of variants by coordinate and still yields numbers.
5
+ Declaring both builds turns that into a one-line refusal naming the cause.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = ["normalize_build", "check_build", "BuildMismatchError", "BUILDS"]
11
+
12
+ BUILDS = ("GRCh37", "GRCh38")
13
+
14
+ _ALIASES = {
15
+ "grch37": "GRCh37", "hg19": "GRCh37", "b37": "GRCh37", "build37": "GRCh37",
16
+ "37": "GRCh37", "hs37d5": "GRCh37",
17
+ "grch38": "GRCh38", "hg38": "GRCh38", "b38": "GRCh38", "build38": "GRCh38",
18
+ "38": "GRCh38",
19
+ }
20
+
21
+
22
+ class BuildMismatchError(ValueError):
23
+ """Two inputs are declared on different genome builds."""
24
+
25
+
26
+ def normalize_build(value, *, name="genome build"):
27
+ """``'GRCh37'`` or ``'GRCh38'`` for any common spelling; ``None`` stays ``None``."""
28
+ if value is None:
29
+ return None
30
+ key = str(value).strip().lower()
31
+ try:
32
+ return _ALIASES[key]
33
+ except KeyError:
34
+ raise ValueError(f"unknown {name} {value!r}; use GRCh37 (hg19, b37) "
35
+ "or GRCh38 (hg38, b38)") from None
36
+
37
+
38
+ def check_build(declared, reference, *, what="summary statistics",
39
+ against="the reference"):
40
+ """Refuse inputs declared on different builds.
41
+
42
+ Returns ``{"declared", "reference", "status"}`` with status ``match`` or
43
+ ``unchecked`` (either side unknown), and raises
44
+ :class:`BuildMismatchError` on a disagreement, naming the remedies.
45
+ """
46
+ declared = normalize_build(declared, name=f"{what} build")
47
+ reference = normalize_build(reference, name="reference build")
48
+ if declared is None or reference is None:
49
+ return {"declared": declared, "reference": reference,
50
+ "status": "unchecked"}
51
+ if declared != reference:
52
+ raise BuildMismatchError(
53
+ f"{what} are declared {declared} but {against} is {reference}. "
54
+ f"Lift the statistics over to {reference} (UCSC liftOver, CrossMap "
55
+ "or bcftools +liftover, keeping the chain and failed mappings), or "
56
+ "re-anchor on identifiers (allelign.reanchor_on_identifier).")
57
+ return {"declared": declared, "reference": reference, "status": "match"}
allelign/catalog.py ADDED
@@ -0,0 +1,88 @@
1
+ """Header rules for streamed GWAS Catalog normalisation.
2
+
3
+ This deposit profile preserves the Catalog consumers' column priorities and
4
+ output names. It is deliberately narrower than the general sumstats reader:
5
+ ``n`` here records reported sample size, not an inferred effective N; the
6
+ consumer must apply its study-specific sample-size policy.
7
+ """
8
+
9
+ __all__ = ["resolve_gwas_catalog_columns"]
10
+
11
+ _ALIASES = {
12
+ "rsid": ("hm_rsid", "rsid", "rs_id", "variant_id"),
13
+ "chrom": ("hm_chrom", "chromosome", "chr"),
14
+ "pos": ("hm_pos", "base_pair_location", "position"),
15
+ "effect_allele": ("hm_effect_allele", "effect_allele"),
16
+ "other_allele": ("hm_other_allele", "other_allele"),
17
+ "beta": ("hm_beta", "beta"),
18
+ "se": ("standard_error", "se", "standarderror"),
19
+ "eaf": ("hm_effect_allele_frequency", "effect_allele_frequency", "eaf"),
20
+ "pval": ("p_value", "pvalue", "p"),
21
+ "n": ("n", "sample_size", "n_total"),
22
+ }
23
+ _ODDS = ("hm_odds_ratio", "odds_ratio")
24
+ # A z-score with a standard error IS an effect: beta = z * se, sign included.
25
+ _ZSCORE = ("hm_z_score", "z_score", "zscore", "z")
26
+ _REQUIRED = ("rsid", "effect_allele", "other_allele", "se")
27
+
28
+ def resolve_gwas_catalog_columns(fieldnames, extra_aliases=None):
29
+ """Map normalised names onto whichever harmonised layout this file uses.
30
+
31
+ ``extra_aliases`` adds provider-scoped source names per target, tried
32
+ before the Catalog aliases. Returns ``(mapping, odds, zscore)`` using
33
+ the original header spellings; the latter two columns supply per-row
34
+ fallback effects. Alleles, effects (including fallbacks) and frequency
35
+ come from one orientation: harmonised when a complete set is available,
36
+ submitted otherwise. Provider allele/effect aliases choose their own
37
+ orientation first. This is header-only: consumers keep their streaming
38
+ row filters and their sample-size provenance.
39
+ """
40
+ have = {}
41
+ for field in (fieldnames or []):
42
+ have.setdefault(field.strip().lower(), field.strip())
43
+ aliases = dict(_ALIASES)
44
+ for target, names in (extra_aliases or {}).items():
45
+ # Provider names FIRST (only providers pass extras), so a generic
46
+ # column (``beta`` beside ``all_inv_var_meta_beta``) cannot override
47
+ # the explicit mapping.
48
+ aliases[target] = tuple(names) + tuple(aliases.get(target, ()))
49
+ mapping = {}
50
+ for target, names in aliases.items():
51
+ for alias in names:
52
+ if alias in have:
53
+ mapping[target] = have[alias]
54
+ break
55
+ # A finite submitted beta must not override a harmonised OR, nor may a
56
+ # row with missing hm_beta fall back to a submitted OR or z-score.
57
+ directional = ("effect_allele", "other_allele", "beta", "eaf")
58
+ provider_modes = {
59
+ mapping[target].lower().startswith("hm_")
60
+ for target in directional[:3]
61
+ if target in mapping and mapping[target].lower()
62
+ in (extra_aliases or {}).get(target, ())}
63
+ if len(provider_modes) > 1:
64
+ raise ValueError("provider aliases mix submitted and harmonised effects/alleles")
65
+ modes = (next(iter(provider_modes)),) if provider_modes else (True, False)
66
+ chosen, odds, zscore = {}, None, None
67
+ for harmonised in modes:
68
+ def pick(names):
69
+ return next((have[name] for name in names if name in have
70
+ and name.startswith("hm_") == harmonised), None)
71
+
72
+ candidate = {target: pick(aliases[target]) for target in directional}
73
+ candidate_odds, candidate_z = pick(_ODDS), pick(_ZSCORE)
74
+ if (candidate["effect_allele"] and candidate["other_allele"]
75
+ and (candidate["beta"] or candidate_odds or candidate_z)):
76
+ chosen = {target: name for target, name in candidate.items() if name}
77
+ odds, zscore = candidate_odds, candidate_z
78
+ break
79
+ for target in directional:
80
+ mapping.pop(target, None)
81
+ mapping.update(chosen)
82
+ missing = [c for c in _REQUIRED if c not in mapping]
83
+ if "beta" not in mapping and odds is None and zscore is None:
84
+ missing.append("beta/odds_ratio/z_score")
85
+ if missing:
86
+ raise ValueError(f"unmappable columns {missing}; header={fieldnames}")
87
+ return mapping, odds, zscore
88
+
allelign/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ """Command-line interface: ``allelign inspect | clean | align | pgs-export | download``.
2
+
3
+ ``align --ld-screen`` adds the LD-consistency screen, with LD computed from the
4
+ reference fileset's own genotypes (``.bed``/``.fam`` beside the ``.bim``).
5
+ ``download`` fetches a published summary-statistics or scoring file by its
6
+ source reference (GWAS Catalog accession, ``FINNGEN:R11:I9_AF`` and the
7
+ other providers in :mod:`allelign.download`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ import numpy as np
17
+
18
+ from . import __version__
19
+ from . import status as _st
20
+ from ._common import atomic_text
21
+
22
+
23
+ def _columns(pairs):
24
+ out = {}
25
+ for item in pairs or []:
26
+ if "=" not in item:
27
+ raise SystemExit(f"allelign: --column expects FIELD=COLUMN, got {item!r}")
28
+ field, col = item.split("=", 1)
29
+ out[field.strip()] = col.strip()
30
+ return out
31
+
32
+
33
+ def _read_kwargs(args):
34
+ kwargs = _columns(args.column)
35
+ if args.n_eff is not None:
36
+ kwargs["n_eff"] = args.n_eff
37
+ if args.n_cases is not None or args.n_controls is not None:
38
+ kwargs["n_cases"] = args.n_cases
39
+ kwargs["n_controls"] = args.n_controls
40
+ return kwargs
41
+
42
+
43
+ def _read(args):
44
+ from .sumstats import read_sumstats
45
+ return read_sumstats(args.sumstats, **_read_kwargs(args))
46
+
47
+
48
+ def _qc(args, ss, status):
49
+ from .qc import qc_sumstats
50
+ if args.no_qc:
51
+ return np.ones(len(ss), dtype=bool), {"skipped": True}
52
+ return qc_sumstats(ss, min_n_ratio=args.min_n_ratio, min_maf=args.min_maf,
53
+ min_info=args.min_info, max_chisq=args.max_chisq,
54
+ drop_duplicates=not args.keep_duplicates, status=status)
55
+
56
+
57
+ def _write_log(out, log):
58
+ with atomic_text(f"{out}.log.json") as fh:
59
+ json.dump(log, fh, indent=2, sort_keys=True, default=str)
60
+ fh.write("\n")
61
+
62
+
63
+ def _write_status(path, ss, status):
64
+ _st.write_status_table(path, id=ss.id, chrom=ss.chrom, pos=ss.pos,
65
+ ea=ss.ea, oa=ss.oa, codes=status)
66
+
67
+
68
+ def cmd_inspect(args):
69
+ from .sumstats import inspect_columns
70
+ info = inspect_columns(args.sumstats, **_read_kwargs(args))
71
+ print(json.dumps(info, indent=2))
72
+ return 1 if info["missing"] else 0
73
+
74
+
75
+ def cmd_clean(args):
76
+ ss = _read(args)
77
+ status = _st.new_status(len(ss))
78
+ keep, qc_log = _qc(args, ss, status)
79
+ out = ss.subset(keep)
80
+ out.write(args.out)
81
+ if args.status_out:
82
+ _write_status(args.status_out, ss, status)
83
+ _write_log(args.out, {"allelign": __version__, "parse": ss.parse_log,
84
+ "qc": qc_log, "status": _st.status_counts(status)})
85
+ print(f"allelign: kept {len(out):,} of {len(ss):,} rows -> {args.out}",
86
+ file=sys.stderr)
87
+ return 0
88
+
89
+
90
+ def cmd_align(args):
91
+ from .build import BuildMismatchError, check_build, normalize_build
92
+ from .align import (coverage_message, harmonize, reanchor_on_identifier,
93
+ reference_coverage)
94
+ from .qc import gwas_af_on_counted_allele
95
+ from .sumstats import Sumstats
96
+ from .variants import read_variants
97
+
98
+ try:
99
+ build = check_build(args.sumstats_build, args.reference_build)
100
+ except BuildMismatchError:
101
+ if not args.reanchor_on_identifier:
102
+ raise
103
+ build = {"declared": normalize_build(args.sumstats_build),
104
+ "reference": normalize_build(args.reference_build),
105
+ "status": "reanchored"}
106
+ build["output"] = (build["reference"] if args.reanchor_on_identifier
107
+ else build["reference"] or build["declared"])
108
+ ref = read_variants(args.reference).freeze()
109
+ ss = _read(args)
110
+ status = _st.new_status(len(ss))
111
+ keep, qc_log = _qc(args, ss, status)
112
+ rows = np.flatnonzero(keep)
113
+ sub = ss.subset(keep)
114
+ log = {"allelign": __version__, "parse": ss.parse_log, "qc": qc_log,
115
+ "build": build}
116
+ if args.reanchor_on_identifier:
117
+ sub, log["reanchor"], rkeep = reanchor_on_identifier(
118
+ sub, ref, return_keep=True)
119
+ status[rows[~rkeep]] = _st.REANCHOR_DROPPED
120
+ rows = rows[rkeep]
121
+ sub_status = _st.new_status(len(sub)) if args.status_out else None
122
+ h = harmonize(sub, ref, drop_ambiguous=not args.keep_ambiguous,
123
+ status=sub_status)
124
+ if sub_status is not None:
125
+ # Per-row harmonisation reasons (the row loop) only when a status
126
+ # table was asked for; otherwise the harmonize log has the counts.
127
+ status[rows] = np.where(status[rows] == 0, sub_status, status[rows])
128
+ log["harmonize"] = h.log
129
+ coverage = reference_coverage(sub, ref, h)
130
+ log["coverage"] = coverage
131
+ message = coverage_message(coverage, label=str(args.sumstats))
132
+ if message:
133
+ print(f"allelign: {message}", file=sys.stderr)
134
+ passed = np.ones(len(h), dtype=bool)
135
+ if args.ld_screen:
136
+ passed, log["ld_screen"] = _ld_screen(args, ref, h)
137
+ status[rows[h.src_index[~passed]]] = _st.LD_INCONSISTENT
138
+ vi, src = h.var_index[passed], h.src_index[passed]
139
+ aligned = Sumstats(
140
+ id=np.asarray(ref.id, dtype=object)[vi],
141
+ chrom=np.asarray(ref.chrom, dtype=object)[vi],
142
+ pos=np.asarray(ref.pos, dtype=np.int64)[vi],
143
+ ea=np.asarray(ref.a1, dtype=object)[vi],
144
+ oa=np.asarray(ref.a2, dtype=object)[vi],
145
+ beta=h.beta[passed], se=h.se[passed], n_eff=h.n_eff[passed],
146
+ eaf=gwas_af_on_counted_allele(sub.eaf, src, h.flipped[passed]),
147
+ info=np.asarray(sub.info, dtype=float)[src])
148
+ aligned.write(args.out)
149
+ if args.status_out:
150
+ _write_status(args.status_out, ss, status)
151
+ log["status"] = _st.status_counts(status)
152
+ else:
153
+ log["status_before_harmonize"] = _st.status_counts(status)
154
+ _write_log(args.out, log)
155
+ print(f"allelign: aligned {len(aligned):,} of {len(ss):,} rows to "
156
+ f"{len(ref):,} reference variants -> {args.out}", file=sys.stderr)
157
+ return 0
158
+
159
+
160
+ def _ld_screen(args, ref, h):
161
+ """Keep-mask over the aligned rows, and its log, from the reference genotypes."""
162
+ from .genotypes import (Bed, ld_consistency_screen_genotypes,
163
+ read_sample_list)
164
+ if not str(args.reference).endswith(".bim"):
165
+ raise ValueError("--ld-screen needs a PLINK 1 --reference (.bim with "
166
+ ".bed and .fam beside it)")
167
+ samples = read_sample_list(args.ld_keep) if args.ld_keep else None
168
+ bed = Bed(args.reference, variants=ref, samples=samples)
169
+ options = {"rounds": args.ld_rounds, "threshold": args.ld_threshold}
170
+ keep, stats = ld_consistency_screen_genotypes(
171
+ bed, h.var_index, h.beta / h.se, max_block=args.ld_block_size,
172
+ seed=args.seed, return_stats=True, **options)
173
+ stats.update(options, n_reference_samples=bed.n_samples,
174
+ max_block=args.ld_block_size, seed=args.seed)
175
+ print(f"allelign: LD screen dropped {stats['n_dropped']:,} of "
176
+ f"{stats['n_tested']:,} tested variants ({bed.n_samples:,} "
177
+ "reference samples)", file=sys.stderr)
178
+ return keep, stats
179
+
180
+
181
+ def cmd_pgs_export(args):
182
+ from .weights import read_weights, write_pgs_scoring_file
183
+ wt = read_weights(args.weights, sd_source=args.sd_source)
184
+ write_pgs_scoring_file(args.out, wt, genome_build=args.genome_build,
185
+ pgs_id=args.pgs_id, trait_reported=args.trait)
186
+ print(f"allelign: wrote {len(wt):,} variants -> {args.out}", file=sys.stderr)
187
+ return 0
188
+
189
+
190
+ def cmd_download(args):
191
+ from .download import download_ref, read_keep_ids, resolve_ref
192
+ if args.dry_run:
193
+ plan = resolve_ref(args.ref, build=args.build)
194
+ print(json.dumps(plan, indent=2, sort_keys=True, default=str))
195
+ return 0
196
+ keep_ids = read_keep_ids(args.keep_ids) if args.keep_ids else None
197
+ record = download_ref(args.ref, args.out, build=args.build,
198
+ keep_ids=keep_ids, meta=not args.no_meta)
199
+ kept = f", kept {record['n_kept']:,} of {record['n_input']:,} rows" \
200
+ if "n_kept" in record else ""
201
+ print(f"allelign: downloaded {record['label']} -> {args.out} "
202
+ f"({record['n_bytes_written']:,} bytes{kept})", file=sys.stderr)
203
+ return 0
204
+
205
+
206
+ def _add_read_options(p):
207
+ p.add_argument("sumstats", help="summary-statistics file (.gz accepted)")
208
+ p.add_argument("--column", action="append", metavar="FIELD=COLUMN",
209
+ help="force a field to a column (name or 0-based index)")
210
+ p.add_argument("--n-eff", type=float, default=None,
211
+ help="constant effective N for every row")
212
+ p.add_argument("--n-cases", type=float, default=None)
213
+ p.add_argument("--n-controls", type=float, default=None)
214
+
215
+
216
+ def _add_qc_options(p):
217
+ p.add_argument("--no-qc", action="store_true", help="skip file-level QC")
218
+ p.add_argument("--min-n-ratio", type=float, default=0.7)
219
+ p.add_argument("--min-maf", type=float, default=0.01)
220
+ p.add_argument("--min-info", type=float, default=0.7)
221
+ p.add_argument("--max-chisq", type=float, default=None)
222
+ p.add_argument("--keep-duplicates", action="store_true")
223
+ p.add_argument("--status-out", default=None,
224
+ help="per-input-row status table (TSV, .gz accepted)")
225
+
226
+
227
+ def _ld_default(name):
228
+ from . import ldcheck
229
+ return getattr(ldcheck, name)
230
+
231
+
232
+ def build_parser():
233
+ ap = argparse.ArgumentParser(
234
+ prog="allelign",
235
+ description="Read, align and QC GWAS summary statistics and PGS weights.")
236
+ ap.add_argument("--version", action="version", version=f"allelign {__version__}")
237
+ sub = ap.add_subparsers(dest="command", required=True)
238
+
239
+ p = sub.add_parser("inspect", help="show the detected column mapping")
240
+ _add_read_options(p)
241
+ p.set_defaults(func=cmd_inspect)
242
+
243
+ p = sub.add_parser("clean", help="parse, QC and write the canonical table")
244
+ _add_read_options(p)
245
+ _add_qc_options(p)
246
+ p.add_argument("-o", "--out", required=True)
247
+ p.set_defaults(func=cmd_clean)
248
+
249
+ p = sub.add_parser("align", help="parse, QC and align to a .bim/.pvar")
250
+ _add_read_options(p)
251
+ _add_qc_options(p)
252
+ p.add_argument("--reference", required=True, help=".bim or .pvar file")
253
+ p.add_argument("-o", "--out", required=True)
254
+ p.add_argument("--keep-ambiguous", action="store_true",
255
+ help="keep strand-ambiguous (A/T, C/G) variants")
256
+ p.add_argument("--reanchor-on-identifier", action="store_true",
257
+ help="take coordinates from the reference's rsIDs")
258
+ p.add_argument("--sumstats-build", default=None)
259
+ p.add_argument("--reference-build", default=None)
260
+ g = p.add_argument_group(
261
+ "LD-consistency screen",
262
+ "drop variants whose z-score disagrees with its neighbours' given the "
263
+ "reference LD. A reference of a few hundred samples flags more "
264
+ "variants than one of thousands; see docs/guide.md.")
265
+ g.add_argument("--ld-screen", action="store_true",
266
+ help="screen with LD from the --reference .bed")
267
+ g.add_argument("--ld-keep", default=None, metavar="FILE",
268
+ help="reference samples to use for LD (FID IID or IID per "
269
+ "line), e.g. the one ancestry matching the GWAS")
270
+ g.add_argument("--ld-block-size", type=int, default=2000,
271
+ help="maximum variants per LD block (default 2000)")
272
+ g.add_argument("--ld-rounds", type=int, default=_ld_default("DEFAULT_ROUNDS"))
273
+ g.add_argument("--ld-threshold", type=float,
274
+ default=_ld_default("DEFAULT_THRESHOLD"),
275
+ help="chi-square cut for an outlier")
276
+ g.add_argument("--seed", type=int, default=0)
277
+ p.set_defaults(func=cmd_align)
278
+
279
+ p = sub.add_parser("pgs-export",
280
+ help="ldpred3 weights -> PGS Catalog scoring file")
281
+ p.add_argument("weights")
282
+ p.add_argument("-o", "--out", required=True)
283
+ p.add_argument("--genome-build", required=True, choices=("GRCh37", "GRCh38"))
284
+ p.add_argument("--pgs-id", default=None)
285
+ p.add_argument("--trait", default=None)
286
+ p.add_argument("--sd-source", choices=("empirical", "hwe"), default=None)
287
+ p.set_defaults(func=cmd_pgs_export)
288
+
289
+ p = sub.add_parser(
290
+ "download", help="fetch published sumstats or a PGS score by reference",
291
+ description="Resolve a source reference (GWAS Catalog GCST accession, "
292
+ "FINNGEN:R11:I9_AF, COVIDHGI:R7:C2, GBMI:ASTHMA:EUR, "
293
+ "EQTL:QTS000001:QTD000001:ENSG00000008128 or a PGS id) "
294
+ "and download it with a <out>.meta.json provenance "
295
+ "sidecar.")
296
+ p.add_argument("ref")
297
+ p.add_argument("-o", "--out", required=True)
298
+ p.add_argument("--build", choices=("GRCh37", "GRCh38"), default=None,
299
+ help="preferred harmonised build for a PGS score "
300
+ "(default GRCh37, then GRCh38, then the submitted "
301
+ "file)")
302
+ p.add_argument("--keep-ids", default=None, metavar="FILE",
303
+ help="rsID list, .bim or .pvar: stream only matching rows "
304
+ "(comma-joined rsids match on any part)")
305
+ p.add_argument("--no-meta", action="store_true",
306
+ help="do not write the <out>.meta.json sidecar")
307
+ p.add_argument("--dry-run", action="store_true",
308
+ help="resolve and print the plan without downloading")
309
+ p.set_defaults(func=cmd_download)
310
+ return ap
311
+
312
+
313
+ def main(argv=None):
314
+ args = build_parser().parse_args(argv)
315
+ try:
316
+ return args.func(args)
317
+ except (ValueError, OSError) as error:
318
+ print(f"allelign: error: {error}", file=sys.stderr)
319
+ return 2
320
+
321
+
322
+ if __name__ == "__main__": # pragma: no cover
323
+ sys.exit(main())