telometer 2.0.0__tar.gz → 2.0.1__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.
- {telometer-2.0.0 → telometer-2.0.1}/PKG-INFO +1 -1
- {telometer-2.0.0 → telometer-2.0.1}/pyproject.toml +1 -1
- telometer-2.0.1/telometer/telometer_variant_support.py +198 -0
- telometer-2.0.1/telometer/variant_support_v2.py +308 -0
- {telometer-2.0.0 → telometer-2.0.1}/telometer.egg-info/PKG-INFO +1 -1
- {telometer-2.0.0 → telometer-2.0.1}/telometer.egg-info/SOURCES.txt +2 -1
- telometer-2.0.0/telometer/__init__.py +0 -34
- {telometer-2.0.0 → telometer-2.0.1}/README.md +0 -0
- {telometer-2.0.0 → telometer-2.0.1}/setup.cfg +0 -0
- {telometer-2.0.0 → telometer-2.0.1}/telometer/telometer.py +0 -0
- {telometer-2.0.0 → telometer-2.0.1}/telometer.egg-info/dependency_links.txt +0 -0
- {telometer-2.0.0 → telometer-2.0.1}/telometer.egg-info/requires.txt +0 -0
- {telometer-2.0.0 → telometer-2.0.1}/telometer.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small, explicit helpers used by isolated Telometer v1.0 patches."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from array import array
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
import hashlib
|
|
8
|
+
import math
|
|
9
|
+
import sqlite3
|
|
10
|
+
import zlib
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import regex
|
|
14
|
+
|
|
15
|
+
_COMPLEMENT = str.maketrans('ACGTRYSWKMBDHVNacgtryswkmbdhvn', 'TGCAYRSWMKVHDBNtgcayrswmkvhdbn')
|
|
16
|
+
_ERROR_PROB = np.power(10.0, -np.arange(256, dtype=float) / 10.0)
|
|
17
|
+
_DOUBLET = regex.compile(r'(?:TTAGGG){2}|(?:CCCTAA){2}')
|
|
18
|
+
NOTES = Counter()
|
|
19
|
+
RECOVERY_COUNTS = Counter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reset_notes():
|
|
23
|
+
NOTES.clear()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def note(key, value=1):
|
|
27
|
+
NOTES[key] += value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reverse_complement_iupac(seq):
|
|
31
|
+
return seq.translate(_COMPLEMENT)[::-1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def native_read_q(read):
|
|
35
|
+
if not read.has_tag('qs'):
|
|
36
|
+
return 0.0
|
|
37
|
+
try:
|
|
38
|
+
value = float(read.get_tag('qs'))
|
|
39
|
+
except (ValueError, TypeError):
|
|
40
|
+
return 0.0
|
|
41
|
+
return value if math.isfinite(value) else 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def probability_mean_q(qualities):
|
|
45
|
+
if not len(qualities):
|
|
46
|
+
return 0.0
|
|
47
|
+
q = np.asarray(qualities, dtype=np.uint8)
|
|
48
|
+
return float(-10.0 * np.log10(_ERROR_PROB[q].mean()))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@lru_cache(maxsize=16)
|
|
52
|
+
def _compiled(pattern):
|
|
53
|
+
return regex.compile(pattern)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cached_findall(pattern, text):
|
|
57
|
+
return _compiled(pattern).findall(text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def refine_doublet_boundaries(seq, start, end, radius=120):
|
|
61
|
+
left = max(0, start-radius)
|
|
62
|
+
right = min(len(seq), start+radius+12)
|
|
63
|
+
starts = [left+m.start() for m in _DOUBLET.finditer(seq[left:right], overlapped=True)
|
|
64
|
+
if abs(left+m.start()-start) <= radius]
|
|
65
|
+
left2 = max(0, end-radius-12)
|
|
66
|
+
right2 = min(len(seq), end+radius)
|
|
67
|
+
ends = [left2+m.end() for m in _DOUBLET.finditer(seq[left2:right2], overlapped=True)
|
|
68
|
+
if abs(left2+m.end()-end) <= radius]
|
|
69
|
+
if not starts or not ends:
|
|
70
|
+
note('boundary_unresolved')
|
|
71
|
+
return None
|
|
72
|
+
refined_start = min(starts, key=lambda x:(abs(x-start), x<start, x))
|
|
73
|
+
refined_end = min(ends, key=lambda x:(abs(x-end), x>end, x))
|
|
74
|
+
if refined_start >= refined_end:
|
|
75
|
+
note('boundary_empty_or_reversed')
|
|
76
|
+
return None
|
|
77
|
+
assert abs(refined_start-start) <= radius and abs(refined_end-end) <= radius
|
|
78
|
+
return refined_start, refined_end
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def flank_support(length, start, end, cigar, hardclips_as_soft=False):
|
|
82
|
+
left_terminal, right_terminal = start < 100, end > length-100
|
|
83
|
+
if not left_terminal and not right_terminal:
|
|
84
|
+
return False, 'none', 'not_terminal'
|
|
85
|
+
left_distance, right_distance = start, length-end
|
|
86
|
+
if left_terminal and right_terminal and left_distance == right_distance:
|
|
87
|
+
return False, 'ambiguous', 'equal_terminal_proximity'
|
|
88
|
+
side = 'left' if left_terminal and (not right_terminal or left_distance < right_distance) else 'right'
|
|
89
|
+
lo, hi = (end,end+50) if side == 'left' else (start-50,start)
|
|
90
|
+
if lo < 0 or hi > length:
|
|
91
|
+
return False, side, 'insufficient_proximal_sequence'
|
|
92
|
+
query, covered = 0, 0
|
|
93
|
+
for op, n in (cigar or []):
|
|
94
|
+
if op in (0,7,8):
|
|
95
|
+
covered += max(0, min(query+n,hi)-max(query,lo))
|
|
96
|
+
if op in (0,1,4,7,8) or (op == 5 and hardclips_as_soft):
|
|
97
|
+
query += n
|
|
98
|
+
return covered == 50, side, 'supported' if covered == 50 else 'proximal_flank_not_fully_match_aligned'
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def alignment_fields(read):
|
|
102
|
+
cigar = read.cigartuples or []
|
|
103
|
+
try:
|
|
104
|
+
alignment_score = float(read.get_tag('AS')) if read.has_tag('AS') else None
|
|
105
|
+
if alignment_score is not None and not math.isfinite(alignment_score):
|
|
106
|
+
alignment_score = None
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
alignment_score = None
|
|
109
|
+
return {'reference_name':read.reference_name or '',
|
|
110
|
+
'reference_start':read.reference_start if read.reference_start is not None else -1,
|
|
111
|
+
'reference_end':read.reference_end if read.reference_end is not None else -1,
|
|
112
|
+
'is_reverse':bool(read.is_reverse), 'flag':int(read.flag), 'cigar':read.cigarstring or '',
|
|
113
|
+
'record_digest':hashlib.sha256(read.to_string().encode()).hexdigest(),
|
|
114
|
+
'mapping_quality':int(read.mapping_quality),
|
|
115
|
+
'alignment_class':2 if read.is_secondary else (1 if read.is_supplementary else 0),
|
|
116
|
+
'aligned_query_bases':sum(n for op,n in cigar if op in (0,7,8)),
|
|
117
|
+
'alignment_score':alignment_score}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def stable_record_key(fields):
|
|
121
|
+
return (fields['reference_name'],fields['reference_start'],fields['reference_end'],
|
|
122
|
+
int(fields['is_reverse']),fields['flag'],fields['cigar'],fields['record_digest'])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def length_neutral_key(fields):
|
|
126
|
+
negative_score = -fields['alignment_score'] if fields['alignment_score'] is not None else math.inf
|
|
127
|
+
return (-fields['mapping_quality'],fields['alignment_class'],-fields['aligned_query_bases'],
|
|
128
|
+
negative_score,stable_record_key(fields))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@lru_cache(maxsize=4)
|
|
132
|
+
def _donor_connection(path):
|
|
133
|
+
return sqlite3.connect(f'file:{path}?mode=ro', uri=True)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class RecoveredRead:
|
|
137
|
+
"""Sequence/quality view retaining the original alignment record and its identity."""
|
|
138
|
+
def __init__(self, original, sequence, qualities):
|
|
139
|
+
self._original = original
|
|
140
|
+
self.query_sequence = sequence
|
|
141
|
+
self.query_qualities = array('B', qualities)
|
|
142
|
+
self.recovered_full_sequence = True
|
|
143
|
+
|
|
144
|
+
def __getattr__(self, name):
|
|
145
|
+
return getattr(self._original, name)
|
|
146
|
+
|
|
147
|
+
def get_forward_sequence(self):
|
|
148
|
+
return reverse_complement_iupac(self.query_sequence) if self.is_reverse else self.query_sequence
|
|
149
|
+
|
|
150
|
+
def get_forward_qualities(self):
|
|
151
|
+
return self.query_qualities[::-1] if self.is_reverse else self.query_qualities
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def recover_record(read, donor_db):
|
|
155
|
+
cigar = read.cigartuples or []
|
|
156
|
+
hard = [(i,n) for i,(op,n) in enumerate(cigar) if op == 5]
|
|
157
|
+
seq, qualities = read.query_sequence, read.query_qualities
|
|
158
|
+
incomplete = bool(hard) or not seq or qualities is None or not len(qualities)
|
|
159
|
+
if not incomplete:
|
|
160
|
+
return read
|
|
161
|
+
RECOVERY_COUNTS['incomplete_records'] += 1
|
|
162
|
+
if not donor_db:
|
|
163
|
+
raise ValueError('Variant 06 needs an explicitly prepared donor database.')
|
|
164
|
+
row = _donor_connection(str(donor_db)).execute(
|
|
165
|
+
'SELECT sequence,qualities,length,conflict FROM donors WHERE read_id=?',(read.query_name,)).fetchone()
|
|
166
|
+
if row is None or row[3]:
|
|
167
|
+
RECOVERY_COUNTS['missing_or_conflicting_donor'] += 1
|
|
168
|
+
return None
|
|
169
|
+
if any(i not in (0,len(cigar)-1) for i,n in hard):
|
|
170
|
+
RECOVERY_COUNTS['internal_hard_clip'] += 1
|
|
171
|
+
return None
|
|
172
|
+
full_length = row[2]
|
|
173
|
+
leading_h = cigar[0][1] if cigar and cigar[0][0]==5 else 0
|
|
174
|
+
trailing_h = cigar[-1][1] if cigar and cigar[-1][0]==5 else 0
|
|
175
|
+
inferred_length = sum(n for op,n in cigar if op in (0,1,4,5,7,8))
|
|
176
|
+
if not cigar and seq:
|
|
177
|
+
inferred_length = len(seq)
|
|
178
|
+
if inferred_length != full_length:
|
|
179
|
+
RECOVERY_COUNTS['inferred_length_conflict'] += 1
|
|
180
|
+
return None
|
|
181
|
+
donor_sequence = zlib.decompress(row[0]).decode('ascii')
|
|
182
|
+
donor_quality = zlib.decompress(row[1])
|
|
183
|
+
if read.is_reverse:
|
|
184
|
+
donor_sequence = reverse_complement_iupac(donor_sequence)
|
|
185
|
+
donor_quality = donor_quality[::-1]
|
|
186
|
+
stop = full_length-trailing_h
|
|
187
|
+
expected_sequence = donor_sequence[leading_h:stop]
|
|
188
|
+
expected_quality = donor_quality[leading_h:stop]
|
|
189
|
+
if seq and seq != expected_sequence:
|
|
190
|
+
RECOVERY_COUNTS['retained_sequence_conflict'] += 1
|
|
191
|
+
return None
|
|
192
|
+
if qualities is not None and len(qualities) and bytes(qualities) != expected_quality:
|
|
193
|
+
RECOVERY_COUNTS['retained_quality_conflict'] += 1
|
|
194
|
+
return None
|
|
195
|
+
if len(donor_sequence) != full_length or len(donor_quality) != full_length:
|
|
196
|
+
raise AssertionError('Invalid donor database SEQ/QUAL length.')
|
|
197
|
+
RECOVERY_COUNTS['recovered_records'] += 1
|
|
198
|
+
return RecoveredRead(read,donor_sequence,donor_quality)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exact 6-mer variant support and repeat-screened proximal-flank proxy.
|
|
3
|
+
|
|
4
|
+
Coordinates in computations are relative to available SEQ in original sequencing
|
|
5
|
+
orientation. Public *_forward coordinates add the original-orientation leading
|
|
6
|
+
hard-clip offset, when present. No absent sequence is recovered or invented.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
import re
|
|
11
|
+
import warnings
|
|
12
|
+
from typing import NamedTuple
|
|
13
|
+
import numpy as np
|
|
14
|
+
from telometer_variant_support import reverse_complement_iupac
|
|
15
|
+
|
|
16
|
+
BASE_COLUMNS = ['chromosome', 'reference_start', 'reference_end', 'telomere_length',
|
|
17
|
+
'read_id', 'mapping_quality', 'read_length', 'arm', 'direction']
|
|
18
|
+
VARIANT_COLUMNS = ['variant_motif', 'variant_present', 'variant_tract_count',
|
|
19
|
+
'variant_supported_bp', 'variant_repeat_equivalents', 'pre_variant_bp',
|
|
20
|
+
'pre_variant_status', 'anchor_status', 'telomeric_query_side',
|
|
21
|
+
'weak_proximal_variant_evidence', 'variant_boundary_truncated', 'variant_overlap_flag']
|
|
22
|
+
TRACT_COLUMNS = ['read_id', 'record_id', 'tract_id', 'query_start_forward', 'query_end_forward',
|
|
23
|
+
'span_bp', 'supported_bp', 'nonoverlapping_units', 'mean_q', 'min_q',
|
|
24
|
+
'boundary_left_truncated', 'boundary_right_truncated', 'touches_telomere_start',
|
|
25
|
+
'touches_telomere_end', 'touches_query_start', 'touches_query_end',
|
|
26
|
+
'sequence_left_missing', 'sequence_right_missing', 'overlap_flag', 'phase_template']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PeriodicRun(NamedTuple):
|
|
30
|
+
start: int
|
|
31
|
+
end: int
|
|
32
|
+
phase: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache(maxsize=32)
|
|
36
|
+
def motif_family(motif):
|
|
37
|
+
"""Deduplicated rotations/RCs; deterministic, cached per process."""
|
|
38
|
+
motif = motif.upper()
|
|
39
|
+
rc = reverse_complement_iupac(motif)
|
|
40
|
+
return tuple(sorted({s[i:] + s[:i] for s in (motif, rc) for i in range(6)}))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def minimum_period(motif):
|
|
44
|
+
return next(p for p in (1, 2, 3, 6) if motif == motif[:p] * (6//p))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_variant_options(motif=None, min_units=None, emit_warnings=True):
|
|
48
|
+
if motif is None:
|
|
49
|
+
if min_units is not None:
|
|
50
|
+
raise ValueError('--min-variant-units requires --variant-motif')
|
|
51
|
+
return None, 2
|
|
52
|
+
motif = str(motif).upper()
|
|
53
|
+
if re.fullmatch('[ACGT]{6}', motif) is None:
|
|
54
|
+
raise ValueError('--variant-motif must be exactly six A/C/G/T bases (not a regex)')
|
|
55
|
+
if motif in motif_family('TTAGGG'):
|
|
56
|
+
raise ValueError('The variant motif must not be canonical-equivalent under rotation/RC')
|
|
57
|
+
units = 2 if min_units is None else min_units
|
|
58
|
+
if isinstance(units, bool) or not isinstance(units, int) or units < 2:
|
|
59
|
+
raise ValueError('--min-variant-units must be an integer at least 2')
|
|
60
|
+
if emit_warnings and (minimum_period(motif) < 6 or len(set(motif)) <= 2):
|
|
61
|
+
warnings.warn('Low-complexity variant motif: periodicity/base diversity may reduce specificity; '
|
|
62
|
+
'exact support is not biological validation.', UserWarning, stacklevel=2)
|
|
63
|
+
return motif, units
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@lru_cache(maxsize=32)
|
|
67
|
+
def _motif_search(motif):
|
|
68
|
+
family = motif_family(motif)
|
|
69
|
+
# Lookahead enumerates overlapping 6-bp hits, never consumes a gap.
|
|
70
|
+
pattern = re.compile('(?=(' + '|'.join(family) + '))')
|
|
71
|
+
# A phase key is the six-base periodic template at absolute query indices mod 6.
|
|
72
|
+
phase_keys = {(m, r): (m[-r:] + m[:-r] if r else m) for m in family for r in range(6)}
|
|
73
|
+
return pattern, phase_keys
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def strict_runs(seq, motif, min_units=2):
|
|
77
|
+
"""Maximal phase-consistent covered runs; no unsupported-gap/phase bridging.
|
|
78
|
+
|
|
79
|
+
Adjacent/overlapping exact hits may join ONLY within the identical global
|
|
80
|
+
periodic template. Distinct templates remain separate, including overlaps.
|
|
81
|
+
Partial end units can be covered by overlapping exact hits; admission still
|
|
82
|
+
requires floor(span/6) >= min_units non-overlapping complete units.
|
|
83
|
+
"""
|
|
84
|
+
if not seq:
|
|
85
|
+
return []
|
|
86
|
+
pattern, phase_keys = _motif_search(motif.upper())
|
|
87
|
+
current, finished = {}, []
|
|
88
|
+
for hit in pattern.finditer(seq):
|
|
89
|
+
start, end = hit.start(), hit.start() + 6
|
|
90
|
+
phase = phase_keys[(hit.group(1), start % 6)]
|
|
91
|
+
previous = current.get(phase)
|
|
92
|
+
if previous is not None and start <= previous[1]:
|
|
93
|
+
current[phase] = (previous[0], max(previous[1], end))
|
|
94
|
+
else:
|
|
95
|
+
if previous is not None:
|
|
96
|
+
finished.append(PeriodicRun(previous[0], previous[1], phase))
|
|
97
|
+
current[phase] = (start, end)
|
|
98
|
+
finished.extend(PeriodicRun(s, e, phase) for phase, (s, e) in current.items())
|
|
99
|
+
# Same-template overlaps/containments already coalesced, distinct phases are not.
|
|
100
|
+
return sorted(set(r for r in finished if r.end-r.start >= 6*min_units))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def union_intervals(intervals):
|
|
104
|
+
merged = []
|
|
105
|
+
for s, e in sorted((int(s), int(e)) for s, e in intervals if e > s):
|
|
106
|
+
if merged and s <= merged[-1][1]:
|
|
107
|
+
merged[-1] = (merged[-1][0], max(e, merged[-1][1]))
|
|
108
|
+
else:
|
|
109
|
+
merged.append((s, e))
|
|
110
|
+
return merged
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def union_bp(intervals):
|
|
114
|
+
return sum(e-s for s, e in union_intervals(intervals))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def overlap_flags(intervals):
|
|
118
|
+
# Sorted sweep: mark every member of a positive-overlap group, not adjacency.
|
|
119
|
+
indexed = sorted((s, e, i) for i, (s, e) in enumerate(intervals))
|
|
120
|
+
flags = [False] * len(indexed)
|
|
121
|
+
active = []
|
|
122
|
+
for s, e, i in indexed:
|
|
123
|
+
active = [(pe, pi) for pe, pi in active if pe > s]
|
|
124
|
+
for _, pi in active:
|
|
125
|
+
flags[pi] = flags[i] = True
|
|
126
|
+
active.append((e, i))
|
|
127
|
+
return flags
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def coverage_prefix(length, runs):
|
|
131
|
+
if not runs:
|
|
132
|
+
return None
|
|
133
|
+
mask = np.zeros(length, dtype=np.uint8)
|
|
134
|
+
for run in runs:
|
|
135
|
+
mask[run.start:run.end] = 1
|
|
136
|
+
prefix = np.empty(length+1, dtype=np.int64)
|
|
137
|
+
prefix[0] = 0
|
|
138
|
+
np.cumsum(mask, out=prefix[1:])
|
|
139
|
+
return prefix
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@lru_cache(maxsize=32)
|
|
143
|
+
def _canonical_pattern(pattern):
|
|
144
|
+
# Standard re and regex have identical semantics for this literal alternation.
|
|
145
|
+
return re.compile(pattern)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def combined_window_density(window_seq, window_start, canonical_pattern, canonical_count, prefix):
|
|
149
|
+
"""Augment the ORIGINAL non-overlapping canonical-window matches, not all registers."""
|
|
150
|
+
if prefix is None:
|
|
151
|
+
return canonical_count * 6 / len(window_seq)
|
|
152
|
+
end = window_start + len(window_seq)
|
|
153
|
+
variant_bp = int(prefix[end]-prefix[window_start])
|
|
154
|
+
if variant_bp == 0:
|
|
155
|
+
return canonical_count * 6 / len(window_seq)
|
|
156
|
+
shared = sum(int(prefix[window_start+m.end()] - prefix[window_start+m.start()])
|
|
157
|
+
for m in _canonical_pattern(canonical_pattern).finditer(window_seq))
|
|
158
|
+
return (canonical_count * 6 + variant_bp - shared) / len(window_seq)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def stored_to_forward(length, start, end, is_reverse, offset=0):
|
|
162
|
+
if not (0 <= start <= end <= length):
|
|
163
|
+
raise ValueError('Query interval is out of bounds')
|
|
164
|
+
s, e = (length-end, length-start) if is_reverse else (start, end)
|
|
165
|
+
return s+offset, e+offset
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def clipping(cigar, is_reverse):
|
|
169
|
+
cigar = cigar or []
|
|
170
|
+
left_h = cigar[0][1] if cigar and cigar[0][0] == 5 else 0
|
|
171
|
+
right_h = cigar[-1][1] if cigar and cigar[-1][0] == 5 else 0
|
|
172
|
+
soft = [n for op, n in cigar if op == 4]
|
|
173
|
+
return {'query_forward_offset': right_h if is_reverse else left_h,
|
|
174
|
+
'sequence_left_missing': bool(right_h if is_reverse else left_h),
|
|
175
|
+
'sequence_right_missing': bool(left_h if is_reverse else right_h),
|
|
176
|
+
'hard_clip_left': left_h, 'hard_clip_right': right_h,
|
|
177
|
+
'hard_clipped_sequence': bool(left_h or right_h), 'soft_clipped_bases': sum(soft)}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def aligned_intervals_forward(length, cigar, is_reverse):
|
|
181
|
+
query, intervals = 0, []
|
|
182
|
+
for op, n in cigar or []:
|
|
183
|
+
if op in (0, 7, 8):
|
|
184
|
+
intervals.append(stored_to_forward(length, query, query+n, is_reverse))
|
|
185
|
+
if op in (0, 1, 4, 7, 8):
|
|
186
|
+
query += n
|
|
187
|
+
if cigar and query != length:
|
|
188
|
+
raise ValueError('CIGAR query-consuming length does not match available SEQ')
|
|
189
|
+
return union_intervals(intervals)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def evaluate_anchor(length, start, end, cigar, is_reverse, repeat_intervals):
|
|
193
|
+
"""Require exactly one terminal side, 50 M/=/X bases, and no strict repeat run."""
|
|
194
|
+
if not (0 <= start < end <= length):
|
|
195
|
+
raise ValueError('Invalid telomere interval')
|
|
196
|
+
left, right = start < 100, end > length-100
|
|
197
|
+
out = {'telomeric_query_side': 'ambiguous' if left and right else ('left' if left else 'right' if right else 'none'),
|
|
198
|
+
'anchor_status': 'ambiguous_terminal_side', 'proximal_boundary_local': None,
|
|
199
|
+
'flank_start_local': None, 'flank_end_local': None, 'flank_aligned_bp': None,
|
|
200
|
+
'flank_repeat_intersection': None}
|
|
201
|
+
if left == right:
|
|
202
|
+
return out
|
|
203
|
+
lo, hi = (end, end+50) if left else (start-50, start)
|
|
204
|
+
out.update(proximal_boundary_local=end if left else start, flank_start_local=lo, flank_end_local=hi)
|
|
205
|
+
if lo < 0 or hi > length:
|
|
206
|
+
out['anchor_status'] = 'insufficient_flank'
|
|
207
|
+
return out
|
|
208
|
+
aligned = aligned_intervals_forward(length, cigar, is_reverse)
|
|
209
|
+
covered = sum(max(0, min(hi, e)-max(lo, s)) for s, e in aligned)
|
|
210
|
+
repeated = any(s < hi and e > lo for s, e in repeat_intervals)
|
|
211
|
+
out.update(flank_aligned_bp=covered, flank_repeat_intersection=bool(repeated))
|
|
212
|
+
out['anchor_status'] = ('unaligned_flank' if covered != 50 else
|
|
213
|
+
'repeat_supported_flank' if repeated else 'supported_proxy')
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def common_distance(intervals, start, end, anchor):
|
|
218
|
+
if not intervals:
|
|
219
|
+
return None, 'no_supported_tract'
|
|
220
|
+
if anchor['anchor_status'] != 'supported_proxy':
|
|
221
|
+
return None, 'anchor_unresolved'
|
|
222
|
+
if anchor['telomeric_query_side'] == 'right':
|
|
223
|
+
distance = min(s for s, e in intervals)-start
|
|
224
|
+
else:
|
|
225
|
+
distance = end-max(e for s, e in intervals)
|
|
226
|
+
if not 0 <= distance <= end-start:
|
|
227
|
+
raise AssertionError('Invalid pre-variant distance')
|
|
228
|
+
return int(distance), 'estimated_proxy'
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def quality_diagnostics(qualities, start, end):
|
|
232
|
+
if qualities is None or len(qualities) < end:
|
|
233
|
+
return None, None
|
|
234
|
+
values = qualities[start:end]
|
|
235
|
+
return (float(sum(values)/len(values)), int(min(values))) if len(values) else (None, None)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def characterize_intervals(seq, qualities, start, end, intervals, motif, cigar, is_reverse,
|
|
239
|
+
read_id, record_id, min_units=2, phases=None, strict_definition=True):
|
|
240
|
+
"""Annotate supplied intervals, preserving a comparator's intervals unchanged.
|
|
241
|
+
|
|
242
|
+
The flank always screens canonical + variant runs at TWO units, even if the
|
|
243
|
+
chosen variant-admission threshold is higher. It is not a mapping-uniqueness test.
|
|
244
|
+
"""
|
|
245
|
+
length = len(seq)
|
|
246
|
+
if any(not start <= s < e <= end for s, e in intervals):
|
|
247
|
+
raise AssertionError('Tract lies outside the selected telomere interval')
|
|
248
|
+
full_weak = strict_runs(seq, motif, min_units=1)
|
|
249
|
+
strict_repeat_intervals = [(r.start, r.end) for r in full_weak if r.end-r.start >= 12]
|
|
250
|
+
strict_repeat_intervals.extend((r.start, r.end) for r in strict_runs(seq, 'TTAGGG', 2))
|
|
251
|
+
anchor = evaluate_anchor(length, start, end, cigar, is_reverse, strict_repeat_intervals)
|
|
252
|
+
distance, status = common_distance(intervals, start, end, anchor)
|
|
253
|
+
clip = clipping(cigar, is_reverse)
|
|
254
|
+
offset = clip['query_forward_offset']
|
|
255
|
+
overlaps = overlap_flags(intervals)
|
|
256
|
+
phases = phases or ['']*len(intervals)
|
|
257
|
+
rows = []
|
|
258
|
+
for i, ((s, e), phase) in enumerate(zip(intervals, phases), 1):
|
|
259
|
+
left_cut = any(r.start < s == start < r.end for r in full_weak)
|
|
260
|
+
right_cut = any(r.start < e == end < r.end for r in full_weak)
|
|
261
|
+
mean_q, min_q = quality_diagnostics(qualities, s, e)
|
|
262
|
+
rows.append(dict(read_id=read_id, record_id=record_id, tract_id=i,
|
|
263
|
+
query_start_forward=s+offset, query_end_forward=e+offset,
|
|
264
|
+
span_bp=e-s, supported_bp=e-s,
|
|
265
|
+
nonoverlapping_units=(e-s)//6 if strict_definition else None,
|
|
266
|
+
mean_q=mean_q, min_q=min_q, boundary_left_truncated=bool(left_cut),
|
|
267
|
+
boundary_right_truncated=bool(right_cut), touches_telomere_start=s == start,
|
|
268
|
+
touches_telomere_end=e == end, touches_query_start=s == 0, touches_query_end=e == length,
|
|
269
|
+
sequence_left_missing=clip['sequence_left_missing'], sequence_right_missing=clip['sequence_right_missing'],
|
|
270
|
+
overlap_flag=overlaps[i-1], phase_template=phase))
|
|
271
|
+
local_weak = strict_runs(seq[start:end], motif, 1)
|
|
272
|
+
weak_intervals = [(r.start+start, r.end+start) for r in local_weak if r.end-r.start < 6*min_units]
|
|
273
|
+
# Includes extraction-boundary fragments shorter than one complete motif.
|
|
274
|
+
fragments = [(max(start, r.start), min(end, r.end)) for r in full_weak if r.start < end and r.end > start]
|
|
275
|
+
weak_intervals += [(s, e) for s, e in fragments if 0 < e-s < 6*min_units]
|
|
276
|
+
side = anchor['telomeric_query_side']
|
|
277
|
+
proximal_region = ((start, min(s for s, e in intervals)) if intervals else (start, end)) if side == 'right' else (
|
|
278
|
+
(max(e for s, e in intervals), end) if intervals else (start, end))
|
|
279
|
+
weak_flag = (any(s < proximal_region[1] and e > proximal_region[0] for s, e in weak_intervals)
|
|
280
|
+
if side in ('left', 'right') else None)
|
|
281
|
+
boundary_truncated = any(r.start < start < r.end or r.start < end < r.end for r in full_weak)
|
|
282
|
+
boundary_truncated |= any((r.start == 0 and clip['sequence_left_missing']) or
|
|
283
|
+
(r.end == length and clip['sequence_right_missing']) for r in full_weak
|
|
284
|
+
if r.start < end and r.end > start)
|
|
285
|
+
coverage = union_bp(intervals)
|
|
286
|
+
public = dict(variant_motif=motif, variant_present=bool(intervals), variant_tract_count=len(intervals),
|
|
287
|
+
variant_supported_bp=coverage, variant_repeat_equivalents=coverage/6,
|
|
288
|
+
pre_variant_bp=distance, pre_variant_status=status, anchor_status=anchor['anchor_status'],
|
|
289
|
+
telomeric_query_side=side, weak_proximal_variant_evidence=weak_flag,
|
|
290
|
+
variant_boundary_truncated=bool(boundary_truncated), variant_overlap_flag=any(overlaps))
|
|
291
|
+
details = {**clip, **anchor, 'query_start_forward': start+offset, 'query_end_forward': end+offset,
|
|
292
|
+
'query_available_start_forward': offset, 'query_available_end_forward': length+offset,
|
|
293
|
+
'proximal_boundary_forward': (anchor['proximal_boundary_local']+offset
|
|
294
|
+
if anchor['proximal_boundary_local'] is not None else None),
|
|
295
|
+
'local_query_start': start, 'local_query_end': end,
|
|
296
|
+
'missing_sequence': False, 'missing_quality': qualities is None,
|
|
297
|
+
'native_subtelomeric_junction_verified': False}
|
|
298
|
+
return public, rows, details
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def summarize_variant(seq, qualities, start, end, motif, min_units, read_data):
|
|
302
|
+
# Requalification uses only the final extracted telomere, never outside support.
|
|
303
|
+
local = strict_runs(seq[start:end], motif, min_units)
|
|
304
|
+
intervals = [(r.start+start, r.end+start) for r in local]
|
|
305
|
+
return characterize_intervals(seq, qualities, start, end, intervals, motif,
|
|
306
|
+
read_data.get('_variant_cigar', []), read_data['is_reverse'],
|
|
307
|
+
read_data['query_name'], read_data['_ranking_fields']['record_digest'],
|
|
308
|
+
min_units=min_units, phases=[r.phase for r in local], strict_definition=True)
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
__version__ = '1.01'
|
|
2
|
-
__author__ = 'Santiago E Sanchez'
|
|
3
|
-
__email__ = 'santy.esanchez@gmail.com'
|
|
4
|
-
__license__ = 'MIT'
|
|
5
|
-
|
|
6
|
-
import logging
|
|
7
|
-
from .telometer import (
|
|
8
|
-
process_bam_file,
|
|
9
|
-
measure_telomere_length,
|
|
10
|
-
identify_telomere_regions,
|
|
11
|
-
get_telomere_repeats,
|
|
12
|
-
reverse_complement,
|
|
13
|
-
process_read_wrapper,
|
|
14
|
-
process_read,
|
|
15
|
-
detect_discontinuities,
|
|
16
|
-
check_gap,
|
|
17
|
-
run_telometer
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
21
|
-
|
|
22
|
-
__all__ = [
|
|
23
|
-
'process_bam_file',
|
|
24
|
-
'measure_telomere_length',
|
|
25
|
-
'identify_telomere_regions',
|
|
26
|
-
'get_telomere_repeats',
|
|
27
|
-
'reverse_complement',
|
|
28
|
-
'process_read_wrapper',
|
|
29
|
-
'process_read',
|
|
30
|
-
'detect_discontinuities',
|
|
31
|
-
'check_gap',
|
|
32
|
-
'run_telometer'
|
|
33
|
-
]
|
|
34
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|