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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telometer
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Quantitative digital telomere measurement from nanopore long-reads
5
5
  Author-email: "Santiago E. Sanchez" <santy.esanchez@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "telometer"
7
- version = "2.0.0"
7
+ version = "2.0.1"
8
8
  description = "Quantitative digital telomere measurement from nanopore long-reads"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telometer
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Quantitative digital telomere measurement from nanopore long-reads
5
5
  Author-email: "Santiago E. Sanchez" <santy.esanchez@gmail.com>
6
6
  License-Expression: MIT
@@ -1,7 +1,8 @@
1
1
  README.md
2
2
  pyproject.toml
3
- telometer/__init__.py
4
3
  telometer/telometer.py
4
+ telometer/telometer_variant_support.py
5
+ telometer/variant_support_v2.py
5
6
  telometer.egg-info/PKG-INFO
6
7
  telometer.egg-info/SOURCES.txt
7
8
  telometer.egg-info/dependency_links.txt
@@ -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