telometer 2.0.0__tar.gz → 2.0.2__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.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2024 The Python Packaging Authority
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.2
2
+ Name: telometer
3
+ Version: 2.0.2
4
+ Summary: Quantitative digital telomere measurement from nanopore long reads
5
+ Author-email: "Santiago E. Sanchez" <santy.esanchez@gmail.com>
6
+ License: Copyright (c) 2024 The Python Packaging Authority
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+
26
+ Classifier: Development Status :: 4 - Beta
27
+ Classifier: Intended Audience :: Science/Research
28
+ Classifier: License :: OSI Approved :: MIT License
29
+ Classifier: Operating System :: OS Independent
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.8
32
+ Classifier: Programming Language :: Python :: 3.9
33
+ Classifier: Programming Language :: Python :: 3.10
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
37
+ Requires-Python: >=3.8
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE.txt
40
+ Requires-Dist: numpy
41
+ Requires-Dist: pandas
42
+ Requires-Dist: pysam
43
+ Requires-Dist: regex
44
+ Requires-Dist: scipy
45
+
46
+ A simple tool for measuring chromosome-specific telomeres from long-read alignments.
47
+
48
+ [Telometer Github](https://github.com/santiago-es/Telometer)
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = [
3
+ "setuptools>=61,<76",
4
+ "wheel"
5
+ ]
6
+ build-backend = "setuptools.build_meta"
7
+
8
+
9
+ [project]
10
+ name = "telometer"
11
+ version = "2.0.2"
12
+ description = "Quantitative digital telomere measurement from nanopore long reads"
13
+ readme = { file = "README.md", content-type = "text/markdown" }
14
+ requires-python = ">=3.8"
15
+ license = { file = "LICENSE.txt" }
16
+
17
+ authors = [
18
+ { name = "Santiago E. Sanchez", email = "santy.esanchez@gmail.com" }
19
+ ]
20
+
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Science/Research",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Operating System :: OS Independent",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.8",
28
+ "Programming Language :: Python :: 3.9",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Topic :: Scientific/Engineering :: Bio-Informatics"
33
+ ]
34
+
35
+ dependencies = [
36
+ "numpy",
37
+ "pandas",
38
+ "pysam",
39
+ "regex",
40
+ "scipy"
41
+ ]
42
+
43
+
44
+ [project.scripts]
45
+ telometer = "telometer.telometer:run_telometer"
46
+
47
+
48
+ [tool.setuptools]
49
+ include-package-data = true
50
+
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["."]
54
+ include = ["telometer", "telometer.*"]
55
+ exclude = ["build", "build.*", "dist", "dist.*"]
@@ -0,0 +1,3 @@
1
+ """Telometer package."""
2
+
3
+ __version__ = "2.0.2"
@@ -1,24 +1,19 @@
1
1
  #!/usr/bin/env python3
2
- # Telometer v2.0: eight-feature baseline with optional variant detection and enrichment
3
- # Baseline changes retained: 01, 03, 07, 11, 12, 14, 15, 16
2
+ # Telometer v2.0
3
+
4
4
  # Created by: Santiago E Sanchez
5
5
  # Artandi Lab, Stanford University, 2024
6
- # Measures telomeres from ONT or PacBio long reads aligned to a T2T genome assembly
6
+
7
+ # Measures telomeres from ONT long reads
8
+
7
9
  # Simple Usage: telometer -b sorted_t2t.bam -o output.tsv
8
- # Optional enrichment: add -e to your existing Telometer command.
9
- # -e adds the sample-level telomeres_per_gb column and console totals. Each QNAME
10
- # contributes one consistent recorded full length, including soft/hard clips,
11
- # before any Telometer filter. Missing/conflicting lengths yield NA, not a
12
- # partial-denominator rate. This measures yield represented by the input BAM;
13
- # reads/bases removed upstream without retained metadata cannot be recovered.
14
- # Memory for -e scales with unique read IDs; -l is not a total-RSS limit.
15
10
 
16
- __version__ = "2.0"
11
+ __version__ = "2.0.2"
17
12
 
18
13
  import pysam
19
14
  from pathlib import Path
20
- from variant_support_v2 import (BASE_COLUMNS, VARIANT_COLUMNS, TRACT_COLUMNS,
21
- validate_variant_options, strict_runs, coverage_prefix, combined_window_density, summarize_variant)
15
+ #from variant_support_v2 import (BASE_COLUMNS, VARIANT_COLUMNS, TRACT_COLUMNS,
16
+ # validate_variant_options, strict_runs, coverage_prefix, combined_window_density, summarize_variant)
22
17
  import regex as re
23
18
  import pandas as pd
24
19
  import time
@@ -27,7 +22,27 @@ import sys
27
22
  from multiprocessing import Pool, cpu_count
28
23
  import numpy as np
29
24
  from scipy.signal import savgol_filter
30
- from telometer_variant_support import alignment_fields, cached_findall, note, reverse_complement_iupac, stable_record_key
25
+ #from telometer_variant_support import alignment_fields, cached_findall, note, reverse_complement_iupac, stable_record_key
26
+
27
+ from .variant_support_v2 import (
28
+ BASE_COLUMNS,
29
+ VARIANT_COLUMNS,
30
+ TRACT_COLUMNS,
31
+ validate_variant_options,
32
+ strict_runs,
33
+ coverage_prefix,
34
+ combined_window_density,
35
+ summarize_variant,
36
+ )
37
+
38
+ from .telometer_variant_support import (
39
+ alignment_fields,
40
+ cached_findall,
41
+ note,
42
+ reverse_complement_iupac,
43
+ stable_record_key,
44
+ )
45
+
31
46
 
32
47
  def reverse_complement(seq):
33
48
  complement = {'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A', 'N': 'N'}
@@ -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)
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.2
2
+ Name: telometer
3
+ Version: 2.0.2
4
+ Summary: Quantitative digital telomere measurement from nanopore long reads
5
+ Author-email: "Santiago E. Sanchez" <santy.esanchez@gmail.com>
6
+ License: Copyright (c) 2024 The Python Packaging Authority
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+
26
+ Classifier: Development Status :: 4 - Beta
27
+ Classifier: Intended Audience :: Science/Research
28
+ Classifier: License :: OSI Approved :: MIT License
29
+ Classifier: Operating System :: OS Independent
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.8
32
+ Classifier: Programming Language :: Python :: 3.9
33
+ Classifier: Programming Language :: Python :: 3.10
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
37
+ Requires-Python: >=3.8
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE.txt
40
+ Requires-Dist: numpy
41
+ Requires-Dist: pandas
42
+ Requires-Dist: pysam
43
+ Requires-Dist: regex
44
+ Requires-Dist: scipy
45
+
46
+ A simple tool for measuring chromosome-specific telomeres from long-read alignments.
47
+
48
+ [Telometer Github](https://github.com/santiago-es/Telometer)
@@ -1,9 +1,13 @@
1
+ LICENSE.txt
1
2
  README.md
2
3
  pyproject.toml
3
4
  telometer/__init__.py
4
5
  telometer/telometer.py
6
+ telometer/telometer_variant_support.py
7
+ telometer/variant_support_v2.py
5
8
  telometer.egg-info/PKG-INFO
6
9
  telometer.egg-info/SOURCES.txt
7
10
  telometer.egg-info/dependency_links.txt
11
+ telometer.egg-info/entry_points.txt
8
12
  telometer.egg-info/requires.txt
9
13
  telometer.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ telometer = telometer.telometer:run_telometer
@@ -0,0 +1,5 @@
1
+ numpy
2
+ pandas
3
+ pysam
4
+ regex
5
+ scipy
telometer-2.0.0/PKG-INFO DELETED
@@ -1,16 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: telometer
3
- Version: 2.0.0
4
- Summary: Quantitative digital telomere measurement from nanopore long-reads
5
- Author-email: "Santiago E. Sanchez" <santy.esanchez@gmail.com>
6
- License-Expression: MIT
7
- Classifier: Programming Language :: Python :: 3
8
- Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.7
10
- Description-Content-Type: text/markdown
11
- Requires-Dist: requests
12
- Requires-Dist: numpy
13
-
14
- A simple tool for measuring chromosome-specific telomeres from long-read alignments.
15
-
16
- [Telometer Github](https://github.com/santiago-es/Telometer)
@@ -1,31 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=77.0.3", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "telometer"
7
- version = "2.0.0"
8
- description = "Quantitative digital telomere measurement from nanopore long-reads"
9
- readme = "README.md"
10
- requires-python = ">=3.7"
11
- license = "MIT"
12
- license-files = ["LICENSE"]
13
-
14
- authors = [
15
- { name = "Santiago E. Sanchez", email = "santy.esanchez@gmail.com" }
16
- ]
17
-
18
- classifiers = [
19
- "Programming Language :: Python :: 3",
20
- "Operating System :: OS Independent"
21
- ]
22
-
23
- dependencies = [
24
- "requests",
25
- "numpy"
26
- ]
27
-
28
- [tool.setuptools.packages.find]
29
- where = ["."]
30
- include = ["telometer*"]
31
- exclude = ["build*", "dist*"]
@@ -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
-
@@ -1,16 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: telometer
3
- Version: 2.0.0
4
- Summary: Quantitative digital telomere measurement from nanopore long-reads
5
- Author-email: "Santiago E. Sanchez" <santy.esanchez@gmail.com>
6
- License-Expression: MIT
7
- Classifier: Programming Language :: Python :: 3
8
- Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.7
10
- Description-Content-Type: text/markdown
11
- Requires-Dist: requests
12
- Requires-Dist: numpy
13
-
14
- A simple tool for measuring chromosome-specific telomeres from long-read alignments.
15
-
16
- [Telometer Github](https://github.com/santiago-es/Telometer)
@@ -1,2 +0,0 @@
1
- requests
2
- numpy
File without changes
File without changes