triohap 0.4.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.
triohap/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Research prototype: trio-aware regional haplotype matching."""
2
+ __version__ = "0.4.0"
triohap/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
triohap/catalogue.py ADDED
@@ -0,0 +1,297 @@
1
+ """Import an existing regional pangenome analysis without changing its files.
2
+
3
+ Full site-defined haplotypes and assembly membership are retained in JSON.
4
+ Only literal SNP records are exposed to the current SNP matching engine.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import csv
10
+ import hashlib
11
+ import json
12
+ from collections import Counter, defaultdict
13
+ from pathlib import Path
14
+
15
+ from .vcf import parse_region
16
+
17
+ SCHEMA = 'triohap.catalogue.v1'
18
+ BASES = frozenset('ACGT')
19
+
20
+
21
+ def _table(path):
22
+ with Path(path).open(newline='') as handle:
23
+ reader = csv.DictReader(handle, delimiter='\t')
24
+ fields = reader.fieldnames or []
25
+ if not fields or len(fields) != len(set(fields)):
26
+ raise ValueError(f'{path}: missing or duplicate TSV column names')
27
+ rows = list(reader)
28
+ if any(None in row or any(value is None for value in row.values()) for row in rows):
29
+ raise ValueError(f'{path}: wrong number of TSV columns')
30
+ return fields, rows
31
+
32
+
33
+ def _require(fields, required, filename):
34
+ missing = set(required) - set(fields)
35
+ if missing:
36
+ raise ValueError(f'{filename}: missing columns {sorted(missing)}')
37
+
38
+
39
+ def _index(token, site, where):
40
+ if token == '.':
41
+ return None
42
+ try:
43
+ index = int(token)
44
+ except (ValueError, TypeError) as exc:
45
+ raise ValueError(f'{where}: invalid allele index {token!r}') from exc
46
+ if index < 0 or index > len(site['alts']):
47
+ raise ValueError(f'{where}: allele index {index} outside REF/ALT dictionary')
48
+ return index
49
+
50
+
51
+ def _literal_snp(site):
52
+ return site['ref'] in BASES and all(a in BASES for a in site['alts'])
53
+
54
+
55
+ def _digest(value):
56
+ return hashlib.sha256(json.dumps(value, separators=(',', ':'), sort_keys=True).encode()).hexdigest()
57
+
58
+
59
+ def _audit(sites, haplotypes, unassigned):
60
+ snps = [i for i, site in enumerate(sites) if _literal_snp(site)]
61
+ projected = defaultdict(list)
62
+ for hap in haplotypes:
63
+ projected[tuple(hap['alleles'][i] for i in snps)].append(hap['id'])
64
+ groups = sorted((sorted(members) for members in projected.values()), key=lambda x: x[0])
65
+ return {
66
+ 'variant_sites': len(sites),
67
+ 'snp_sites': len(snps),
68
+ 'non_snp_sites': len(sites) - len(snps),
69
+ 'full_haplotype_classes': len(haplotypes),
70
+ 'assembly_haplotypes': sum(h['count'] for h in haplotypes),
71
+ 'unassigned_assembly_haplotypes': len(unassigned),
72
+ 'unassigned_members': unassigned,
73
+ 'snp_signature_classes': len(projected),
74
+ 'snp_equivalence_groups': groups,
75
+ 'snp_ambiguous_groups': [g for g in groups if len(g) > 1],
76
+ 'snp_sites_missing_in_any_class': sum(any(h['alleles'][i] is None for h in haplotypes) for i in snps),
77
+ 'note': 'SNP signatures describe the complete panel SNP projection, before patient missingness. '
78
+ 'Full classes can remain indistinguishable even with every panel SNP observed. '
79
+ 'Counts are assembly membership, not calibrated population priors.',
80
+ }
81
+
82
+
83
+ def import_catalogue(bundle_dir, tag='core', build='GRCh38'):
84
+ """Validate pipeline TSVs and preserve full haplotype identities.
85
+
86
+ The matrix is authoritative for multi-digit allele indices. A compact
87
+ display-only ``allele_string`` is never parsed.
88
+ """
89
+ bundle = Path(bundle_dir)
90
+ if not tag or any(c in tag for c in '/\\') or tag in ('.', '..'):
91
+ raise ValueError('tag must be a simple filename suffix')
92
+ table_dir = bundle / 'results' / 'tables'
93
+ filenames = {
94
+ 'sites': f'variant_sites.{tag}.tsv',
95
+ 'matrix': f'haplotype_matrix.{tag}.tsv',
96
+ 'assignments': f'haplotype_assignments.{tag}.tsv',
97
+ 'classes': f'haplotypes.{tag}.tsv',
98
+ }
99
+ loaded = {key: _table(table_dir / name) for key, name in filenames.items()}
100
+ fields, raw_sites = loaded['sites']
101
+ _require(fields, ['chrom', 'pos', 'ref', 'alt'], filenames['sites'])
102
+ if not raw_sites:
103
+ raise ValueError('The source panel has no variant sites')
104
+ sites, positions = [], set()
105
+ for row in raw_sites:
106
+ chrom, pos, ref = row['chrom'], int(row['pos']), row['ref'].upper()
107
+ if not chrom or pos < 1 or not ref or ref == '.':
108
+ raise ValueError('Invalid site CHROM/POS/REF')
109
+ if (chrom, pos) in positions:
110
+ raise ValueError(f'Duplicate site coordinate {chrom}:{pos}; reconcile records before import')
111
+ positions.add((chrom, pos))
112
+ alts = [] if row['alt'] == '.' else row['alt'].upper().split(',')
113
+ if any(not a for a in alts) or len(set([ref] + alts)) != len(alts) + 1:
114
+ raise ValueError(f'Invalid or duplicate allele dictionary at {chrom}:{pos}')
115
+ site = {'chrom': chrom, 'pos': pos, 'ref': ref, 'alts': alts,
116
+ 'kind': row.get('class', 'unspecified')}
117
+ site['is_literal_snp'] = _literal_snp(site)
118
+ sites.append(site)
119
+ if len({s['chrom'] for s in sites}) != 1:
120
+ raise ValueError('One catalogue must cover a single chromosome region')
121
+
122
+ matrix_header, matrix_rows = loaded['matrix']
123
+ expected_header = ['haplotype_id'] + [f"{s['chrom']}:{s['pos']}" for s in sites]
124
+ if matrix_header != expected_header:
125
+ raise ValueError('Haplotype matrix column order does not match variant_sites exactly')
126
+ matrix = {}
127
+ for row in matrix_rows:
128
+ name = row['haplotype_id']
129
+ if not name or name in matrix:
130
+ raise ValueError(f'Duplicate or empty matrix haplotype ID {name!r}')
131
+ matrix[name] = [_index(row[column], site, name + ':' + column)
132
+ for column, site in zip(matrix_header[1:], sites)]
133
+
134
+ fields, assignment_rows = loaded['assignments']
135
+ _require(fields, ['haplotype_id', 'haplotype_class'], filenames['assignments'])
136
+ assignments = {}
137
+ for row in assignment_rows:
138
+ member = row['haplotype_id']
139
+ if member in assignments:
140
+ raise ValueError(f'Duplicate assignment for {member}')
141
+ assignments[member] = row['haplotype_class']
142
+ if set(assignments) != set(matrix):
143
+ raise ValueError('Assignments must cover every matrix haplotype exactly once; '
144
+ f'missing={sorted(set(matrix)-set(assignments))[:5]}, '
145
+ f'extra={sorted(set(assignments)-set(matrix))[:5]}')
146
+
147
+ fields, class_rows = loaded['classes']
148
+ _require(fields, ['haplotype_class', 'count'], filenames['classes'])
149
+ class_ids = [r['haplotype_class'] for r in class_rows]
150
+ if any(not c or c in ('.', 'NA') for c in class_ids) or len(class_ids) != len(set(class_ids)):
151
+ raise ValueError('Class IDs must be nonempty and unique')
152
+ members_by_class = defaultdict(list)
153
+ unassigned = []
154
+ for member, cls in assignments.items():
155
+ if cls in ('', '.', 'NA'):
156
+ unassigned.append(member)
157
+ elif cls not in class_ids:
158
+ raise ValueError(f'Assignment references unknown class {cls!r}')
159
+ else:
160
+ members_by_class[cls].append(member)
161
+
162
+ haplotypes, seen_signatures = [], {}
163
+ for row in class_rows:
164
+ cls = row['haplotype_class']
165
+ members = sorted(members_by_class[cls])
166
+ count = int(row['count'])
167
+ if not members or count != len(members):
168
+ raise ValueError(f'{cls}: catalogue count {count} disagrees with {len(members)} assigned members')
169
+ indices = matrix[members[0]]
170
+ if any(matrix[m] != indices for m in members[1:]):
171
+ raise ValueError(f'{cls}: members do not share one full-site allele vector')
172
+ signature = tuple(indices)
173
+ if signature in seen_signatures:
174
+ raise ValueError(f'{cls} and {seen_signatures[signature]} duplicate a full-site class')
175
+ seen_signatures[signature] = cls
176
+ alleles = [None if index is None else ([site['ref']] + site['alts'])[index]
177
+ for index, site in zip(indices, sites)]
178
+ haplotypes.append({'id': cls, 'allele_indices': indices, 'alleles': alleles,
179
+ 'members': members, 'count': count,
180
+ 'signature_hash': _digest({'sites': sites, 'alleles': alleles})})
181
+ if not haplotypes:
182
+ raise ValueError('The source panel has no assigned haplotype classes')
183
+
184
+ summary_path = table_dir / f'summary.{tag}.json'
185
+ source_summary = {}
186
+ if summary_path.exists():
187
+ source_summary = json.loads(summary_path.read_text())
188
+ if source_summary.get('region'):
189
+ chrom, start, end = parse_region(source_summary['region'])
190
+ region_source = str(summary_path.relative_to(bundle))
191
+ else:
192
+ raise ValueError('Source summary is present but has no region')
193
+ else:
194
+ chrom = sites[0]['chrom']
195
+ start = min(s['pos'] for s in sites)
196
+ end = max(s['pos'] + len(s['ref']) - 1 for s in sites)
197
+ region_source = 'inferred_from_variant_extent; not the original window boundary'
198
+ if any(s['chrom'] != chrom or not (start <= s['pos'] <= end) for s in sites):
199
+ raise ValueError('Variant sites fall outside the declared catalogue region')
200
+ region = {'chrom': chrom, 'start': start, 'end': end,
201
+ 'coordinate_system': '1-based-inclusive', 'source': region_source}
202
+ source_files = [table_dir / f for f in filenames.values()]
203
+ if summary_path.exists():
204
+ source_files.append(summary_path)
205
+ source = {'bundle_name': bundle.name, 'tag': tag,
206
+ 'files': {str(p.relative_to(bundle)): {'sha256': hashlib.sha256(p.read_bytes()).hexdigest(),
207
+ 'size_bytes': p.stat().st_size}
208
+ for p in source_files},
209
+ 'build_note': 'Build supplied by caller; retained source coordinates are not lifted over.'}
210
+ if source_summary.get('vcf'):
211
+ source['upstream_vcf'] = source_summary['vcf']
212
+ return {'schema': SCHEMA, 'build': build, 'region': region, 'sites': sites,
213
+ 'haplotypes': haplotypes, 'source': source,
214
+ 'audit': _audit(sites, haplotypes, sorted(unassigned))}
215
+
216
+
217
+ def _validate(catalogue):
218
+ if catalogue.get('schema') != SCHEMA:
219
+ raise ValueError(f'Expected catalogue schema {SCHEMA}')
220
+ sites, haps = catalogue.get('sites', []), catalogue.get('haplotypes', [])
221
+ if not sites or not haps or len({h['id'] for h in haps}) != len(haps):
222
+ raise ValueError('Catalogue requires sites and unique haplotype IDs')
223
+ positions = [(s['chrom'], s['pos']) for s in sites]
224
+ if len(set(positions)) != len(positions):
225
+ raise ValueError('Catalogue has duplicate site coordinates')
226
+ for hap in haps:
227
+ if len(hap['alleles']) != len(sites) or len(hap['allele_indices']) != len(sites):
228
+ raise ValueError('Catalogue allele vectors have wrong dimensions')
229
+ for site, index, allele in zip(sites, hap['allele_indices'], hap['alleles']):
230
+ if index is None:
231
+ if allele is not None:
232
+ raise ValueError('Missing allele index must retain a missing allele')
233
+ elif (not isinstance(index, int) or isinstance(index, bool) or index < 0 or
234
+ index > len(site['alts']) or ([site['ref']] + site['alts'])[index] != allele):
235
+ raise ValueError('Catalogue allele index and sequence disagree')
236
+
237
+
238
+ def read_catalogue(path, region=None):
239
+ """Expose a saved catalogue's literal SNP projection to the matcher."""
240
+ catalogue = json.loads(Path(path).read_text())
241
+ _validate(catalogue)
242
+ requested = parse_region(region)
243
+ records, diagnostics = {}, []
244
+ for index, site in enumerate(catalogue['sites']):
245
+ chrom, pos, ref = site['chrom'], site['pos'], site['ref']
246
+ if requested and (chrom != requested[0] or not requested[1] <= pos <= requested[2]):
247
+ continue
248
+ if not _literal_snp(site):
249
+ diagnostics.append({'reason': 'catalogue_non_snp_not_scored', 'chrom': chrom, 'pos': pos,
250
+ 'ref': ref, 'alts': site['alts'],
251
+ 'detail': 'Full allele retained in catalogue; SNP matcher does not score this record.'})
252
+ continue
253
+ records[(chrom, pos, ref)] = {'chrom': chrom, 'pos': pos, 'ref': ref,
254
+ 'alleles': {h['id']: h['alleles'][index] for h in catalogue['haplotypes']}}
255
+ return {'haplotypes': [h['id'] for h in catalogue['haplotypes']], 'records': records,
256
+ 'diagnostics': diagnostics, 'source': str(path), 'references': [catalogue['build']],
257
+ 'contig_lengths': {}, 'catalogue': catalogue}
258
+
259
+
260
+ def write_catalogue(catalogue, outdir):
261
+ """Write the catalogue, its explicit SNP table, and audit metadata."""
262
+ _validate(catalogue)
263
+ outdir = Path(outdir)
264
+ if outdir.exists() and any(outdir.iterdir()):
265
+ raise ValueError('Output directory must be new or empty')
266
+ outdir.mkdir(parents=True, exist_ok=True)
267
+ (outdir / 'catalogue.json').write_text(json.dumps(catalogue, indent=2) + '\n')
268
+ (outdir / 'audit.json').write_text(json.dumps(catalogue['audit'], indent=2) + '\n')
269
+ with (outdir / 'panel.snps.tsv').open('w', newline='') as handle:
270
+ writer = csv.writer(handle, delimiter='\t')
271
+ writer.writerow(['CHROM', 'POS', 'REF'] + [h['id'] for h in catalogue['haplotypes']])
272
+ for i, site in enumerate(catalogue['sites']):
273
+ if _literal_snp(site):
274
+ writer.writerow([site['chrom'], site['pos'], site['ref']] +
275
+ [h['alleles'][i] if h['alleles'][i] is not None else '.'
276
+ for h in catalogue['haplotypes']])
277
+ return outdir
278
+
279
+
280
+ def main(argv=None):
281
+ parser = argparse.ArgumentParser(description='Import a regional pangenome analysis catalogue')
282
+ parser.add_argument('--bundle', required=True)
283
+ parser.add_argument('--tag', default='core')
284
+ parser.add_argument('--build', default='GRCh38')
285
+ parser.add_argument('--out', required=True)
286
+ args = parser.parse_args(argv)
287
+ try:
288
+ catalogue = import_catalogue(args.bundle, tag=args.tag, build=args.build)
289
+ write_catalogue(catalogue, args.out)
290
+ except (ValueError, OSError, KeyError) as exc:
291
+ parser.exit(2, f'triohap catalogue: {exc}\n')
292
+ print(json.dumps({'catalogue': str(Path(args.out) / 'catalogue.json'), 'audit': catalogue['audit']}, indent=2))
293
+ return 0
294
+
295
+
296
+ if __name__ == '__main__':
297
+ raise SystemExit(main())
@@ -0,0 +1,248 @@
1
+ """Draw the full regional catalogue without discarding unobserved variation.
2
+
3
+ The fixed panel layout is independent of the proband's markers or assignments.
4
+ Edges count unequal variant records, not differing bases. Patient overlays
5
+ preserve shared support for sets of catalogue haplotypes; they do not split
6
+ an observed-marker class's weight among indistinguishable full haplotypes.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from collections import defaultdict
12
+ import json
13
+ from pathlib import Path
14
+ import textwrap
15
+
16
+
17
+ def _catalogue_tree(haplotypes):
18
+ """Deterministic Prim MST, with stable ties resolved by catalogue order."""
19
+ n = len(haplotypes)
20
+ if n < 2:
21
+ return []
22
+ vectors = [h['allele_indices'] for h in haplotypes]
23
+ if len({len(v) for v in vectors}) != 1:
24
+ raise ValueError('Catalogue haplotypes have different marker counts')
25
+ if any(a is None for v in vectors for a in v):
26
+ raise ValueError('Full-catalogue graph requires complete variant vectors; missing is not reference')
27
+ distance = lambda i, j: sum(a != b for a, b in zip(vectors[i], vectors[j]))
28
+ remaining = {j: (distance(0, j), 0) for j in range(1, n)}
29
+ edges = []
30
+ while remaining:
31
+ j = min(remaining, key=lambda k: (remaining[k][0], remaining[k][1], k))
32
+ weight, i = remaining.pop(j)
33
+ edges.append((i, j, weight))
34
+ for k in remaining:
35
+ candidate = distance(j, k), j
36
+ if candidate < remaining[k]:
37
+ remaining[k] = candidate
38
+ return edges
39
+
40
+
41
+ def _layout(n, edges):
42
+ """Deterministic force layout; coordinates are schematic, not distances."""
43
+ import numpy as np
44
+ if n == 1:
45
+ return np.zeros((1, 2))
46
+ theta = np.linspace(0, 2 * np.pi, n, endpoint=False)
47
+ xy = np.column_stack((np.cos(theta), np.sin(theta)))
48
+ xy += np.random.default_rng(4927).normal(0, .035, (n, 2))
49
+ ideal = .5 / max(n, 1) ** .22
50
+ for step in range(1200):
51
+ delta = xy[:, None, :] - xy[None, :, :]
52
+ distances = np.maximum(np.linalg.norm(delta, axis=2), .025)
53
+ np.fill_diagonal(distances, 1e9)
54
+ force = (delta * (ideal ** 2 / distances ** 2)[:, :, None]).sum(axis=1)
55
+ for i, j, weight in edges:
56
+ diff = xy[j] - xy[i]
57
+ d = max(float(np.linalg.norm(diff)), .001)
58
+ pull = diff * d / (ideal * max(weight, 1) ** .4)
59
+ force[i] += pull
60
+ force[j] -= pull
61
+ force -= xy * .06
62
+ lengths = np.maximum(np.linalg.norm(force, axis=1), .001)
63
+ temperature = .045 * (1 - step / 1200) + .0005
64
+ xy += force / lengths[:, None] * np.minimum(lengths, temperature)[:, None]
65
+ xy -= xy.mean(axis=0)
66
+ # Principal-axis orientation is deterministic and keeps tall branches legible.
67
+ _, _, axes = np.linalg.svd(xy, full_matrices=False)
68
+ xy = xy @ axes.T
69
+ if xy[0, 0] < 0:
70
+ xy[:, 0] *= -1
71
+ if xy[0, 1] < 0:
72
+ xy[:, 1] *= -1
73
+ xy /= max(float(np.abs(xy).max()), .1)
74
+ return xy
75
+
76
+
77
+ def _snp_equivalence(haplotypes, sites):
78
+ indices = [i for i, s in enumerate(sites) if s['is_literal_snp']]
79
+ groups = defaultdict(list)
80
+ for hap in haplotypes:
81
+ groups[tuple(hap['allele_indices'][i] for i in indices)].append(hap['id'])
82
+ return sorted((ids for ids in groups.values() if len(ids) > 1), key=lambda ids: (-len(ids), ids))
83
+
84
+
85
+ def _placement_sets(result, catalogue_ids, side):
86
+ classes = {c['id']: c for c in result['classes']}
87
+ output = []
88
+ for item in result[side + '_marginals']:
89
+ members = classes[item['class_id']]['members']
90
+ unknown = set(members) - catalogue_ids
91
+ if unknown:
92
+ raise ValueError('Patient result members are not catalogue haplotype IDs: ' + ', '.join(sorted(unknown)))
93
+ output.append({'ids': sorted(members), 'weight': item['relative_weight']})
94
+ return output
95
+
96
+
97
+ def draw_catalogue(catalogue, outdir, result=None, title=None):
98
+ """Write catalogue_graph.png/svg and return their absolute file paths.
99
+
100
+ With no result, display SNP-equivalence sets only, explicitly identifying
101
+ this as a panel audit. With a result, highlight leading shared maternal and
102
+ paternal assignments on exactly the same full-catalogue layout.
103
+ """
104
+ import numpy as np
105
+ import matplotlib
106
+ matplotlib.use('Agg')
107
+ import matplotlib.pyplot as plt
108
+
109
+ haps = catalogue['haplotypes']
110
+ sites = catalogue['sites']
111
+ if not haps or not sites:
112
+ raise ValueError('Catalogue graph needs haplotypes and variant sites')
113
+ if len(haps) > 250:
114
+ raise ValueError('Static catalogue plot is limited to 250 full haplotypes; retain all in JSON')
115
+ if any(len(h['allele_indices']) != len(sites) for h in haps):
116
+ raise ValueError('Catalogue allele vectors do not match site count')
117
+ ids = [h['id'] for h in haps]
118
+ if len(ids) != len(set(ids)):
119
+ raise ValueError('Duplicate catalogue haplotype IDs')
120
+ edges = _catalogue_tree(haps)
121
+ coordinates = _layout(len(haps), edges)
122
+ equivalents = _snp_equivalence(haps, sites)
123
+ snp_count = sum(s['is_literal_snp'] for s in sites)
124
+ snp_classes = len(haps) - sum(len(group) - 1 for group in equivalents)
125
+ region = catalogue['region']
126
+ region_text = f"{catalogue['build']} {region['chrom']}:{region['start']:,}–{region['end']:,}"
127
+
128
+ background, ink, quiet = '#f8fafc', '#24374c', '#576c81'
129
+ colors = ['#b33d72', '#296eaf', '#c77b18', '#459a83', '#785da8', '#af554d']
130
+ mat_color, pat_color = '#b83d72', '#267ba9'
131
+ group_color = {hid: colors[i % len(colors)] for i, group in enumerate(equivalents) for hid in group}
132
+ mat_sets = _placement_sets(result, set(ids), 'maternal') if result else []
133
+ pat_sets = _placement_sets(result, set(ids), 'paternal') if result else []
134
+ # Preserve ambiguity at the level inferred: every candidate in a set gets
135
+ # the same ring; no per-full-haplotype probability is manufactured.
136
+ def chosen(sets):
137
+ if not sets:
138
+ return []
139
+ cutoff = max(.02, sets[0]['weight'] * .1)
140
+ return [item for item in sets if item['weight'] >= cutoff]
141
+ chosen_mat, chosen_pat = chosen(mat_sets), chosen(pat_sets)
142
+ maternal_ids = {h for item in chosen_mat for h in item['ids']}
143
+ paternal_ids = {h for item in chosen_pat for h in item['ids']}
144
+
145
+ plt.rcParams.update({'font.family': 'DejaVu Sans', 'svg.fonttype': 'none'})
146
+ fig = plt.figure(figsize=(16, 10.5), facecolor=background)
147
+ heading = title or ('Regional haplotype catalogue: trio placement' if result else
148
+ 'Regional haplotype catalogue: SNP ambiguity')
149
+ fig.text(.04, .947, heading, size=22, weight='bold', color=ink)
150
+ fig.text(.04, .909, f"{region_text} · {len(haps)} full haplotype classes · "
151
+ f"{len(sites)} variant records · {snp_count} literal SNP records", color=quiet, size=12)
152
+ fig.text(.04, .876, 'Trio placements shown on the unchanged full catalogue.' if result else
153
+ 'Panel audit only — no trio or proband evidence supplied.',
154
+ color=mat_color if result else quiet, size=11, weight='bold')
155
+
156
+ ax = fig.add_axes([.035, .115, .665, .725])
157
+ ax.set_aspect('equal')
158
+ ax.set_xlim(-1.14, 1.14)
159
+ ymin, ymax = coordinates[:, 1].min(), coordinates[:, 1].max()
160
+ ax.set_ylim(min(-.75, ymin - .12), max(.75, ymax + .12))
161
+ ax.axis('off')
162
+ for i, j, d in edges:
163
+ a, b = coordinates[i], coordinates[j]
164
+ ax.plot([a[0], b[0]], [a[1], b[1]], color='#b8c6d3', lw=1.4, zorder=1)
165
+ if d != 1:
166
+ mid = (a + b) / 2
167
+ ax.text(*mid, str(d), ha='center', va='center', size=7.5, color=quiet,
168
+ bbox={'facecolor': background, 'edgecolor': 'none', 'pad': .7}, zorder=3)
169
+ max_count = max(max(int(h.get('count', len(h.get('members', [])))), 1) for h in haps)
170
+ sizes = []
171
+ for hap in haps:
172
+ count = max(int(hap.get('count', len(hap.get('members', [])))), 1)
173
+ sizes.append(350 + 1200 * count / max_count)
174
+ for i, hap in enumerate(haps):
175
+ x, y = coordinates[i]
176
+ color = group_color.get(hap['id'], '#778ba0') if result is None else '#778ba0'
177
+ ax.scatter([x], [y], s=sizes[i], facecolor='white', edgecolor=color, lw=2, zorder=4)
178
+ ax.text(x, y, hap['id'], ha='center', va='center', size=8, color=color, weight='bold', zorder=6)
179
+ if hap['id'] in maternal_ids:
180
+ ax.scatter([x], [y], s=sizes[i] + 220, facecolor='none', edgecolor=mat_color, lw=2.2, zorder=5)
181
+ if hap['id'] in paternal_ids:
182
+ ax.scatter([x], [y], s=sizes[i] + 450, facecolor='none', edgecolor=pat_color, lw=2.2, zorder=5)
183
+
184
+ side = fig.add_axes([.735, .15, .235, .69])
185
+ side.axis('off')
186
+ y = .98
187
+ if result is None:
188
+ side.text(0, y, 'What SNPs can distinguish', size=15, color=ink, weight='bold')
189
+ y -= .085
190
+ side.text(0, y, f"{len(haps)} full classes → {snp_classes} SNP signatures", size=12, color=ink)
191
+ y -= .08
192
+ side.text(0, y, 'Each colored set is identical across all\nlisted literal SNPs. Non-SNP records\ndistinguish its full haplotypes.',
193
+ size=10, color=quiet, va='top', linespacing=1.55)
194
+ y -= .17
195
+ for index, group in enumerate(equivalents[:6]):
196
+ label = textwrap.fill(', '.join(group), width=34)
197
+ side.text(0, y, label, color=colors[index % len(colors)], size=11,
198
+ weight='bold', va='top', linespacing=1.4)
199
+ y -= .055 * (label.count('\n') + 1) + .055
200
+ if len(equivalents) > 6:
201
+ side.text(0, y, f'+ {len(equivalents) - 6} further sets in catalogue JSON', size=9, color=quiet)
202
+ y -= .06
203
+ side.text(0, y, 'Fewer callable patient SNPs can create\nfurther ties. The graph retains every\nfull haplotype and its original label.',
204
+ size=10, color=quiet, va='top', linespacing=1.55)
205
+ else:
206
+ for label, sets, color in [('Maternal candidate sets', mat_sets, mat_color),
207
+ ('Paternal candidate sets', pat_sets, pat_color)]:
208
+ side.text(0, y, label, size=14, color=color, weight='bold')
209
+ y -= .065
210
+ for item in sets[:3]:
211
+ name = textwrap.fill(', '.join(item['ids']), width=29)
212
+ side.text(0, y, name, color=ink, size=10, va='top', linespacing=1.3)
213
+ side.text(1, y, f"{item['weight']:.1%}", color=color, size=10,
214
+ ha='right', va='top', weight='bold')
215
+ y -= .038 * (name.count('\n') + 1) + .045
216
+ y -= .065
217
+ side.text(0, y, 'Support belongs to each entire set.\nRings do not assign that weight to\neach individual full haplotype.\n\nWeights are conditional ranking scores,\nnot calibrated clinical probabilities.',
218
+ size=9.5, color=quiet, va='top', linespacing=1.5)
219
+
220
+ fig.text(.04, .082, 'Edges: minimum spanning tree over all variant records; labels count differing records (unlabelled edges = 1).',
221
+ size=10, color=quiet)
222
+ fig.text(.04, .055, 'Node size increases with supplied assembly count, including reference entries. Counts are not patient assignment priors.',
223
+ size=10, color=quiet)
224
+ fig.text(.04, .028, 'Schematic similarity network, not a genealogy or sequence pangenome graph. Layout is independent of patient evidence.',
225
+ size=10, color=quiet)
226
+ output = Path(outdir)
227
+ output.mkdir(parents=True, exist_ok=True)
228
+ png, svg = output / 'catalogue_graph.png', output / 'catalogue_graph.svg'
229
+ fig.savefig(png, dpi=160, facecolor=background)
230
+ fig.savefig(svg, facecolor=background)
231
+ plt.close(fig)
232
+ return {'png': str(png.resolve()), 'svg': str(svg.resolve())}
233
+
234
+
235
+ def main(argv=None):
236
+ parser = argparse.ArgumentParser(description='Plot a full haplotype catalogue and optional trio placement')
237
+ parser.add_argument('--catalogue', required=True)
238
+ parser.add_argument('--out', required=True)
239
+ parser.add_argument('--result')
240
+ parser.add_argument('--title')
241
+ args = parser.parse_args(argv)
242
+ catalogue = json.loads(Path(args.catalogue).read_text())
243
+ result = json.loads(Path(args.result).read_text()) if args.result else None
244
+ print(json.dumps(draw_catalogue(catalogue, args.out, result, args.title), indent=2))
245
+
246
+
247
+ if __name__ == '__main__':
248
+ main()
triohap/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ """Command-line entry point for an inspectable research prototype."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from . import __version__
8
+ from .vcf import read_trio_vcf, read_trio_vcfs, read_panel
9
+ from .core import match_haplotypes
10
+ from .origin import read_unfazed_annotations
11
+ from .report import write_report
12
+
13
+
14
+ def main(argv=None):
15
+ parser = argparse.ArgumentParser(description='Research: match a trio to regional panel SNP haplotypes.')
16
+ parser.add_argument('--version', action='version', version=f'triohap {__version__}')
17
+ parser.add_argument('--trio', help='Joint VCF/gVCF containing all three trio members; detected automatically')
18
+ for role in ('child', 'mother', 'father'):
19
+ parser.add_argument('--' + role + '-vcf', help=f'Individual VCF/gVCF containing the {role}; detected automatically; supply all three')
20
+ parser.add_argument('--panel', required=True)
21
+ for role in ('child', 'mother', 'father'):
22
+ parser.add_argument('--' + role, required=True)
23
+ parser.add_argument('--region', required=True, help='CHROM:START-END; 1-based inclusive')
24
+ parser.add_argument('--build', required=True, help='Asserted shared coordinate reference (provenance only)')
25
+ parser.add_argument('--out', required=True)
26
+ parser.add_argument('--panel-format', choices=['auto', 'vcf', 'tsv', 'catalogue'], default='auto')
27
+ parser.add_argument('--epsilon', type=float, default=.01, help='Per-allele mismatch tolerance, not a calibrated error estimate')
28
+ parser.add_argument('--min-gq', type=float, default=0)
29
+ parser.add_argument('--top-n', type=int, default=20)
30
+ parser.add_argument('--max-classes', type=int, default=200)
31
+ parser.add_argument('--ignore-phase', action='store_true')
32
+ parser.add_argument('--unfazed-vcf', help='Optional parent-of-origin annotation; does not alter scores')
33
+ parser.add_argument('--no-plot', action='store_true')
34
+ parser.add_argument('--title', default='Trio-aware regional haplotype matches')
35
+ args = parser.parse_args(argv)
36
+ individual_paths = (args.child_vcf, args.mother_vcf, args.father_vcf)
37
+ if args.trio and any(individual_paths):
38
+ parser.error('Use --trio OR all three --child-vcf, --mother-vcf and --father-vcf inputs.')
39
+ if not args.trio and not all(individual_paths):
40
+ parser.error('Supply --trio OR all three --child-vcf, --mother-vcf and --father-vcf inputs.')
41
+ try:
42
+ output = Path(args.out)
43
+ if output.exists() and any(output.iterdir()):
44
+ raise ValueError(f'Output directory {output} is not empty. Choose a new directory to preserve previous results.')
45
+ panel = read_panel(args.panel, region=args.region, format=args.panel_format)
46
+ trio_options = dict(child=args.child, mother=args.mother, father=args.father,
47
+ region=args.region, min_gq=args.min_gq, reference_sites=panel['records'])
48
+ if args.trio:
49
+ trio = read_trio_vcf(args.trio, **trio_options)
50
+ else:
51
+ trio = read_trio_vcfs(*individual_paths, **trio_options)
52
+ result = match_haplotypes(trio, panel, epsilon=args.epsilon, ignore_phase=args.ignore_phase,
53
+ max_classes=args.max_classes, top_n=args.top_n)
54
+ if 'catalogue' in panel:
55
+ result['catalogue'] = panel['catalogue']
56
+ result['provenance'] = {'program': 'triohap', 'version': __version__,
57
+ 'generated_utc': datetime.now(timezone.utc).isoformat(),
58
+ 'region': args.region, 'asserted_coordinate_build': args.build,
59
+ 'build_note': 'Caller assertion; no reference FASTA validation or liftover performed.'}
60
+ if args.unfazed_vcf:
61
+ result['unfazed_annotations'] = read_unfazed_annotations(
62
+ args.unfazed_vcf, args.child, args.region, trio=trio, asserted_build=args.build)
63
+ result['sources']['unfazed_annotation'] = args.unfazed_vcf
64
+ write_report(result, output, args.title, plot=not args.no_plot)
65
+ print(json.dumps({'report': str(output / 'report.html'), 'summary': result['summary']}, indent=2))
66
+ return 0
67
+ except (ValueError, OSError, ImportError) as exc:
68
+ print(f'triohap: {exc}', file=sys.stderr)
69
+ return 2
70
+
71
+
72
+ if __name__ == '__main__':
73
+ raise SystemExit(main())