telometer 0.95__tar.gz → 1.0__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.1
2
2
  Name: telometer
3
- Version: 0.95
3
+ Version: 1.0
4
4
  Summary: a simple regular expression based method for measuring individual, chromosome-specific telomere lengths from long-read sequencing data
5
5
  Author: Santiago E Sanchez
6
6
  Author-email: ses94@stanford.edu
@@ -2,7 +2,7 @@ import setuptools
2
2
 
3
3
  setuptools.setup(
4
4
  name="telometer",
5
- version="0.95",
5
+ version="1.0",
6
6
  author="Santiago E Sanchez",
7
7
  author_email="ses94@stanford.edu",
8
8
  description="a simple regular expression based method for measuring individual, chromosome-specific telomere lengths from long-read sequencing data",
@@ -0,0 +1,34 @@
1
+ __version__ = '1.0'
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
+
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ # Telometer v1.0
3
+ # Created by: Santiago E Sanchez
4
+ # Artandi Lab, Stanford University, 2024
5
+ # Measures telomeres from ONT or PacBio long reads aligned to a T2T genome assembly
6
+ # Simple Usage: telometer -b sorted_t2t.bam -o output.tsv
7
+
8
+ import pysam
9
+ import regex as re
10
+ import pandas as pd
11
+ import time
12
+ import argparse
13
+ import sys
14
+ from multiprocessing import Pool, cpu_count
15
+ import numpy as np
16
+ from scipy.signal import savgol_filter
17
+
18
+ def reverse_complement(seq):
19
+ complement = {'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A', 'N': 'N'}
20
+ return "".join(complement[base] for base in reversed(seq))
21
+
22
+ def get_telomere_repeats():
23
+ telomere_repeats = ['CCCTAA', 'TTAGGG']
24
+ telomere_repeats_rc = [reverse_complement(repeat) for repeat in telomere_repeats]
25
+ return telomere_repeats + telomere_repeats_rc
26
+
27
+
28
+ def identify_telomere_regions(seq, base_qualities, telomere_motifs, window_size=120, step_size=12,
29
+ density_threshold=0.1, max_gap_length=250, quality_threshold=15):
30
+ telomere_regions = []
31
+ current_start = None
32
+ gap_length = 0
33
+ has_large_gap = False
34
+ densities = []
35
+
36
+ combined_motif_pattern = '|'.join(f'({motif})' for motif in telomere_motifs)
37
+
38
+ for i in range(0, len(seq) - window_size + 1, step_size):
39
+ window_seq = seq[i:i + window_size]
40
+ motif_count = len(re.findall(combined_motif_pattern, window_seq))
41
+ density = motif_count / (window_size / len(telomere_motifs[0])) # Assuming all motifs have similar lengths
42
+ densities.append(density)
43
+
44
+ if density >= density_threshold:
45
+ if current_start is None:
46
+ current_start = i
47
+ gap_length = 0
48
+ else:
49
+ if current_start is not None:
50
+ gap_length += step_size
51
+
52
+
53
+ gap_start = i - gap_length
54
+ gap_end = i
55
+ gap_quality = base_qualities[gap_start:gap_end]
56
+
57
+ if len(gap_quality) > 0:
58
+ avg_gap_quality = sum(gap_quality) / len(gap_quality)
59
+ else:
60
+ avg_gap_quality = 0
61
+
62
+
63
+ if avg_gap_quality < quality_threshold:
64
+ gap_length = 0
65
+ continue
66
+
67
+
68
+ if gap_length > max_gap_length:
69
+
70
+ mismatch_motif_count = len(re.findall(f"({combined_motif_pattern}){{e<=1}}", window_seq))
71
+ mismatch_density = mismatch_motif_count / (window_size / len(telomere_motifs[0]))
72
+
73
+ if mismatch_density >= density_threshold:
74
+
75
+ gap_length = 0
76
+ else:
77
+ has_large_gap = True
78
+ telomere_regions.append((current_start, i - gap_length + step_size))
79
+ current_start = None
80
+ gap_length = 0
81
+
82
+ if current_start is not None:
83
+ telomere_regions.append((current_start, len(seq)))
84
+
85
+ return telomere_regions, densities, has_large_gap, combined_motif_pattern
86
+
87
+
88
+ def detect_discontinuities(base_qualities, window_length=11, polyorder=2, gradient_threshold=-1):
89
+
90
+ if len(base_qualities) < window_length:
91
+ window_length = len(base_qualities) if len(base_qualities) % 2 == 1 else len(base_qualities) - 1 # Window length must be odd
92
+
93
+
94
+ if len(base_qualities) > polyorder:
95
+ smoothed_qualities = savgol_filter(base_qualities, window_length=window_length, polyorder=polyorder)
96
+ else:
97
+
98
+ smoothed_qualities = base_qualities
99
+
100
+
101
+ gradient = np.diff(smoothed_qualities)
102
+
103
+
104
+ discontinuities = np.where(gradient < gradient_threshold)[0]
105
+
106
+ return discontinuities, smoothed_qualities
107
+
108
+
109
+ def check_gap(seq, base_qualities, telomere_regions, gradient_threshold=-1, quality_threshold=15):
110
+ merged_regions = []
111
+ prev_end = None
112
+ current_start = None
113
+
114
+ for i, (start, end) in enumerate(telomere_regions):
115
+ if current_start is None:
116
+ current_start = start
117
+
118
+ if prev_end is not None:
119
+ gap_start = prev_end
120
+ gap_end = start
121
+ gap_quality = base_qualities[gap_start:gap_end]
122
+ discontinuities, smoothed_qualities = detect_discontinuities(gap_quality, gradient_threshold=gradient_threshold)
123
+ avg_gap_quality = sum(gap_quality) / len(gap_quality) if len(gap_quality) > 0 else 0
124
+ if avg_gap_quality < quality_threshold or len(discontinuities) == 0:
125
+ continue
126
+ merged_regions.append((current_start, end))
127
+ current_start = None
128
+ prev_end = end
129
+
130
+ return merged_regions
131
+
132
+ def measure_telomere_length(seq, telomere_motifs, base_qualities, window_size=120, step_size=12, density_threshold=0.1, max_gap_length=150, quality_threshold=15):
133
+ telomere_regions, densities, has_large_gap, combined_motif_pattern = identify_telomere_regions(
134
+ seq, base_qualities, telomere_motifs, window_size, step_size, density_threshold, max_gap_length, quality_threshold)
135
+
136
+ if not telomere_regions:
137
+ return 0, 0, 0, densities, has_large_gap, combined_motif_pattern
138
+
139
+ merged_telomere_regions = check_gap(seq, base_qualities, telomere_regions, gradient_threshold=-1, quality_threshold=quality_threshold)
140
+ terminus_regions = [region for region in merged_telomere_regions if region[0] < 100 or region[1] > len(seq) - 100]
141
+
142
+ if not terminus_regions:
143
+ return 0, 0, 0, densities, has_large_gap, combined_motif_pattern
144
+
145
+ telomere_start, telomere_end = terminus_regions[0]
146
+ telomere_length = telomere_end - telomere_start
147
+ return telomere_start, telomere_end, telomere_length, densities, has_large_gap, combined_motif_pattern
148
+
149
+ # New function to process a single read
150
+ def process_read(read_data, telomere_motifs, max_gap_length, min_read_len):
151
+ if read_data['is_unmapped'] or read_data['reference_name'] == 'chrM':
152
+ return None
153
+
154
+ if read_data['reference_start'] > 30000 and read_data['reference_end'] < read_data['reference_length'] - 30000:
155
+ return None
156
+
157
+ seq = read_data['query_sequence']
158
+ base_qualities = read_data['query_qualities'] # Extract base qualities here
159
+ if seq is None or len(seq) < min_read_len:
160
+ return None
161
+
162
+ alignment_start = read_data['reference_start']
163
+ alignment_end = read_data['reference_end']
164
+ reference_genome_length = read_data['reference_length']
165
+
166
+ if alignment_start < 15000 and alignment_end <= reference_genome_length - 30000:
167
+ arm = "p"
168
+ else:
169
+ arm = "q"
170
+
171
+ direction = "rev" if read_data['is_reverse'] else "fwd"
172
+
173
+
174
+ telomere_start, telomere_end, telomere_length, densities, has_large_gap, combined_motif_pattern = measure_telomere_length(
175
+ seq, telomere_motifs, base_qualities, max_gap_length=max_gap_length)
176
+
177
+ if telomere_length < 1:
178
+ return None
179
+
180
+ return {
181
+ 'chromosome': read_data['reference_name'],
182
+ 'reference_start': alignment_start,
183
+ 'reference_end': alignment_end,
184
+ 'telomere_length': telomere_length,
185
+ 'read_id': read_data['query_name'],
186
+ 'mapping_quality': read_data['mapping_quality'],
187
+ 'read_length': len(seq),
188
+ 'arm': arm,
189
+ 'direction': direction
190
+ }
191
+
192
+
193
+ def process_read_wrapper(args):
194
+ return process_read(*args)
195
+
196
+ def estimate_memory_usage(reads_list):
197
+ """
198
+ Estimate the memory usage of the reads in bytes.
199
+ Approximate size based on the size of sequences, base qualities, and metadata.
200
+ """
201
+ memory_usage = 0
202
+ for read in reads_list:
203
+ seq_len = len(read['query_sequence']) if read['query_sequence'] else 0
204
+ quality_len = len(read['query_qualities']) if read['query_qualities'] else 0
205
+ memory_usage += sys.getsizeof(read['query_name']) + seq_len + quality_len + sys.getsizeof(read)
206
+ return memory_usage
207
+
208
+
209
+ def process_bam_file(bam_file_path, output_file_path, max_gap_length=150, min_read_len=1000, num_processes=8, memory_limit_gb=8):
210
+ start_time = time.time()
211
+ bam_file = pysam.AlignmentFile(bam_file_path, "rb")
212
+ telomere_motifs = get_telomere_repeats()
213
+
214
+ results = []
215
+ read_results = {}
216
+ total_reads = 0
217
+ memory_limit_bytes = memory_limit_gb * (1024 ** 3)
218
+
219
+ batch = []
220
+ batch_memory = 0
221
+
222
+ def process_batch(batch_data):
223
+ nonlocal total_reads, read_results
224
+
225
+ with Pool(processes=num_processes) as pool:
226
+ for result in pool.imap_unordered(process_read_wrapper, [(rd, telomere_motifs, max_gap_length, min_read_len) for rd in batch_data]):
227
+ if result:
228
+ total_reads += 1
229
+ existing_result = read_results.get(result['read_id'])
230
+ if existing_result:
231
+ if (result['mapping_quality'] > existing_result['mapping_quality'] or
232
+ (result['mapping_quality'] == existing_result['mapping_quality'] and
233
+ result['telomere_length'] > existing_result['telomere_length'])):
234
+ read_results[result['read_id']] = result
235
+ else:
236
+ read_results[result['read_id']] = result
237
+
238
+ batch_data.clear()
239
+
240
+ for read in bam_file:
241
+ if read.reference_name is not None and read.reference_name != 'chrM':
242
+ read_data = {
243
+ 'query_name': read.query_name,
244
+ 'is_unmapped': read.is_unmapped,
245
+ 'is_reverse': read.is_reverse,
246
+ 'reference_start': read.reference_start,
247
+ 'reference_end': read.reference_end,
248
+ 'reference_name': read.reference_name,
249
+ 'mapping_quality': read.mapping_quality,
250
+ 'query_sequence': read.query_sequence,
251
+ 'query_qualities': read.query_qualities,
252
+ 'reference_length': bam_file.get_reference_length(read.reference_name) if read.reference_name is not None else None
253
+ }
254
+
255
+
256
+ read_memory = estimate_memory_usage([read_data])
257
+ batch_memory += read_memory
258
+ batch.append(read_data)
259
+
260
+
261
+ if batch_memory >= memory_limit_bytes:
262
+ print(f"Processing batch of {len(batch)} reads, approx memory used: {batch_memory / (1024 ** 3):.2f} GB")
263
+ process_batch(batch)
264
+ batch_memory = 0
265
+
266
+
267
+ if batch:
268
+ print(f"Processing final batch of {len(batch)} reads")
269
+ process_batch(batch)
270
+
271
+ bam_file.close()
272
+
273
+
274
+ results_df = pd.DataFrame(list(read_results.values()))
275
+ results_df.to_csv(output_file_path, sep='\t', index=False)
276
+
277
+ print(f"Telometer completed successfully. Total telomeres measured: {len(read_results)}")
278
+ print(f"Total processing time: {time.time() - start_time:.2f} seconds")
279
+
280
+
281
+ def run_telometer():
282
+ parser = argparse.ArgumentParser(description='Calculate telomere length from a BAM file.')
283
+ parser.add_argument('-b', '--bam', help='The path to the sorted BAM file.', required=True)
284
+ parser.add_argument('-o', '--output', help='The path to the output file.', required=True)
285
+ parser.add_argument('-m', '--minreadlen', default=1000, type=int, help='Minimum read length to consider (Default: 1000 for telomere capture, use 4000 for WGS). Optional', required=False)
286
+ parser.add_argument('-g', '--maxgaplen', default=250, type=int, help='Maximum allowed gap length between telomere regions. Optional', required=False)
287
+ parser.add_argument('-t', '--threads', default=cpu_count(), type=int, help='Number of processing threads to use. Optional', required=False)
288
+ args = parser.parse_args()
289
+ process_bam_file(args.bam, args.output, max_gap_length=args.maxgaplen, min_read_len=args.minreadlen, num_processes=args.threads)
290
+
291
+ if __name__ == "__main__":
292
+ run_telometer()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: telometer
3
- Version: 0.95
3
+ Version: 1.0
4
4
  Summary: a simple regular expression based method for measuring individual, chromosome-specific telomere lengths from long-read sequencing data
5
5
  Author: Santiago E Sanchez
6
6
  Author-email: ses94@stanford.edu
@@ -1,19 +0,0 @@
1
-
2
- __version__ = '0.95'
3
- __author__ = 'Santiago E Sanchez'
4
- __email__ = 'santy.esanchez@gmail.com'
5
- __license__ = 'MIT'
6
-
7
- import logging
8
- from .telometer import (
9
- process_bam_file,
10
- measure_telomere_length,
11
- identify_telomere_regions,
12
- get_telomere_repeats,
13
- reverse_complement,
14
- process_read_wrapper,
15
- process_read,
16
- run_telometer
17
- )
18
-
19
-
@@ -1,207 +0,0 @@
1
- #!/usr/bin/env python3
2
- # Telometer v0.9
3
- # Created by: Santiago E Sanchez
4
- # Artandi Lab, Stanford University, 2024
5
- # Measures telomeres from ONT or PacBio long reads aligned to a T2T genome assembly
6
- # Simple Usage: telometer -b sorted_t2t.bam -o output.tsv
7
-
8
- import pysam
9
- import regex as re
10
- import pandas as pd
11
- import time
12
- import argparse
13
- from multiprocessing import Pool, cpu_count
14
-
15
-
16
- def reverse_complement(seq):
17
- complement = {'A': 'T', 'C': 'G', 'G': 'C', 'T': 'A', 'N': 'N'}
18
- return "".join(complement[base] for base in reversed(seq))
19
-
20
-
21
- def get_telomere_repeats():
22
- telomere_repeats = ['CCCTAA', 'TTAGGG']
23
- telomere_repeats_rc = [reverse_complement(repeat) for repeat in telomere_repeats]
24
- return telomere_repeats + telomere_repeats_rc
25
-
26
-
27
- def identify_telomere_regions(seq, telomere_motifs, window_size=100, step_size=10, density_threshold=0.1, max_gap_length=50):
28
- telomere_regions = []
29
- current_start = None
30
- gap_length = 0
31
- has_large_gap = False
32
- densities = []
33
-
34
- combined_motif_pattern = '|'.join(f'({motif})' for motif in telomere_motifs)
35
-
36
- for i in range(0, len(seq) - window_size + 1, step_size):
37
- window_seq = seq[i:i + window_size]
38
- motif_count = len(re.findall(combined_motif_pattern, window_seq))
39
- density = motif_count / (window_size / len(telomere_motifs[0])) # Assuming all motifs have similar lengths
40
- densities.append(density)
41
-
42
- if density >= density_threshold:
43
- if current_start is None:
44
- current_start = i
45
- gap_length = 0
46
- else:
47
- if current_start is not None:
48
- gap_length += step_size
49
- if gap_length > max_gap_length:
50
-
51
- mismatch_motif_count = len(re.findall(f"({combined_motif_pattern}){{e<=1}}", window_seq))
52
- mismatch_density = mismatch_motif_count / (window_size / len(telomere_motifs[0]))
53
-
54
- if mismatch_density >= density_threshold:
55
-
56
- gap_length = 0
57
- else:
58
- has_large_gap = True
59
- telomere_regions.append((current_start, i - gap_length + step_size))
60
- current_start = None
61
- gap_length = 0
62
-
63
- if current_start is not None:
64
- telomere_regions.append((current_start, len(seq)))
65
-
66
- return telomere_regions, densities, has_large_gap, combined_motif_pattern
67
-
68
-
69
- def measure_telomere_length(seq, telomere_motifs, window_size=100, step_size=10, density_threshold=0.1, max_gap_length=50):
70
- telomere_regions, densities, has_large_gap, combined_motif_pattern = identify_telomere_regions(seq, telomere_motifs, window_size, step_size, density_threshold, max_gap_length)
71
-
72
- if not telomere_regions:
73
- return 0, 0, 0, densities, has_large_gap, combined_motif_pattern
74
-
75
-
76
- terminus_regions = []
77
- for start, end in telomere_regions:
78
- if start < 100 or end > len(seq) - 100:
79
- terminus_regions.append((start, end))
80
-
81
- if not terminus_regions:
82
- return 0, 0, 0, densities, has_large_gap, combined_motif_pattern
83
-
84
-
85
- telomere_start, telomere_end = terminus_regions[0]
86
- telomere_length = telomere_end - telomere_start
87
-
88
- return telomere_start, telomere_end, telomere_length, densities, has_large_gap, combined_motif_pattern
89
-
90
-
91
- def process_read(read_data, telomere_motifs, max_gap_length, min_read_len):
92
- if read_data['is_unmapped'] or read_data['reference_name'] == 'chrM':
93
- return None
94
-
95
- if read_data['reference_start'] > 30000 and read_data['reference_end'] < read_data['reference_length'] - 30000:
96
- return None
97
-
98
- seq = read_data['query_sequence']
99
- if seq is None or len(seq) < min_read_len:
100
- return None
101
-
102
-
103
- alignment_start = read_data['reference_start']
104
- alignment_end = read_data['reference_end']
105
- reference_genome_length = read_data['reference_length']
106
-
107
- if alignment_start < 15000 and alignment_end <= reference_genome_length - 30000:
108
- arm = "p"
109
- else:
110
- arm = "q"
111
-
112
- direction = "rev" if read_data['is_reverse'] else "fwd"
113
-
114
-
115
- telomere_start, telomere_end, telomere_length, densities, has_large_gap, combined_motif_pattern = measure_telomere_length(seq, telomere_motifs, max_gap_length=max_gap_length)
116
- if telomere_length < 1:
117
- return None
118
- return {
119
- 'chromosome': read_data['reference_name'],
120
- 'reference_start': alignment_start,
121
- 'reference_end': alignment_end,
122
- 'telomere_length': telomere_length,
123
- #'subtel_boundary_length': telomere_end - telomere_start,
124
- 'read_id': read_data['query_name'],
125
- 'mapping_quality': read_data['mapping_quality'],
126
- 'read_length': len(seq),
127
- 'arm': arm,
128
- 'direction': direction
129
- #'has_large_gap': has_large_gap
130
- }
131
-
132
-
133
- def process_read_wrapper(args):
134
- return process_read(*args)
135
-
136
-
137
- def process_bam_file(bam_file_path, output_file_path, max_gap_length=100, min_read_len=1000, num_processes=8):
138
- start_time = time.time()
139
-
140
-
141
- bam_file = pysam.AlignmentFile(bam_file_path, "rb")
142
-
143
- telomere_motifs = get_telomere_repeats()
144
-
145
- results = []
146
- read_results = {}
147
- total_reads = 0
148
- large_gap_count = 0
149
-
150
-
151
- read_data_list = [{
152
- 'query_name': read.query_name,
153
- 'is_unmapped': read.is_unmapped,
154
- 'is_reverse': read.is_reverse,
155
- 'reference_start': read.reference_start,
156
- 'reference_end': read.reference_end,
157
- 'reference_name': read.reference_name,
158
- 'mapping_quality': read.mapping_quality,
159
- 'query_sequence': read.query_sequence,
160
- 'reference_length': bam_file.get_reference_length(read.reference_name) if read.reference_name is not None else None
161
- } for read in bam_file if read.reference_name is not None and read.reference_name != 'chrM']
162
-
163
- # Use multiprocessing Pool for parallel processing
164
- with Pool(processes=num_processes) as pool:
165
- for result in pool.imap_unordered(process_read_wrapper, [(rd, telomere_motifs, max_gap_length, min_read_len) for rd in read_data_list]):
166
- if result:
167
- total_reads += 1
168
- #if result['has_large_gap']:
169
- # large_gap_count += 1
170
-
171
- # Check for existing results with the same read_id
172
- existing_result = read_results.get(result['read_id'])
173
- if existing_result:
174
- if (result['mapping_quality'] > existing_result['mapping_quality'] or
175
- (result['mapping_quality'] == existing_result['mapping_quality'] and
176
- result['telomere_length'] > existing_result['telomere_length'])):
177
- read_results[result['read_id']] = result
178
- else:
179
- read_results[result['read_id']] = result
180
-
181
- bam_file.close()
182
-
183
- # Convert results to a DataFrame and save to a TSV file
184
- results_df = pd.DataFrame(list(read_results.values()))
185
- results_df.to_csv(output_file_path, sep='\t', index=False)
186
-
187
- # Calculate and print the percentage of telomeres with gaps larger than max_gap_length
188
- #if total_reads > 0:
189
- # large_gap_percentage = (large_gap_count / total_reads) * 100
190
- #else:
191
- # large_gap_percentage = 0
192
-
193
- print(f"Telometer completed successfully. Total telomeres measured: {len(read_results)}")
194
- print(f"Total processing time: {time.time() - start_time:.2f} seconds")
195
-
196
- def run_telometer():
197
- parser = argparse.ArgumentParser(description='Calculate telomere length from a BAM file.')
198
- parser.add_argument('-b', '--bam', help='The path to the sorted BAM file.', required=True)
199
- parser.add_argument('-o', '--output', help='The path to the output file.', required=True)
200
- parser.add_argument('-m', '--minreadlen', default=1000, type=int, help='Minimum read length to consider (Default: 1000 for telomere capture, use 4000 for WGS). Optional', required=False)
201
- parser.add_argument('-g', '--maxgaplen', default=100, type=int, help='Maximum allowed gap length between telomere regions. Optional', required=False)
202
- parser.add_argument('-t', '--threads', default=cpu_count(), type=int, help='Number of processing threads to use. Optional', required=False)
203
- args = parser.parse_args()
204
- process_bam_file(args.bam, args.output, max_gap_length=args.maxgaplen, min_read_len=args.minreadlen, num_processes=args.threads)
205
-
206
- if __name__ == "__main__":
207
- run_telometer()
File without changes
File without changes
File without changes