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.
@@ -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())