its-fun-2-map 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- its_fun_2_map/UNITEd.py +1323 -0
- its_fun_2_map/__init__.py +17 -0
- its_fun_2_map/assembly_module.py +514 -0
- its_fun_2_map/blast_output_parser.py +1097 -0
- its_fun_2_map/blast_round1.py +102 -0
- its_fun_2_map/blast_round1_parser.py +818 -0
- its_fun_2_map/blast_round2.py +160 -0
- its_fun_2_map/fastp_module.py +556 -0
- its_fun_2_map/its_a_summary_compiler.py +599 -0
- its_fun_2_map/its_decision_making.R +159 -0
- its_fun_2_map/its_fun_tools.py +148 -0
- its_fun_2_map/its_primer_binding.py +576 -0
- its_fun_2_map/mapping_module.py +584 -0
- its_fun_2_map/parse_fastp_json.R +56 -0
- its_fun_2_map/pull_ncbi_lineage.py +103 -0
- its_fun_2_map-1.0.0.dist-info/METADATA +664 -0
- its_fun_2_map-1.0.0.dist-info/RECORD +21 -0
- its_fun_2_map-1.0.0.dist-info/WHEEL +5 -0
- its_fun_2_map-1.0.0.dist-info/entry_points.txt +12 -0
- its_fun_2_map-1.0.0.dist-info/licenses/LICENSE +21 -0
- its_fun_2_map-1.0.0.dist-info/top_level.txt +1 -0
its_fun_2_map/UNITEd.py
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
UNITE to NCBI taxonomy matcher & sequence retriever
|
|
4
|
+
|
|
5
|
+
This script:
|
|
6
|
+
1. Parsing UNITE to extract taxonomy and NCBI lineage information for input taxids
|
|
7
|
+
2. Matching NCBI taxonomy against UNITE taxonomy with fallback strategy
|
|
8
|
+
3. Extracting the corresponding sequence for each taxon based on user parameters( --all, --number, --tax_rank, --traverse, --diversity)
|
|
9
|
+
4. Outputs FASTA files of reference UNITE sequences per sample in the input tracking sheet
|
|
10
|
+
|
|
11
|
+
Basic usage:
|
|
12
|
+
python UNITEd.py --tracking_sheet samples.csv --unite_db unite.fasta --output output_dir --email your@email.com --api your_api_key
|
|
13
|
+
|
|
14
|
+
Required Arguments:
|
|
15
|
+
--tracking_sheet: CSV file with minimum 2 columns: unique IDs and valid taxonimic IDs. See help for --id_header and --taxid_header for more detailed information"
|
|
16
|
+
--unite_db: Local UNITE FASTA database file
|
|
17
|
+
--output: Output directory for results
|
|
18
|
+
--email: Email address (required by NCBI Entrez API)
|
|
19
|
+
|
|
20
|
+
Optional Arguments:
|
|
21
|
+
--api: NCBI API key (increases query limits)
|
|
22
|
+
--all: Retrieve ALL sequences for each match instead of just the longest (with fallback, (species->genus->family->order->class->phylum)
|
|
23
|
+
--number N: Retrieve exactly N sequences for each match (overrides --all)
|
|
24
|
+
--tax_rank RANK: Search only at specific rank (species, genus, family, order, class, phylum) without fallback
|
|
25
|
+
--traverse RANK: Traverse up to specified rank if '--number N' cannot be found at the specified '--tax_rank [rank]' (requires both --tax_rank and --number)
|
|
26
|
+
--diversity: Distribute sequences across child taxa, prioritising target species matches first (requires --number and --tax_rank)
|
|
27
|
+
--summary_csv FILENAME: Generate detailed CSV summary with specified filename (placed in output directory)
|
|
28
|
+
--id_header: Optional argument to specify column in input --tracking_sheet that refers to unique sample ID
|
|
29
|
+
--taxid_header: Optional argument to specify column in input --tracking_sheet that refers to valid taxonomic IDs
|
|
30
|
+
--version: Show version information
|
|
31
|
+
|
|
32
|
+
Output:
|
|
33
|
+
Creates files named {sample_id}_seed.fasta in the specified output directory using a flat directory structure.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
import csv
|
|
39
|
+
import argparse
|
|
40
|
+
import re
|
|
41
|
+
import traceback
|
|
42
|
+
from collections import defaultdict
|
|
43
|
+
from Bio import Entrez
|
|
44
|
+
from datetime import datetime
|
|
45
|
+
from seqpy_tools import setup_logging
|
|
46
|
+
from .its_fun_tools import get_ncbi_lineage, normalise_taxon_name
|
|
47
|
+
from . import __version__
|
|
48
|
+
|
|
49
|
+
# Increase time between and number of tries used by entrez (from go_fetch.py)
|
|
50
|
+
Entrez.sleep_between_tries = 20
|
|
51
|
+
Entrez.max_tries = 20
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def setup_argument_parser():
|
|
55
|
+
"""
|
|
56
|
+
Set up argument parser with traverse and diversity functionality.
|
|
57
|
+
"""
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
description="Extract sequences from UNITE FASTA based on NCBI taxonomy matching",
|
|
60
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
61
|
+
epilog="""
|
|
62
|
+
Example usage:
|
|
63
|
+
python unite_fetch.py --tracking_sheet samples.csv --unite_db unite_db.fasta --output results --email your@email.com --api your_api_key
|
|
64
|
+
|
|
65
|
+
# Use a custom ID column name:
|
|
66
|
+
python UNITEd.py --tracking_sheet samples.csv --unite_db unite_db.fasta --output results --email your@email.com --id-header "MyCustomID"
|
|
67
|
+
|
|
68
|
+
# Get 20 sequences distributed across species within a genus:
|
|
69
|
+
python UNITEd.py --tracking_sheet samples.csv --unite_db unite_db.fasta --output results --email your@email.com --api your_api_key --tax_rank genus --number 20 --diversity
|
|
70
|
+
|
|
71
|
+
# Get 20 sequences with diversity, traverse up to family if insufficient child taxa:
|
|
72
|
+
python UNITEd.py --tracking_sheet samples.csv --unite_db unite_db.fasta --output results --email your@email.com --api your_api_key --tax_rank genus --number 20 --diversity --traverse family
|
|
73
|
+
|
|
74
|
+
# Get 20 sequences starting at genus, traverse up to order if needed (no diversity):
|
|
75
|
+
python UNITEd.py --tracking_sheet samples.csv --unite_db unite_db.fasta --output results --email your@email.com --api your_api_key --tax_rank genus --number 20 --traverse order
|
|
76
|
+
|
|
77
|
+
Diversity functionality:
|
|
78
|
+
--diversity: When used with --tax_rank and --number, distributes sequences across child taxa.
|
|
79
|
+
Prioritises finding target species matches first (up to 25% of --number), then
|
|
80
|
+
distributes remaining sequences across other child taxa.
|
|
81
|
+
|
|
82
|
+
Example: "--tax_rank genus --number 20 --diversity"
|
|
83
|
+
- First checks if target species exists in the genus (takes up to 5 sequences from it)
|
|
84
|
+
- Finds remaining species within the genus
|
|
85
|
+
- Distributes remaining 15 sequences proportionally across those species
|
|
86
|
+
- If 4 other species exist, tries to get ~3-4 sequences from each
|
|
87
|
+
- Prioritises even distribution over total sequence length
|
|
88
|
+
"""
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
91
|
+
|
|
92
|
+
parser.add_argument("--tracking_sheet", required=True,
|
|
93
|
+
help="CSV file with 'ID' and 'taxid' columns. Specifically named columns can be set using parameters --id_header and --taxid_header")
|
|
94
|
+
parser.add_argument("--unite_db", required=True,
|
|
95
|
+
help="UNITE FASTA file with taxonomy information")
|
|
96
|
+
parser.add_argument("--output", required=True,
|
|
97
|
+
help="Output directory for results")
|
|
98
|
+
parser.add_argument("--email", required=True,
|
|
99
|
+
help="Email address for NCBI Entrez API")
|
|
100
|
+
parser.add_argument("--api", default="None",
|
|
101
|
+
help="(Optional) NCBI API key")
|
|
102
|
+
parser.add_argument("--all", action="store_true",
|
|
103
|
+
help="Retrieve ALL sequences for each match instead of just the longest (requires more memory)")
|
|
104
|
+
parser.add_argument("--number", type=int,
|
|
105
|
+
help="Retrieve exactly N sequences for each match (overrides --all flag)")
|
|
106
|
+
parser.add_argument("--tax_rank",
|
|
107
|
+
choices=['species', 'genus', 'family', 'order', 'class', 'phylum'],
|
|
108
|
+
help="Search only at this specific taxonomic rank (no fallback to higher ranks unless --traverse used)")
|
|
109
|
+
parser.add_argument("--traverse",
|
|
110
|
+
choices=['species', 'genus', 'family', 'order', 'class', 'phylum', 'kingdom'],
|
|
111
|
+
help="When used with --tax_rank and --number, traverse up taxonomic hierarchy "
|
|
112
|
+
"to this rank to collect target number of sequences if insufficient sequences found at specified rank "
|
|
113
|
+
"(e.g., --tax_rank genus --traverse family will search genus then family if needed)")
|
|
114
|
+
parser.add_argument("--diversity", action="store_true",
|
|
115
|
+
help="When used with --tax_rank and --number, distribute sequences across child taxa "
|
|
116
|
+
"instead of taking longest sequences from parent taxon. Prioritises target species matches first "
|
|
117
|
+
"(up to 25%% of --number), then distributes remaining across other child taxa")
|
|
118
|
+
parser.add_argument("--id_header",
|
|
119
|
+
help="(Optional) Specify the exact column name to use for sample IDs in the input CSV"
|
|
120
|
+
"If not specified, default searches are for columns named: 'id', 'ID', 'Id', 'sample_id',"
|
|
121
|
+
"'sample_ID', 'Sample_ID', 'SampleID', 'sample', or 'Sample'")
|
|
122
|
+
parser.add_argument("--taxid_header",
|
|
123
|
+
help = "(Optional) Specify the exact column name to use for taxonomic IDs in the input CSV"
|
|
124
|
+
"If not specified, default searches are for columns named: 'taxid', 'taxID', 'TaxID',"
|
|
125
|
+
"'TAXID', 'tax_id', 'Tax_ID', 'TAX_ID', 'taxonomy_id', 'ncbi_taxid', or 'NCBI_TaxID'")
|
|
126
|
+
parser.add_argument("--summary_csv",
|
|
127
|
+
help="Generate detailed CSV summary with specified filename (will be placed in output directory)")
|
|
128
|
+
parser.add_argument("--log_file", help="Log file path; if not provided, a timestamped `UNITEd_log` file will be created")
|
|
129
|
+
|
|
130
|
+
return parser
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_fasta_header(header):
|
|
134
|
+
"""
|
|
135
|
+
Parse a FASTA header to extract taxonomy information regardless of its position.
|
|
136
|
+
Looks for patterns like k__Fungi;p__Ascomycota;... anywhere in the header.
|
|
137
|
+
"""
|
|
138
|
+
# Remove the '>' character at the beginning
|
|
139
|
+
header = header.lstrip('>')
|
|
140
|
+
|
|
141
|
+
# Look taxonomy pattern in the header, e.g. k__Fungi;p__Ascomycota;...
|
|
142
|
+
taxonomy_pattern = r'([a-z]__[^;|]+(?:;[a-z]__[^;|]+)*)'
|
|
143
|
+
taxonomy_match = re.search(taxonomy_pattern, header)
|
|
144
|
+
|
|
145
|
+
if not taxonomy_match:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
taxonomy_str = taxonomy_match.group(1)
|
|
149
|
+
|
|
150
|
+
# Extract sequence accession (use the first part before | as default)
|
|
151
|
+
id_match = re.match(r'^([^|]+)', header)
|
|
152
|
+
id_str = id_match.group(1) if id_match else "unknown"
|
|
153
|
+
|
|
154
|
+
# Parse the taxonomy string
|
|
155
|
+
taxonomy = {}
|
|
156
|
+
for tax_part in taxonomy_str.split(';'):
|
|
157
|
+
if not tax_part:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Match patterns like k__Fungi, p__Ascomycota, etc.
|
|
161
|
+
match = re.match(r'([a-z])__(.+)', tax_part)
|
|
162
|
+
if match:
|
|
163
|
+
rank_code, name = match.groups()
|
|
164
|
+
taxonomy[rank_code] = name
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
'id': id_str,
|
|
168
|
+
'header': header,
|
|
169
|
+
'taxonomy': taxonomy
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_unite_taxonomy_index(fasta_file, logger, keep_all_sequences=False):
|
|
174
|
+
"""
|
|
175
|
+
Parse UNITE FASTA file and build taxonomy index for fast lookups.
|
|
176
|
+
"""
|
|
177
|
+
if keep_all_sequences:
|
|
178
|
+
taxonomy_index = defaultdict(lambda: defaultdict(list))
|
|
179
|
+
logger.info("Building index with ALL sequences per taxon (higher memory usage)")
|
|
180
|
+
else:
|
|
181
|
+
taxonomy_index = defaultdict(dict)
|
|
182
|
+
logger.info("Building index with LONGEST sequence per taxon (memory efficient)")
|
|
183
|
+
|
|
184
|
+
# Define rank order
|
|
185
|
+
rank_order = ['s', 'g', 'f', 'o', 'c', 'p', 'k']
|
|
186
|
+
rank_names = {
|
|
187
|
+
'k': 'kingdom',
|
|
188
|
+
'p': 'phylum',
|
|
189
|
+
'c': 'class',
|
|
190
|
+
'o': 'order',
|
|
191
|
+
'f': 'family',
|
|
192
|
+
'g': 'genus',
|
|
193
|
+
's': 'species'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
logger.info(f"Parsing UNITE FASTA file: {fasta_file}")
|
|
197
|
+
|
|
198
|
+
sequences_processed = 0
|
|
199
|
+
sequences_with_taxonomy = 0
|
|
200
|
+
current_header = None
|
|
201
|
+
current_sequence = []
|
|
202
|
+
|
|
203
|
+
def process_sequence(header, sequence_str):
|
|
204
|
+
"""Process a single sequence and update index."""
|
|
205
|
+
nonlocal sequences_processed, sequences_with_taxonomy
|
|
206
|
+
|
|
207
|
+
sequences_processed += 1
|
|
208
|
+
if sequences_processed % 10000 == 0:
|
|
209
|
+
logger.info(f"Processed {sequences_processed} sequences...")
|
|
210
|
+
|
|
211
|
+
taxonomy_info = parse_fasta_header(header)
|
|
212
|
+
if not taxonomy_info or not taxonomy_info['taxonomy']:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
sequences_with_taxonomy += 1
|
|
216
|
+
sequence_length = len(sequence_str)
|
|
217
|
+
|
|
218
|
+
# Index this sequence at all taxonomic ranks it has
|
|
219
|
+
for rank_code in rank_order:
|
|
220
|
+
if rank_code in taxonomy_info['taxonomy']:
|
|
221
|
+
taxon_name = taxonomy_info['taxonomy'][rank_code]
|
|
222
|
+
rank_name = rank_names[rank_code]
|
|
223
|
+
|
|
224
|
+
# Create sequence info
|
|
225
|
+
seq_info = {
|
|
226
|
+
'header': header,
|
|
227
|
+
'sequence': sequence_str,
|
|
228
|
+
'length': sequence_length,
|
|
229
|
+
'taxonomy_info': taxonomy_info,
|
|
230
|
+
'rank': rank_name,
|
|
231
|
+
'taxon_name': taxon_name
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if keep_all_sequences:
|
|
235
|
+
# Store all sequences (higher memory usage)
|
|
236
|
+
taxonomy_index[rank_name][taxon_name].append(seq_info)
|
|
237
|
+
else:
|
|
238
|
+
# Only keep the longest sequence (memory efficient)
|
|
239
|
+
if (taxon_name not in taxonomy_index[rank_name] or
|
|
240
|
+
sequence_length > taxonomy_index[rank_name][taxon_name]['length']):
|
|
241
|
+
taxonomy_index[rank_name][taxon_name] = seq_info
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
with open(fasta_file, 'r') as f:
|
|
245
|
+
for line_num, line in enumerate(f, 1):
|
|
246
|
+
line = line.strip()
|
|
247
|
+
|
|
248
|
+
if line.startswith('>'):
|
|
249
|
+
# Process previous sequence if exists
|
|
250
|
+
if current_header is not None and current_sequence:
|
|
251
|
+
sequence_str = ''.join(current_sequence)
|
|
252
|
+
process_sequence(current_header, sequence_str)
|
|
253
|
+
|
|
254
|
+
# Start new sequence
|
|
255
|
+
current_header = line
|
|
256
|
+
current_sequence = []
|
|
257
|
+
|
|
258
|
+
elif line: # Non-empty sequence line
|
|
259
|
+
current_sequence.append(line)
|
|
260
|
+
|
|
261
|
+
# Process the last sequence
|
|
262
|
+
if current_header is not None and current_sequence:
|
|
263
|
+
sequence_str = ''.join(current_sequence)
|
|
264
|
+
process_sequence(current_header, sequence_str)
|
|
265
|
+
|
|
266
|
+
logger.info(f"Processed {sequences_processed} sequences, {sequences_with_taxonomy} with taxonomy")
|
|
267
|
+
|
|
268
|
+
# Log statistics
|
|
269
|
+
for rank_name, taxa in taxonomy_index.items():
|
|
270
|
+
if keep_all_sequences:
|
|
271
|
+
total_sequences = sum(len(seq_list) for seq_list in taxa.values())
|
|
272
|
+
logger.info(f"Found {len(taxa)} unique taxa at {rank_name} level ({total_sequences} total sequences)")
|
|
273
|
+
else:
|
|
274
|
+
logger.info(f"Found {len(taxa)} unique taxa at {rank_name} level")
|
|
275
|
+
|
|
276
|
+
return taxonomy_index
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.error(f"Error parsing UNITE FASTA file at line {line_num if 'line_num' in locals() else 'unknown'}: {str(e)}")
|
|
280
|
+
import traceback
|
|
281
|
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_most_specific_taxon(ncbi_lineage):
|
|
286
|
+
"""
|
|
287
|
+
Get the most specific taxon name from NCBI lineage.
|
|
288
|
+
Returns the name at the most specific rank available.
|
|
289
|
+
"""
|
|
290
|
+
rank_order = ['species', 'genus', 'family', 'order', 'class', 'phylum', 'kingdom']
|
|
291
|
+
|
|
292
|
+
for rank in rank_order:
|
|
293
|
+
if rank in ncbi_lineage:
|
|
294
|
+
return ncbi_lineage[rank]
|
|
295
|
+
|
|
296
|
+
return "Unknown"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def search_rank_for_taxon(unite_index, rank, taxon_name):
|
|
300
|
+
"""
|
|
301
|
+
Helper function to search for a taxon at a specific rank with various matching strategies.
|
|
302
|
+
"""
|
|
303
|
+
# Look for exact matches first
|
|
304
|
+
if taxon_name in unite_index[rank]:
|
|
305
|
+
return unite_index[rank][taxon_name]
|
|
306
|
+
|
|
307
|
+
# Look for case-insensitive matches
|
|
308
|
+
taxon_lower = taxon_name.lower()
|
|
309
|
+
for unite_taxon, sequences in unite_index[rank].items():
|
|
310
|
+
if unite_taxon.lower() == taxon_lower:
|
|
311
|
+
return sequences
|
|
312
|
+
|
|
313
|
+
# Look for partial matches (species name without underscore/space differences)
|
|
314
|
+
if rank == 'species':
|
|
315
|
+
# Handle variations in species names (spaces vs underscores)
|
|
316
|
+
taxon_normalised = normalise_taxon_name(taxon_name)
|
|
317
|
+
for unite_taxon, sequences in unite_index[rank].items():
|
|
318
|
+
if normalise_taxon_name(unite_taxon) == taxon_normalised:
|
|
319
|
+
return sequences
|
|
320
|
+
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_rank_hierarchy():
|
|
325
|
+
"""Get the taxonomic rank hierarchy in order from most specific to most general."""
|
|
326
|
+
return ['species', 'genus', 'family', 'order', 'class', 'phylum', 'kingdom']
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_child_rank(parent_rank):
|
|
330
|
+
"""Get the child rank for a given parent rank."""
|
|
331
|
+
hierarchy = get_rank_hierarchy()
|
|
332
|
+
try:
|
|
333
|
+
parent_index = hierarchy.index(parent_rank)
|
|
334
|
+
if parent_index > 0:
|
|
335
|
+
return hierarchy[parent_index - 1]
|
|
336
|
+
else:
|
|
337
|
+
return None # species has no child rank
|
|
338
|
+
except ValueError:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def find_child_taxa(unite_index, parent_rank, parent_taxon_name, logger):
|
|
343
|
+
"""
|
|
344
|
+
Find all child taxa under a given parent taxon.
|
|
345
|
+
"""
|
|
346
|
+
child_rank = get_child_rank(parent_rank)
|
|
347
|
+
if not child_rank:
|
|
348
|
+
logger.warning(f"No child rank exists for {parent_rank}")
|
|
349
|
+
return {}
|
|
350
|
+
|
|
351
|
+
child_taxa = {}
|
|
352
|
+
parent_rank_code = parent_rank[0] # Get first letter (genus -> 'g')
|
|
353
|
+
|
|
354
|
+
# Look through all sequences at the child rank
|
|
355
|
+
if child_rank in unite_index:
|
|
356
|
+
for child_taxon_name, child_sequences in unite_index[child_rank].items():
|
|
357
|
+
# For each child taxon, check if any of its sequences also belong to our parent taxon
|
|
358
|
+
if isinstance(child_sequences, list):
|
|
359
|
+
# Multiple sequences case
|
|
360
|
+
matching_sequences = []
|
|
361
|
+
for seq_info in child_sequences:
|
|
362
|
+
if (parent_rank_code in seq_info['taxonomy_info']['taxonomy'] and
|
|
363
|
+
seq_info['taxonomy_info']['taxonomy'][parent_rank_code] == parent_taxon_name):
|
|
364
|
+
matching_sequences.append(seq_info)
|
|
365
|
+
if matching_sequences:
|
|
366
|
+
child_taxa[child_taxon_name] = matching_sequences
|
|
367
|
+
else:
|
|
368
|
+
# Single sequence case
|
|
369
|
+
seq_info = child_sequences
|
|
370
|
+
if (parent_rank_code in seq_info['taxonomy_info']['taxonomy'] and
|
|
371
|
+
seq_info['taxonomy_info']['taxonomy'][parent_rank_code] == parent_taxon_name):
|
|
372
|
+
child_taxa[child_taxon_name] = [seq_info]
|
|
373
|
+
|
|
374
|
+
logger.info(f"Found {len(child_taxa)} child taxa under {parent_rank} '{parent_taxon_name}': {list(child_taxa.keys())}")
|
|
375
|
+
return child_taxa
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def distribute_sequences_across_taxa(child_taxa, target_count, logger):
|
|
379
|
+
"""
|
|
380
|
+
Distribute a target number of sequences across child taxa as evenly as possible.
|
|
381
|
+
Redistributes unused allocations when taxa have insufficient sequences.
|
|
382
|
+
"""
|
|
383
|
+
if not child_taxa:
|
|
384
|
+
return []
|
|
385
|
+
|
|
386
|
+
# First pass: calculate ideal distribution
|
|
387
|
+
num_taxa = len(child_taxa)
|
|
388
|
+
base_per_taxon = target_count // num_taxa
|
|
389
|
+
extra_sequences = target_count % num_taxa
|
|
390
|
+
|
|
391
|
+
logger.info(f"Distributing {target_count} sequences across {num_taxa} taxa: "
|
|
392
|
+
f"{base_per_taxon} base sequences per taxon, {extra_sequences} taxa get +1 extra")
|
|
393
|
+
|
|
394
|
+
# Create list of taxa with their allocations and available sequences
|
|
395
|
+
taxa_info = []
|
|
396
|
+
for i, (taxon_name, sequences) in enumerate(child_taxa.items()):
|
|
397
|
+
allocation = base_per_taxon + (1 if i < extra_sequences else 0)
|
|
398
|
+
available = len(sequences)
|
|
399
|
+
taxa_info.append({
|
|
400
|
+
'name': taxon_name,
|
|
401
|
+
'sequences': sequences,
|
|
402
|
+
'allocation': allocation,
|
|
403
|
+
'available': available,
|
|
404
|
+
'can_take': min(allocation, available)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
# Second pass: redistribute unused allocations
|
|
408
|
+
total_can_take = sum(info['can_take'] for info in taxa_info)
|
|
409
|
+
if total_can_take < target_count:
|
|
410
|
+
# We can't reach target even with perfect distribution
|
|
411
|
+
logger.warning(f"Cannot reach target {target_count} sequences - only {total_can_take} available across all taxa")
|
|
412
|
+
else:
|
|
413
|
+
# Redistribute unused allocations to taxa that can take more
|
|
414
|
+
unused = sum(info['allocation'] - info['can_take'] for info in taxa_info if info['can_take'] < info['allocation'])
|
|
415
|
+
if unused > 0:
|
|
416
|
+
logger.info(f"Redistributing {unused} unused allocations from taxa with insufficient sequences")
|
|
417
|
+
|
|
418
|
+
# Find taxa that can take more than their current allocation
|
|
419
|
+
expandable_taxa = [info for info in taxa_info if info['available'] > info['can_take']]
|
|
420
|
+
if expandable_taxa:
|
|
421
|
+
# Distribute unused sequences among expandable taxa
|
|
422
|
+
while unused > 0 and expandable_taxa:
|
|
423
|
+
for info in expandable_taxa[:]:
|
|
424
|
+
if unused <= 0:
|
|
425
|
+
break
|
|
426
|
+
if info['can_take'] < info['available']:
|
|
427
|
+
info['can_take'] += 1
|
|
428
|
+
unused -= 1
|
|
429
|
+
if info['can_take'] >= info['available']:
|
|
430
|
+
expandable_taxa.remove(info)
|
|
431
|
+
|
|
432
|
+
# Third pass: collect sequences
|
|
433
|
+
selected_sequences = []
|
|
434
|
+
for info in taxa_info:
|
|
435
|
+
taxon_name = info['name']
|
|
436
|
+
sequences = info['sequences']
|
|
437
|
+
take_count = info['can_take']
|
|
438
|
+
|
|
439
|
+
# Sort sequences by length (longest first) and take the requested number
|
|
440
|
+
sorted_sequences = sorted(sequences, key=lambda x: x['length'], reverse=True)
|
|
441
|
+
|
|
442
|
+
for seq_info in sorted_sequences[:take_count]:
|
|
443
|
+
# Create a copy and add source information
|
|
444
|
+
seq_copy = seq_info.copy()
|
|
445
|
+
seq_copy['diversity_source_taxon'] = taxon_name
|
|
446
|
+
# The diversity_source_rank should be the rank of the sequences we're collecting,
|
|
447
|
+
# not the child rank of that rank (which would be one level down)
|
|
448
|
+
seq_copy['diversity_source_rank'] = seq_info['rank']
|
|
449
|
+
selected_sequences.append(seq_copy)
|
|
450
|
+
|
|
451
|
+
logger.info(f" {taxon_name}: took {take_count}/{len(sequences)} sequences")
|
|
452
|
+
|
|
453
|
+
logger.info(f"Total sequences collected with diversity: {len(selected_sequences)}")
|
|
454
|
+
return selected_sequences
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def find_sequences_with_diversity(ncbi_lineage, unite_index, target_rank, target_count, logger, traverse_limit=None):
|
|
458
|
+
"""
|
|
459
|
+
Find sequences using diversity strategy - distribute across child taxa.
|
|
460
|
+
Prioritises target species matches first (up to 25% of target_count).
|
|
461
|
+
When traverse_limit is set, collects cumulatively across ranks until target is reached or limit hit.
|
|
462
|
+
"""
|
|
463
|
+
hierarchy = get_rank_hierarchy()
|
|
464
|
+
|
|
465
|
+
# Validate target rank exists in lineage
|
|
466
|
+
if target_rank not in ncbi_lineage:
|
|
467
|
+
logger.error(f"Target rank '{target_rank}' not found in NCBI lineage")
|
|
468
|
+
return None, None
|
|
469
|
+
|
|
470
|
+
# Determine search ranks based on traverse_limit
|
|
471
|
+
start_index = hierarchy.index(target_rank)
|
|
472
|
+
if traverse_limit:
|
|
473
|
+
try:
|
|
474
|
+
limit_index = hierarchy.index(traverse_limit)
|
|
475
|
+
if limit_index < start_index:
|
|
476
|
+
logger.error(f"Traverse limit '{traverse_limit}' is more specific than start rank '{target_rank}'")
|
|
477
|
+
return None, None
|
|
478
|
+
search_ranks = hierarchy[start_index:limit_index + 1]
|
|
479
|
+
except ValueError:
|
|
480
|
+
logger.error(f"Invalid traverse limit rank: {traverse_limit}")
|
|
481
|
+
return None, None
|
|
482
|
+
else:
|
|
483
|
+
search_ranks = [target_rank]
|
|
484
|
+
|
|
485
|
+
logger.info(f"Starting diversity search at '{target_rank}' rank, target: {target_count} sequences")
|
|
486
|
+
if traverse_limit:
|
|
487
|
+
logger.info(f"Will traverse up hierarchy collecting cumulatively up to '{traverse_limit}': {' -> '.join(search_ranks)}")
|
|
488
|
+
else:
|
|
489
|
+
logger.info("No traverse mode - will only search at specified rank")
|
|
490
|
+
|
|
491
|
+
# For cumulative collection when traversing
|
|
492
|
+
all_collected_sequences = []
|
|
493
|
+
collection_info = []
|
|
494
|
+
target_species_sequences = []
|
|
495
|
+
# Headers already selected, so no sequence is chosen twice (e.g. as a
|
|
496
|
+
# target-species match, again as a child taxon, and again at a higher rank)
|
|
497
|
+
selected_headers = set()
|
|
498
|
+
|
|
499
|
+
# First, try to find target species matches (up to 25% of target)
|
|
500
|
+
target_species_name = ncbi_lineage.get('species')
|
|
501
|
+
if target_species_name:
|
|
502
|
+
max_species_sequences = max(1, int(target_count * 0.25))
|
|
503
|
+
logger.info(f"Checking for target species '{target_species_name}' matches (max {max_species_sequences} sequences, 25% of {target_count})")
|
|
504
|
+
|
|
505
|
+
species_matches = search_rank_for_taxon(unite_index, 'species', target_species_name)
|
|
506
|
+
if species_matches:
|
|
507
|
+
# Convert to list if needed
|
|
508
|
+
if isinstance(species_matches, dict):
|
|
509
|
+
species_seq_list = [species_matches]
|
|
510
|
+
else:
|
|
511
|
+
species_seq_list = species_matches
|
|
512
|
+
|
|
513
|
+
# Sort by length and take up to 25%
|
|
514
|
+
species_seq_list = sorted(species_seq_list, key=lambda x: x['length'], reverse=True)
|
|
515
|
+
target_species_sequences = species_seq_list[:max_species_sequences]
|
|
516
|
+
|
|
517
|
+
# Tag these sequences
|
|
518
|
+
for seq in target_species_sequences:
|
|
519
|
+
seq_copy = seq.copy()
|
|
520
|
+
# Label with the UNITE name (e.g. 'Cladonia_uncialis'), as for the
|
|
521
|
+
# child taxa below, not the NCBI spelling ('Cladonia uncialis')
|
|
522
|
+
seq_copy['diversity_source_taxon'] = seq['taxon_name']
|
|
523
|
+
seq_copy['diversity_source_rank'] = 'species'
|
|
524
|
+
seq_copy['target_species_match'] = True
|
|
525
|
+
all_collected_sequences.append(seq_copy)
|
|
526
|
+
selected_headers.add(seq['header'])
|
|
527
|
+
|
|
528
|
+
logger.info(f"Found target species match! Collected {len(target_species_sequences)} sequences from '{target_species_name}'")
|
|
529
|
+
else:
|
|
530
|
+
logger.info(f"No sequences found for target species '{target_species_name}'")
|
|
531
|
+
|
|
532
|
+
# Calculate remaining sequences needed
|
|
533
|
+
remaining_needed = target_count - len(all_collected_sequences)
|
|
534
|
+
logger.info(f"Need {remaining_needed} more sequences after target species collection")
|
|
535
|
+
|
|
536
|
+
if remaining_needed <= 0:
|
|
537
|
+
logger.info(f"Target reached with species-level matches alone")
|
|
538
|
+
diversity_info = {
|
|
539
|
+
'strategy': 'diversity_species_only',
|
|
540
|
+
'sequences_collected': len(all_collected_sequences),
|
|
541
|
+
'target_species_sequences': len(target_species_sequences),
|
|
542
|
+
'target_count': target_count
|
|
543
|
+
}
|
|
544
|
+
return all_collected_sequences, diversity_info
|
|
545
|
+
|
|
546
|
+
# Now proceed with diversity distribution for remaining sequences
|
|
547
|
+
for current_rank in search_ranks:
|
|
548
|
+
if current_rank not in ncbi_lineage:
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
# Calculate how many more sequences we need
|
|
552
|
+
current_remaining = target_count - len(all_collected_sequences)
|
|
553
|
+
if current_remaining <= 0:
|
|
554
|
+
logger.info(f"Target {target_count} sequences already reached, stopping traverse")
|
|
555
|
+
break
|
|
556
|
+
|
|
557
|
+
current_taxon_name = ncbi_lineage[current_rank]
|
|
558
|
+
logger.info(f"Searching for child taxa under {current_rank}: {current_taxon_name} "
|
|
559
|
+
f"(need {current_remaining} more sequences)")
|
|
560
|
+
|
|
561
|
+
# Find child taxa under current rank
|
|
562
|
+
child_taxa = find_child_taxa(unite_index, current_rank, current_taxon_name, logger)
|
|
563
|
+
|
|
564
|
+
# Remove target species from child taxa if it exists (already processed).
|
|
565
|
+
# Compare normalised names: NCBI uses spaces, UNITE uses underscores.
|
|
566
|
+
if target_species_name:
|
|
567
|
+
target_normalised = normalise_taxon_name(target_species_name)
|
|
568
|
+
for child_name in list(child_taxa):
|
|
569
|
+
if normalise_taxon_name(child_name) == target_normalised:
|
|
570
|
+
logger.info(f"Removing target species '{child_name}' from child taxa (already processed)")
|
|
571
|
+
del child_taxa[child_name]
|
|
572
|
+
|
|
573
|
+
# Drop sequences already selected at an earlier step or rank. At a
|
|
574
|
+
# higher rank the child taxon containing the target lineage (e.g. genus
|
|
575
|
+
# 'Cladonia' under family 'Cladoniaceae') holds sequences already taken.
|
|
576
|
+
for child_name in list(child_taxa):
|
|
577
|
+
remaining = [seq for seq in child_taxa[child_name] if seq['header'] not in selected_headers]
|
|
578
|
+
if remaining:
|
|
579
|
+
child_taxa[child_name] = remaining
|
|
580
|
+
else:
|
|
581
|
+
del child_taxa[child_name]
|
|
582
|
+
|
|
583
|
+
if not child_taxa:
|
|
584
|
+
logger.warning(f"No child taxa found under {current_rank}: {current_taxon_name}")
|
|
585
|
+
if not traverse_limit:
|
|
586
|
+
break
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
# Apply diversity distribution for this rank
|
|
590
|
+
rank_sequences = distribute_sequences_across_taxa(child_taxa, current_remaining, logger)
|
|
591
|
+
|
|
592
|
+
if rank_sequences:
|
|
593
|
+
# Add to cumulative collection
|
|
594
|
+
all_collected_sequences.extend(rank_sequences)
|
|
595
|
+
selected_headers.update(seq['header'] for seq in rank_sequences)
|
|
596
|
+
collection_info.append({
|
|
597
|
+
'rank': current_rank,
|
|
598
|
+
'taxon_name': current_taxon_name,
|
|
599
|
+
'child_rank': get_child_rank(current_rank),
|
|
600
|
+
'num_child_taxa': len(child_taxa),
|
|
601
|
+
'sequences_added': len(rank_sequences)
|
|
602
|
+
})
|
|
603
|
+
logger.info(f"Added {len(rank_sequences)} sequences from {current_rank} level "
|
|
604
|
+
f"(cumulative: {len(all_collected_sequences)}/{target_count})")
|
|
605
|
+
|
|
606
|
+
# Check if we have enough sequences now
|
|
607
|
+
if len(all_collected_sequences) >= target_count:
|
|
608
|
+
logger.info(f"Target {target_count} sequences reached through cumulative collection")
|
|
609
|
+
break
|
|
610
|
+
else:
|
|
611
|
+
logger.warning(f"No sequences collected at {current_rank} level")
|
|
612
|
+
if not traverse_limit:
|
|
613
|
+
break
|
|
614
|
+
|
|
615
|
+
if all_collected_sequences:
|
|
616
|
+
# Check if we reached target
|
|
617
|
+
if len(all_collected_sequences) < target_count:
|
|
618
|
+
if traverse_limit:
|
|
619
|
+
logger.warning(f"Traverse limit '{traverse_limit}' reached but only collected "
|
|
620
|
+
f"{len(all_collected_sequences)}/{target_count} sequences")
|
|
621
|
+
else:
|
|
622
|
+
logger.warning(f"Could not reach target {target_count}, collected {len(all_collected_sequences)} sequences")
|
|
623
|
+
|
|
624
|
+
# Return cumulative results
|
|
625
|
+
diversity_info = {
|
|
626
|
+
'strategy': 'diversity_traverse' if traverse_limit else 'diversity',
|
|
627
|
+
'sequences_collected': len(all_collected_sequences),
|
|
628
|
+
'target_species_sequences': len(target_species_sequences),
|
|
629
|
+
'target_count': target_count,
|
|
630
|
+
'collection_details': collection_info,
|
|
631
|
+
'ranks_searched': [info['rank'] for info in collection_info],
|
|
632
|
+
'traverse_limit': traverse_limit,
|
|
633
|
+
'target_reached': len(all_collected_sequences) >= target_count
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
logger.info(f"Diversity {'traverse ' if traverse_limit else ''}search completed: {len(all_collected_sequences)} sequences")
|
|
637
|
+
if target_species_sequences:
|
|
638
|
+
logger.info(f" Target species: {len(target_species_sequences)} sequences")
|
|
639
|
+
for info in collection_info:
|
|
640
|
+
logger.info(f" {info['rank']}: {info['sequences_added']} sequences from "
|
|
641
|
+
f"{info['num_child_taxa']} {info['child_rank']} taxa")
|
|
642
|
+
|
|
643
|
+
return all_collected_sequences, diversity_info
|
|
644
|
+
|
|
645
|
+
logger.warning("Diversity search failed - no suitable child taxa found")
|
|
646
|
+
return None, None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def find_best_unite_match(ncbi_lineage, unite_index, logger, return_all_sequences=False, max_sequences=None, target_rank=None, traverse_limit=None, diversity=False):
|
|
650
|
+
"""
|
|
651
|
+
Find the best matching UNITE sequence(s) for an NCBI lineage with diversity support.
|
|
652
|
+
"""
|
|
653
|
+
# Define valid ranks in hierarchical order
|
|
654
|
+
valid_ranks = ['species', 'genus', 'family', 'order', 'class', 'phylum', 'kingdom']
|
|
655
|
+
|
|
656
|
+
# Validate diversity usage
|
|
657
|
+
if diversity:
|
|
658
|
+
if not target_rank:
|
|
659
|
+
logger.error("--diversity requires --tax_rank to be specified")
|
|
660
|
+
return None, None
|
|
661
|
+
if max_sequences is None:
|
|
662
|
+
logger.error("--diversity requires --number to be specified")
|
|
663
|
+
return None, None
|
|
664
|
+
|
|
665
|
+
# Use diversity strategy
|
|
666
|
+
return find_sequences_with_diversity(ncbi_lineage, unite_index, target_rank, max_sequences, logger, traverse_limit)
|
|
667
|
+
|
|
668
|
+
# Original logic for non-diversity cases
|
|
669
|
+
if target_rank is not None:
|
|
670
|
+
if target_rank not in valid_ranks:
|
|
671
|
+
logger.error(f"Invalid target rank '{target_rank}'. Valid ranks: {', '.join(valid_ranks)}")
|
|
672
|
+
return None, None
|
|
673
|
+
|
|
674
|
+
# Check if the requested rank exists in the NCBI lineage
|
|
675
|
+
if target_rank not in ncbi_lineage:
|
|
676
|
+
logger.error(f"Requested rank '{target_rank}' not found in NCBI lineage for this taxid")
|
|
677
|
+
logger.info(f"Available ranks: {', '.join(ncbi_lineage.keys())}")
|
|
678
|
+
return None, None
|
|
679
|
+
|
|
680
|
+
if traverse_limit and max_sequences is not None:
|
|
681
|
+
# Traverse up taxonomy to collect enough sequences
|
|
682
|
+
return find_sequences_with_traverse(ncbi_lineage, unite_index, target_rank, max_sequences, logger, traverse_limit)
|
|
683
|
+
else:
|
|
684
|
+
# Original behavior: search only at specified rank
|
|
685
|
+
rank_priority = [target_rank]
|
|
686
|
+
logger.info(f"Searching only at rank '{target_rank}' (no fallback)")
|
|
687
|
+
else:
|
|
688
|
+
# Default behavior: search all ranks with fallback
|
|
689
|
+
rank_priority = ['species', 'genus', 'family', 'order', 'class', 'phylum', 'kingdom']
|
|
690
|
+
logger.info("Using default search strategy with fallback (species -> genus -> family -> ...)")
|
|
691
|
+
|
|
692
|
+
# Original search logic for non-traverse cases
|
|
693
|
+
for rank in rank_priority:
|
|
694
|
+
if rank in ncbi_lineage and rank in unite_index:
|
|
695
|
+
taxon_name = ncbi_lineage[rank]
|
|
696
|
+
sequences = search_rank_for_taxon(unite_index, rank, taxon_name)
|
|
697
|
+
|
|
698
|
+
if sequences:
|
|
699
|
+
return process_sequences_for_output(sequences, rank, taxon_name, return_all_sequences, max_sequences, logger)
|
|
700
|
+
|
|
701
|
+
logger.warning(f"No match found for lineage: {', '.join([f'{k}:{v}' for k,v in ncbi_lineage.items()])}")
|
|
702
|
+
return None, None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def find_sequences_with_traverse(ncbi_lineage, unite_index, start_rank, target_count, logger, traverse_limit):
|
|
706
|
+
"""
|
|
707
|
+
Traverse up taxonomic hierarchy to collect target number of sequences.
|
|
708
|
+
Stops at traverse_limit rank even if target not reached.
|
|
709
|
+
"""
|
|
710
|
+
valid_ranks = ['species', 'genus', 'family', 'order', 'class', 'phylum', 'kingdom']
|
|
711
|
+
|
|
712
|
+
# Validate traverse_limit
|
|
713
|
+
start_index = valid_ranks.index(start_rank)
|
|
714
|
+
try:
|
|
715
|
+
limit_index = valid_ranks.index(traverse_limit)
|
|
716
|
+
except ValueError:
|
|
717
|
+
logger.error(f"Invalid traverse limit rank: {traverse_limit}")
|
|
718
|
+
return None, None
|
|
719
|
+
|
|
720
|
+
if limit_index < start_index:
|
|
721
|
+
logger.error(f"Traverse limit '{traverse_limit}' is more specific than start rank '{start_rank}'")
|
|
722
|
+
return None, None
|
|
723
|
+
|
|
724
|
+
# Create rank list from start to limit
|
|
725
|
+
traverse_ranks = valid_ranks[start_index:limit_index + 1]
|
|
726
|
+
|
|
727
|
+
collected_sequences = []
|
|
728
|
+
matched_ranks_info = []
|
|
729
|
+
|
|
730
|
+
logger.info(f"Starting traverse search at '{start_rank}' rank, target: {target_count} sequences, limit: '{traverse_limit}'")
|
|
731
|
+
|
|
732
|
+
for rank in traverse_ranks:
|
|
733
|
+
if len(collected_sequences) >= target_count:
|
|
734
|
+
break
|
|
735
|
+
|
|
736
|
+
if rank not in ncbi_lineage or rank not in unite_index:
|
|
737
|
+
continue
|
|
738
|
+
|
|
739
|
+
taxon_name = ncbi_lineage[rank]
|
|
740
|
+
sequences = search_rank_for_taxon(unite_index, rank, taxon_name)
|
|
741
|
+
|
|
742
|
+
if sequences:
|
|
743
|
+
# Convert single sequence to list for uniform processing
|
|
744
|
+
if isinstance(sequences, dict):
|
|
745
|
+
rank_sequences = [sequences]
|
|
746
|
+
else:
|
|
747
|
+
rank_sequences = sequences
|
|
748
|
+
|
|
749
|
+
# Skip sequences already taken at a more specific rank: a genus-level
|
|
750
|
+
# search also returns the species-level sequences collected before it
|
|
751
|
+
collected_headers = {seq['header'] for seq in collected_sequences}
|
|
752
|
+
rank_sequences = [seq for seq in rank_sequences if seq['header'] not in collected_headers]
|
|
753
|
+
if not rank_sequences:
|
|
754
|
+
logger.info(f"No new sequences at {rank} level: {taxon_name} (all already collected)")
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
# Sort by length (longest first)
|
|
758
|
+
rank_sequences = sorted(rank_sequences, key=lambda x: x['length'], reverse=True)
|
|
759
|
+
|
|
760
|
+
# Calculate how many sequences we need from this rank
|
|
761
|
+
remaining_needed = target_count - len(collected_sequences)
|
|
762
|
+
sequences_to_take = min(len(rank_sequences), remaining_needed)
|
|
763
|
+
|
|
764
|
+
# Add sequences from this rank and TAG them with source information
|
|
765
|
+
taken_sequences = rank_sequences[:sequences_to_take]
|
|
766
|
+
for seq in taken_sequences:
|
|
767
|
+
# Create a copy to avoid modifying the original
|
|
768
|
+
seq_copy = seq.copy()
|
|
769
|
+
seq_copy['source_rank'] = rank
|
|
770
|
+
seq_copy['source_taxon'] = taxon_name
|
|
771
|
+
collected_sequences.append(seq_copy)
|
|
772
|
+
|
|
773
|
+
# Record which rank these came from
|
|
774
|
+
matched_ranks_info.append({
|
|
775
|
+
'rank': rank,
|
|
776
|
+
'taxon_name': taxon_name,
|
|
777
|
+
'sequences_taken': len(taken_sequences),
|
|
778
|
+
'total_available': len(rank_sequences)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
logger.info(f"Found {len(rank_sequences)} sequences at {rank} level: {taxon_name}")
|
|
782
|
+
logger.info(f"Taking {sequences_to_take} sequences (collected {len(collected_sequences)}/{target_count})")
|
|
783
|
+
|
|
784
|
+
if not collected_sequences:
|
|
785
|
+
logger.warning(f"No sequences found during traverse search from {start_rank} to {traverse_limit}")
|
|
786
|
+
return None, None
|
|
787
|
+
|
|
788
|
+
# Check if we reached target
|
|
789
|
+
if len(collected_sequences) < target_count:
|
|
790
|
+
logger.warning(f"Traverse limit '{traverse_limit}' reached but only collected {len(collected_sequences)}/{target_count} sequences")
|
|
791
|
+
|
|
792
|
+
# Log traverse summary
|
|
793
|
+
logger.info(f"Traverse search completed: collected {len(collected_sequences)} sequences from {len(matched_ranks_info)} taxonomic levels")
|
|
794
|
+
for rank_info in matched_ranks_info:
|
|
795
|
+
logger.info(f" {rank_info['rank']}: {rank_info['taxon_name']} "
|
|
796
|
+
f"({rank_info['sequences_taken']}/{rank_info['total_available']} sequences)")
|
|
797
|
+
|
|
798
|
+
# Create summary of matched ranks for return value
|
|
799
|
+
matched_ranks_summary = " + ".join([f"{info['rank']}({info['sequences_taken']})" for info in matched_ranks_info])
|
|
800
|
+
|
|
801
|
+
return collected_sequences, matched_ranks_summary
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def process_sequences_for_output(sequences, rank, taxon_name, return_all_sequences, max_sequences, logger):
|
|
805
|
+
"""
|
|
806
|
+
Process sequences according to output requirements (all, longest, or specific number).
|
|
807
|
+
"""
|
|
808
|
+
# Convert single sequence to list for uniform processing
|
|
809
|
+
if isinstance(sequences, dict):
|
|
810
|
+
sequence_list = [sequences]
|
|
811
|
+
else:
|
|
812
|
+
sequence_list = sequences
|
|
813
|
+
|
|
814
|
+
# Sort by length (longest first) to ensure we get the best sequences
|
|
815
|
+
sequence_list = sorted(sequence_list, key=lambda x: x['length'], reverse=True)
|
|
816
|
+
|
|
817
|
+
# Determine how many sequences to return
|
|
818
|
+
if max_sequences is not None:
|
|
819
|
+
# Specific number requested
|
|
820
|
+
selected_sequences = sequence_list[:max_sequences]
|
|
821
|
+
count_msg = f"{len(selected_sequences)} sequences (requested {max_sequences})"
|
|
822
|
+
elif return_all_sequences:
|
|
823
|
+
# All sequences requested
|
|
824
|
+
selected_sequences = sequence_list
|
|
825
|
+
count_msg = f"{len(selected_sequences)} sequences (all)"
|
|
826
|
+
else:
|
|
827
|
+
# Only longest sequence (default behavior)
|
|
828
|
+
selected_sequences = [sequence_list[0]]
|
|
829
|
+
count_msg = f"1 sequence (longest, length: {selected_sequences[0]['length']})"
|
|
830
|
+
|
|
831
|
+
logger.info(f"Found match at {rank} level: {taxon_name} ({count_msg})")
|
|
832
|
+
|
|
833
|
+
# Return in the expected format
|
|
834
|
+
if not return_all_sequences and max_sequences is None:
|
|
835
|
+
# Original behavior: return single sequence as dict
|
|
836
|
+
return selected_sequences[0], rank
|
|
837
|
+
else:
|
|
838
|
+
# Return as list
|
|
839
|
+
return selected_sequences, rank
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def extract_unite_sequence_id(header):
|
|
843
|
+
"""
|
|
844
|
+
Extract the UNITE sequence ID from the original header.
|
|
845
|
+
Expected format: '>MK092089|k__Fungi;p__...|SH1320883.10FU'
|
|
846
|
+
"""
|
|
847
|
+
# Remove the '>' character
|
|
848
|
+
header = header.lstrip('>')
|
|
849
|
+
|
|
850
|
+
# Extract the first part before the first '|'
|
|
851
|
+
parts = header.split('|')
|
|
852
|
+
if parts:
|
|
853
|
+
return parts[0]
|
|
854
|
+
else:
|
|
855
|
+
return "unknown"
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def write_output_fasta(sequence_info, matched_rank, sample_id, taxid, output_file, logger, summary_data=None):
|
|
859
|
+
"""
|
|
860
|
+
Write the selected sequence(s) to output FASTA file with custom header.
|
|
861
|
+
Handles both single sequences, traverse results, and diversity results.
|
|
862
|
+
Aggregates data for summary CSV instead of writing directly.
|
|
863
|
+
"""
|
|
864
|
+
# Create output directory if it doesn't exist
|
|
865
|
+
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
|
866
|
+
|
|
867
|
+
# Handle both single sequence and multiple sequences
|
|
868
|
+
if isinstance(sequence_info, list):
|
|
869
|
+
sequences = sequence_info
|
|
870
|
+
else:
|
|
871
|
+
sequences = [sequence_info]
|
|
872
|
+
|
|
873
|
+
total_length = 0
|
|
874
|
+
|
|
875
|
+
# Lists to collect data for summary
|
|
876
|
+
reference_names = []
|
|
877
|
+
taxa_list = []
|
|
878
|
+
accessions = []
|
|
879
|
+
lengths = []
|
|
880
|
+
|
|
881
|
+
with open(output_file, 'w') as f:
|
|
882
|
+
for seq_info in sequences:
|
|
883
|
+
# Extract UNITE sequence ID from original header
|
|
884
|
+
unite_seq_id = extract_unite_sequence_id(seq_info['header'])
|
|
885
|
+
|
|
886
|
+
# Determine which rank and taxon to use in the header
|
|
887
|
+
if 'target_species_match' in seq_info and seq_info['target_species_match']:
|
|
888
|
+
# This is a target species match from diversity mode
|
|
889
|
+
header_rank = seq_info['diversity_source_rank']
|
|
890
|
+
header_taxon = seq_info['diversity_source_taxon']
|
|
891
|
+
header_suffix = f"div-target-{header_rank}-{header_taxon}"
|
|
892
|
+
elif 'diversity_source_taxon' in seq_info:
|
|
893
|
+
# This sequence came from diversity mode
|
|
894
|
+
header_rank = seq_info['diversity_source_rank']
|
|
895
|
+
header_taxon = seq_info['diversity_source_taxon']
|
|
896
|
+
header_suffix = f"div-{header_rank}-{header_taxon}"
|
|
897
|
+
elif 'source_rank' in seq_info:
|
|
898
|
+
# This sequence came from traverse mode
|
|
899
|
+
header_rank = seq_info['source_rank']
|
|
900
|
+
header_taxon = seq_info['source_taxon']
|
|
901
|
+
header_suffix = f"trav-{header_rank}-{header_taxon}"
|
|
902
|
+
else:
|
|
903
|
+
# This sequence came from regular mode
|
|
904
|
+
if isinstance(matched_rank, dict) and 'search_rank' in matched_rank:
|
|
905
|
+
# Diversity mode matched_rank info
|
|
906
|
+
header_rank = matched_rank['child_rank']
|
|
907
|
+
header_taxon = seq_info.get('taxon_name', 'unknown')
|
|
908
|
+
header_suffix = f"div-{header_rank}-{header_taxon}"
|
|
909
|
+
else:
|
|
910
|
+
# Regular mode
|
|
911
|
+
header_rank = matched_rank if isinstance(matched_rank, str) else 'unknown'
|
|
912
|
+
header_taxon = seq_info.get('taxon_name', 'unknown')
|
|
913
|
+
header_suffix = f"{header_rank}-{header_taxon}"
|
|
914
|
+
|
|
915
|
+
# Create new header: {sample_id}-{taxid}-{strategy_info}-{unite_seq_id}
|
|
916
|
+
new_header = f"{sample_id}-{taxid}-{header_suffix}-{unite_seq_id}"
|
|
917
|
+
# Replace spaces with underscores to ensure BWA compatibility
|
|
918
|
+
new_header = new_header.replace(' ', '_')
|
|
919
|
+
|
|
920
|
+
header_line = f">{new_header}\n"
|
|
921
|
+
f.write(header_line)
|
|
922
|
+
|
|
923
|
+
# Write sequence with line breaks every 80 characters
|
|
924
|
+
sequence = seq_info['sequence']
|
|
925
|
+
for i in range(0, len(sequence), 80):
|
|
926
|
+
seq_line = sequence[i:i+80] + '\n'
|
|
927
|
+
f.write(seq_line)
|
|
928
|
+
|
|
929
|
+
total_length += len(sequence)
|
|
930
|
+
|
|
931
|
+
# Collect data for summary CSV
|
|
932
|
+
if summary_data is not None:
|
|
933
|
+
reference_names.append(seq_info['header'])
|
|
934
|
+
taxa_list.append(header_taxon)
|
|
935
|
+
accessions.append(unite_seq_id)
|
|
936
|
+
lengths.append(seq_info['length'])
|
|
937
|
+
|
|
938
|
+
logger.info(f"Wrote {len(sequences)} sequence(s) to {output_file} (total length: {total_length} bp)")
|
|
939
|
+
|
|
940
|
+
# Add aggregated data to summary_data dictionary
|
|
941
|
+
if summary_data is not None and sample_id not in summary_data:
|
|
942
|
+
summary_data[sample_id] = {
|
|
943
|
+
'reference_names': reference_names,
|
|
944
|
+
'taxa': taxa_list,
|
|
945
|
+
'accessions': accessions,
|
|
946
|
+
'lengths': lengths,
|
|
947
|
+
'taxid': taxid
|
|
948
|
+
}
|
|
949
|
+
logger.info(f"Added aggregated data to summary for sample {sample_id}")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def normalise_csv_headers(fieldnames, logger, id_header=None, taxid_header=None):
|
|
953
|
+
"""
|
|
954
|
+
Create a mapping from normalised headers to actual headers in the CSV.
|
|
955
|
+
Returns a dictionary mapping expected column names to actual column names.
|
|
956
|
+
"""
|
|
957
|
+
|
|
958
|
+
id_variations = [
|
|
959
|
+
'id', 'ID', 'Id',
|
|
960
|
+
'sample_id', 'sample_ID', 'Sample_ID',
|
|
961
|
+
'SampleID', 'sample', 'Sample'
|
|
962
|
+
]
|
|
963
|
+
|
|
964
|
+
taxid_variations = [
|
|
965
|
+
'taxid', 'taxID', 'TaxID', 'TAXID',
|
|
966
|
+
'tax_id', 'Tax_ID', 'TAX_ID',
|
|
967
|
+
'taxonomy_id', 'ncbi_taxid', 'NCBI_TaxID'
|
|
968
|
+
]
|
|
969
|
+
|
|
970
|
+
id_column = None
|
|
971
|
+
taxid_column = None
|
|
972
|
+
|
|
973
|
+
# Resolve ID column
|
|
974
|
+
if id_header:
|
|
975
|
+
if id_header in fieldnames:
|
|
976
|
+
id_column = id_header
|
|
977
|
+
else:
|
|
978
|
+
logger.warning(
|
|
979
|
+
"Specified ID column '%s' not found in CSV. Trying standard variations.",
|
|
980
|
+
id_header
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
if id_column is None:
|
|
984
|
+
for variation in id_variations:
|
|
985
|
+
if variation in fieldnames:
|
|
986
|
+
id_column = variation
|
|
987
|
+
break
|
|
988
|
+
|
|
989
|
+
if id_column is None:
|
|
990
|
+
raise ValueError(
|
|
991
|
+
f"Could not find an ID column in CSV. Tried: {id_variations}"
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Resolve taxid column
|
|
995
|
+
if taxid_header:
|
|
996
|
+
if taxid_header in fieldnames:
|
|
997
|
+
taxid_column = taxid_header
|
|
998
|
+
else:
|
|
999
|
+
logger.warning(
|
|
1000
|
+
"Specified taxid column '%s' not found in CSV. Trying standard variations.",
|
|
1001
|
+
taxid_header
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
if taxid_column is None:
|
|
1005
|
+
for variation in taxid_variations:
|
|
1006
|
+
if variation in fieldnames:
|
|
1007
|
+
taxid_column = variation
|
|
1008
|
+
break
|
|
1009
|
+
|
|
1010
|
+
if taxid_column is None:
|
|
1011
|
+
raise ValueError(
|
|
1012
|
+
f"Could not find a taxid column in CSV. Tried: {taxid_variations}"
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
return {
|
|
1016
|
+
'ID': id_column,
|
|
1017
|
+
'taxid': taxid_column
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def validate_input_files(csv_file, fasta_file, logger, id_header=None, taxid_header=None):
|
|
1022
|
+
"""
|
|
1023
|
+
Validate that input files exist and have required format.
|
|
1024
|
+
"""
|
|
1025
|
+
|
|
1026
|
+
# Check CSV file exists
|
|
1027
|
+
if not os.path.exists(csv_file):
|
|
1028
|
+
logger.error(f"Input CSV file does not exist: {csv_file}")
|
|
1029
|
+
sys.exit(1)
|
|
1030
|
+
|
|
1031
|
+
try:
|
|
1032
|
+
with open(csv_file, 'r') as f:
|
|
1033
|
+
reader = csv.DictReader(f)
|
|
1034
|
+
|
|
1035
|
+
header_mapping = normalise_csv_headers(
|
|
1036
|
+
reader.fieldnames,
|
|
1037
|
+
logger,
|
|
1038
|
+
id_header,
|
|
1039
|
+
taxid_header
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
logger.info(
|
|
1043
|
+
"Found ID column: '%s', taxid column: '%s'",
|
|
1044
|
+
header_mapping['ID'],
|
|
1045
|
+
header_mapping['taxid']
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
except ValueError as e:
|
|
1049
|
+
# Raised by normalise_csv_headers
|
|
1050
|
+
logger.error(str(e))
|
|
1051
|
+
sys.exit(1)
|
|
1052
|
+
|
|
1053
|
+
except Exception as e:
|
|
1054
|
+
logger.error(f"Error reading CSV file: {str(e)}")
|
|
1055
|
+
sys.exit(1)
|
|
1056
|
+
|
|
1057
|
+
# Check FASTA file exists
|
|
1058
|
+
if not os.path.exists(fasta_file):
|
|
1059
|
+
logger.error(f"UNITE FASTA file does not exist: {fasta_file}")
|
|
1060
|
+
sys.exit(1)
|
|
1061
|
+
|
|
1062
|
+
logger.info("Input file validation passed")
|
|
1063
|
+
return header_mapping
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def write_summary_csv(summary_data, output_path, logger):
|
|
1067
|
+
"""
|
|
1068
|
+
Write the aggregated summary data to CSV file.
|
|
1069
|
+
Each sample appears once with semicolon-delimited lists and length statistics.
|
|
1070
|
+
"""
|
|
1071
|
+
fieldnames = ['ID', 'searched_taxid', 'searched_taxa', 'number', 'reference_name',
|
|
1072
|
+
'taxa', 'accession', 'min_length', 'max_length', 'avg_length']
|
|
1073
|
+
|
|
1074
|
+
try:
|
|
1075
|
+
with open(output_path, 'w', newline='') as csvfile:
|
|
1076
|
+
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
|
1077
|
+
writer.writeheader()
|
|
1078
|
+
|
|
1079
|
+
for sample_id, data in summary_data.items():
|
|
1080
|
+
# Calculate length statistics
|
|
1081
|
+
lengths = data['lengths']
|
|
1082
|
+
min_length = min(lengths) if lengths else 0
|
|
1083
|
+
max_length = max(lengths) if lengths else 0
|
|
1084
|
+
avg_length = sum(lengths) / len(lengths) if lengths else 0
|
|
1085
|
+
|
|
1086
|
+
# Create semicolon-delimited strings
|
|
1087
|
+
reference_name_str = ';'.join(data['reference_names'])
|
|
1088
|
+
taxa_str = ';'.join(data['taxa'])
|
|
1089
|
+
accession_str = ';'.join(data['accessions'])
|
|
1090
|
+
|
|
1091
|
+
row = {
|
|
1092
|
+
'ID': sample_id,
|
|
1093
|
+
'searched_taxid': data['taxid'],
|
|
1094
|
+
'searched_taxa': data.get('searched_taxa', ''),
|
|
1095
|
+
'number': len(data['reference_names']),
|
|
1096
|
+
'reference_name': reference_name_str,
|
|
1097
|
+
'taxa': taxa_str,
|
|
1098
|
+
'accession': accession_str,
|
|
1099
|
+
'min_length': min_length,
|
|
1100
|
+
'max_length': max_length,
|
|
1101
|
+
'avg_length': round(avg_length, 2)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
writer.writerow(row)
|
|
1105
|
+
|
|
1106
|
+
logger.info(f"Successfully wrote summary CSV to {output_path}")
|
|
1107
|
+
logger.info(f"Summary contains {len(summary_data)} samples")
|
|
1108
|
+
|
|
1109
|
+
except Exception as e:
|
|
1110
|
+
logger.error(f"Failed to write summary CSV: {str(e)}")
|
|
1111
|
+
raise
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def main(argv=None):
|
|
1115
|
+
"""Main function to orchestrate the UNITE sequence extraction process."""
|
|
1116
|
+
|
|
1117
|
+
# Set up argument parser
|
|
1118
|
+
parser = setup_argument_parser()
|
|
1119
|
+
args = parser.parse_args(argv)
|
|
1120
|
+
|
|
1121
|
+
# Set up logger
|
|
1122
|
+
if args.log_file is None:
|
|
1123
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
1124
|
+
args.log_file = f'UNITEd_log_{timestamp}.log'
|
|
1125
|
+
|
|
1126
|
+
# Set up logging
|
|
1127
|
+
logger = setup_logging(log_file=args.log_file)
|
|
1128
|
+
|
|
1129
|
+
# Validate argument combinations
|
|
1130
|
+
if args.all and args.number is not None:
|
|
1131
|
+
logger.warning("Both --all and --number specified. --number takes precedence.")
|
|
1132
|
+
args.all = False
|
|
1133
|
+
|
|
1134
|
+
# Validate diversity argument usage
|
|
1135
|
+
if args.diversity:
|
|
1136
|
+
if not args.tax_rank:
|
|
1137
|
+
logger.error("--diversity requires --tax_rank to be specified")
|
|
1138
|
+
sys.exit(1)
|
|
1139
|
+
if args.number is None:
|
|
1140
|
+
logger.error("--diversity requires --number to be specified")
|
|
1141
|
+
sys.exit(1)
|
|
1142
|
+
if args.all:
|
|
1143
|
+
logger.warning("--diversity overrides --all flag")
|
|
1144
|
+
args.all = False
|
|
1145
|
+
logger.info(f"Diversity mode enabled: will distribute {args.number} sequences across child taxa of {args.tax_rank}")
|
|
1146
|
+
logger.info(f" -> Will prioritise target species matches first (up to 25% of {args.number} = {max(1, int(args.number * 0.25))} sequences)")
|
|
1147
|
+
if args.traverse:
|
|
1148
|
+
logger.info(f" -> Traverse mode also enabled: will traverse up to '{args.traverse}' if insufficient child taxa found")
|
|
1149
|
+
|
|
1150
|
+
# Validate traverse argument usage
|
|
1151
|
+
if args.traverse and not args.diversity:
|
|
1152
|
+
if not args.tax_rank:
|
|
1153
|
+
logger.error("--traverse requires --tax_rank to be specified")
|
|
1154
|
+
sys.exit(1)
|
|
1155
|
+
if args.number is None:
|
|
1156
|
+
logger.error("--traverse requires --number to be specified (what target number of sequences?)")
|
|
1157
|
+
sys.exit(1)
|
|
1158
|
+
logger.info(f"Traverse mode enabled: will search from {args.tax_rank} up to {args.traverse} to collect {args.number} sequences")
|
|
1159
|
+
|
|
1160
|
+
logger.info("Starting UNITE sequence extraction")
|
|
1161
|
+
logger.info(f"Input CSV: {args.tracking_sheet}")
|
|
1162
|
+
logger.info(f"UNITE FASTA: {args.unite_db}")
|
|
1163
|
+
logger.info(f"Output directory: {args.output}")
|
|
1164
|
+
logger.info(f"Email: {args.email}")
|
|
1165
|
+
|
|
1166
|
+
if args.number is not None:
|
|
1167
|
+
logger.info(f"Retrieve specific number of sequences: {args.number}")
|
|
1168
|
+
keep_all_sequences = True # Need to keep all sequences to select top N or apply diversity
|
|
1169
|
+
elif args.all:
|
|
1170
|
+
logger.info("Retrieve all sequences: True")
|
|
1171
|
+
keep_all_sequences = True
|
|
1172
|
+
else:
|
|
1173
|
+
logger.info("Retrieve all sequences: False (longest only)")
|
|
1174
|
+
keep_all_sequences = False
|
|
1175
|
+
|
|
1176
|
+
if args.tax_rank:
|
|
1177
|
+
strategy_msg = f"Searching at taxonomic rank: {args.tax_rank}"
|
|
1178
|
+
if args.diversity:
|
|
1179
|
+
strategy_msg += " (with diversity across child taxa, prioritising target species)"
|
|
1180
|
+
if args.traverse:
|
|
1181
|
+
strategy_msg += f" (with traverse up to {args.traverse} if needed)" if args.diversity else f" (with traverse up to {args.traverse} if needed)"
|
|
1182
|
+
if not args.diversity and not args.traverse:
|
|
1183
|
+
strategy_msg += " (no fallback)"
|
|
1184
|
+
logger.info(strategy_msg)
|
|
1185
|
+
else:
|
|
1186
|
+
logger.info("Using default search strategy with fallback (species -> genus -> family -> ...)")
|
|
1187
|
+
|
|
1188
|
+
if keep_all_sequences:
|
|
1189
|
+
logger.warning("WARNING: Sequence collection mode enabled. This will use more memory for large UNITE databases.")
|
|
1190
|
+
|
|
1191
|
+
# Validate input files and get header mapping
|
|
1192
|
+
header_mapping = validate_input_files(args.tracking_sheet, args.unite_db, logger, args.id_header, args.taxid_header)
|
|
1193
|
+
|
|
1194
|
+
# Parse UNITE FASTA and build taxonomy index
|
|
1195
|
+
try:
|
|
1196
|
+
unite_index = build_unite_taxonomy_index(args.unite_db, logger, keep_all_sequences=keep_all_sequences)
|
|
1197
|
+
if not unite_index:
|
|
1198
|
+
logger.error("No taxonomy information found in UNITE FASTA file")
|
|
1199
|
+
sys.exit(1)
|
|
1200
|
+
except Exception as e:
|
|
1201
|
+
logger.error(f"Failed to parse UNITE FASTA file: {str(e)}")
|
|
1202
|
+
sys.exit(1)
|
|
1203
|
+
|
|
1204
|
+
# Process each sample in the CSV
|
|
1205
|
+
logger.info(f"Reading input CSV: {args.tracking_sheet}")
|
|
1206
|
+
|
|
1207
|
+
total_samples = 0
|
|
1208
|
+
successful_matches = 0
|
|
1209
|
+
failed_matches = 0
|
|
1210
|
+
|
|
1211
|
+
# Initialise summary CSV writer if requested
|
|
1212
|
+
summary_data = {} # Dictionary to aggregate data by sample ID
|
|
1213
|
+
if args.summary_csv:
|
|
1214
|
+
try:
|
|
1215
|
+
# Place summary CSV in the output directory
|
|
1216
|
+
summary_csv_path = os.path.join(args.output, args.summary_csv)
|
|
1217
|
+
|
|
1218
|
+
# Create output directory if it doesn't exist
|
|
1219
|
+
os.makedirs(args.output, exist_ok=True)
|
|
1220
|
+
|
|
1221
|
+
# We'll write the summary at the end after aggregating all data
|
|
1222
|
+
logger.info(f"Will create summary CSV: {summary_csv_path}")
|
|
1223
|
+
except Exception as e:
|
|
1224
|
+
logger.error(f"Failed to set up summary CSV file: {str(e)}")
|
|
1225
|
+
sys.exit(1)
|
|
1226
|
+
|
|
1227
|
+
try:
|
|
1228
|
+
with open(args.tracking_sheet, 'r') as csvfile:
|
|
1229
|
+
reader = csv.DictReader(csvfile)
|
|
1230
|
+
|
|
1231
|
+
for row in reader:
|
|
1232
|
+
total_samples += 1
|
|
1233
|
+
sample_id = row[header_mapping['ID']].strip()
|
|
1234
|
+
taxid = row[header_mapping['taxid']].strip()
|
|
1235
|
+
|
|
1236
|
+
logger.info(f"Processing sample {sample_id} with taxid {taxid}")
|
|
1237
|
+
|
|
1238
|
+
try:
|
|
1239
|
+
# Get NCBI lineage
|
|
1240
|
+
ncbi_lineage = get_ncbi_lineage(taxid, args.email, logger, args.api)
|
|
1241
|
+
|
|
1242
|
+
# Get most specific taxon name for summary
|
|
1243
|
+
searched_taxa = get_most_specific_taxon(ncbi_lineage)
|
|
1244
|
+
|
|
1245
|
+
# Find best UNITE match (now with diversity support)
|
|
1246
|
+
sequences, matched_rank = find_best_unite_match(
|
|
1247
|
+
ncbi_lineage,
|
|
1248
|
+
unite_index,
|
|
1249
|
+
logger,
|
|
1250
|
+
return_all_sequences=args.all,
|
|
1251
|
+
max_sequences=args.number,
|
|
1252
|
+
target_rank=args.tax_rank,
|
|
1253
|
+
traverse_limit=args.traverse,
|
|
1254
|
+
diversity=args.diversity
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
# Define output file path - CHANGED: flat structure with sample ID
|
|
1258
|
+
output_file = os.path.join(args.output, f"{sample_id}_seed.fasta")
|
|
1259
|
+
|
|
1260
|
+
if sequences is None:
|
|
1261
|
+
logger.warning(f"No UNITE sequence found for sample {sample_id} (taxid: {taxid})")
|
|
1262
|
+
# Create empty output file with comment
|
|
1263
|
+
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
|
1264
|
+
with open(output_file, 'w') as f:
|
|
1265
|
+
f.write(f"# No UNITE sequence found for taxid {taxid}\n")
|
|
1266
|
+
f.write(f"# NCBI lineage: {', '.join([f'{k}:{v}' for k,v in ncbi_lineage.items()])}\n")
|
|
1267
|
+
|
|
1268
|
+
failed_matches += 1
|
|
1269
|
+
else:
|
|
1270
|
+
# Write successful match(es) and collect summary data
|
|
1271
|
+
write_output_fasta(sequences, matched_rank, sample_id, taxid, output_file, logger,
|
|
1272
|
+
summary_data if args.summary_csv else None)
|
|
1273
|
+
|
|
1274
|
+
# Add searched_taxa to summary data
|
|
1275
|
+
if args.summary_csv and sample_id in summary_data:
|
|
1276
|
+
summary_data[sample_id]['searched_taxa'] = searched_taxa
|
|
1277
|
+
|
|
1278
|
+
logger.info(f"Successfully wrote seed.fasta for sample {sample_id} (taxid: {taxid})")
|
|
1279
|
+
|
|
1280
|
+
# Log diversity/traverse strategy results
|
|
1281
|
+
if isinstance(matched_rank, dict) and 'strategy' in matched_rank:
|
|
1282
|
+
if matched_rank.get('target_species_sequences', 0) > 0:
|
|
1283
|
+
logger.info(f" -> Target species priority: {matched_rank['target_species_sequences']} sequences")
|
|
1284
|
+
if not matched_rank.get('target_reached', True):
|
|
1285
|
+
logger.warning(f" -> Target not fully reached: {matched_rank['sequences_collected']}/{matched_rank['target_count']}")
|
|
1286
|
+
|
|
1287
|
+
successful_matches += 1
|
|
1288
|
+
|
|
1289
|
+
except Exception as e:
|
|
1290
|
+
logger.error(f"Error processing sample {sample_id} (taxid: {taxid}): {str(e)}")
|
|
1291
|
+
# Create empty output file for failed cases
|
|
1292
|
+
output_file = os.path.join(args.output, f"{sample_id}_seed.fasta")
|
|
1293
|
+
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
|
1294
|
+
with open(output_file, 'w') as f:
|
|
1295
|
+
f.write(f"# Error processing taxid {taxid}: {str(e)}\n")
|
|
1296
|
+
|
|
1297
|
+
failed_matches += 1
|
|
1298
|
+
|
|
1299
|
+
except Exception as e:
|
|
1300
|
+
logger.error(f"Error reading CSV file: {str(e)}")
|
|
1301
|
+
sys.exit(1)
|
|
1302
|
+
|
|
1303
|
+
# Write summary CSV if requested
|
|
1304
|
+
if args.summary_csv and summary_data:
|
|
1305
|
+
try:
|
|
1306
|
+
write_summary_csv(summary_data, summary_csv_path, logger)
|
|
1307
|
+
except Exception as e:
|
|
1308
|
+
logger.error(f"Failed to write summary CSV: {str(e)}")
|
|
1309
|
+
|
|
1310
|
+
# Log final statistics
|
|
1311
|
+
logger.info("="*50)
|
|
1312
|
+
logger.info("UNITE sequence extraction completed")
|
|
1313
|
+
logger.info(f"Total samples processed: {total_samples}")
|
|
1314
|
+
logger.info(f"Successful matches: {successful_matches}")
|
|
1315
|
+
logger.info(f"Failed matches: {failed_matches}")
|
|
1316
|
+
logger.info(f"Success rate: {(successful_matches/total_samples)*100:.1f}%")
|
|
1317
|
+
logger.info("="*50)
|
|
1318
|
+
|
|
1319
|
+
return 0
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
if __name__ == "__main__":
|
|
1323
|
+
sys.exit(main())
|