22"""
33Check candidate regions consensus completeness.
44
5- This script takes a CRs BED file and a consensus FASTA file and checks for completeness,
6- meaning it counts the consensus sequences for each candidate region and lists the candidate
7- regions that did not produce any consensus .
5+ This script takes a CRs BED file and a CRS container results file to check for completeness,
6+ meaning it identifies which candidate regions successfully produced consensus sequences
7+ and which ones failed to do so .
88
9- The BED file should have columns: chrom, start, end, cr_ids
10- where cr_ids can be comma-separated when multiple CRs are merged into one region.
9+ The BED file should have columns: chrom, start, end, crID
10+ where each line represents a single candidate region.
1111
12- The consensus sequences are represented in the record name of each record in the FASTA ,
13- with the prefix being the candidate region ID. The suffix is an index of the consensus
14- sequence for that candidate region .
12+ The CRS container results file contains CrsContainerResult objects (one per line as JSON) ,
13+ which store Consensus objects. Each Consensus object has a crIDs field listing all
14+ candidate region IDs that contributed to creating that consensus object .
1515
16- Example:
17- A candidate region with ID 13 can have consensus sequences with IDs:
18- "13.0", "13.1", "13.2", etc.
19-
20- Note: Some candidate regions may be merged, so multiple CR IDs can share the same
21- consensus sequence. In the BED file, this is represented as comma-separated IDs
22- (e.g., "12,13").
16+ This allows us to track:
17+ - Which candidate regions produced consensus sequences
18+ - Which candidate regions were merged together (multiple crIDs in one Consensus)
19+ - Which candidate regions failed to produce any consensus
2320"""
2421
2522from __future__ import annotations
2623
2724import argparse
28- import gzip
2925import logging
3026import sys
3127from dataclasses import dataclass
3228from pathlib import Path
3329
34- from Bio import SeqIO
30+ # Import the necessary classes and functions from the svirlpool package
31+ from ..localassembly import consensus_align
3532
3633log = logging .getLogger (__name__ )
3734
4037class CRConsensusMatch :
4138 """Results of matching a candidate region against consensus sequences."""
4239
43- cr_ids : list [ int ] # Can be multiple IDs if merged
40+ cr_id : int
4441 cr_region : str
4542 found : bool
4643 consensus_count : int
4744 consensus_ids : list [str ]
45+ merged_with_crs : set [int ] # Other CR IDs that were merged with this one
4846
4947 def __str__ (self ) -> str :
5048 """Format the match result as a string."""
5149 status = "FOUND" if self .found else "MISSING"
52- cr_ids_str = "," .join (str (cid ) for cid in self .cr_ids )
50+
51+ # Format merged CRs
52+ merged_str = "N/A"
53+ if self .merged_with_crs :
54+ merged_str = "," .join (str (cid ) for cid in sorted (self .merged_with_crs ))
55+
5356 return (
54- f"{ cr_ids_str } \t { self .cr_region } \t { status } \t "
55- f"{ self .consensus_count } \t { ',' .join (self .consensus_ids ) if self .consensus_ids else 'N/A' } "
57+ f"{ self .cr_id } \t { self .cr_region } \t { status } \t "
58+ f"{ self .consensus_count } \t { merged_str } \t "
59+ f"{ ',' .join (self .consensus_ids ) if self .consensus_ids else 'N/A' } "
5660 )
5761
5862
5963@dataclass
6064class CandidateRegion :
6165 """Simple representation of a candidate region from BED file."""
6266
63- cr_ids : list [ int ] # Can be multiple IDs if merged
67+ cr_id : int
6468 chrom : str
6569 start : int
6670 end : int
6771
6872 def region_string (self ) -> str :
6973 return f"{ self .chrom } :{ self .start } -{ self .end } "
7074
71- def ids_string (self ) -> str :
72- return "," .join (str (cid ) for cid in self .cr_ids )
73-
7475
7576def load_candidate_regions_from_bed (bed_path : Path ) -> list [CandidateRegion ]:
7677 """Load candidate regions from BED file.
7778
78- Expected BED format: chrom, start, end, cr_ids (comma-separated)
79+ Expected BED format: chrom, start, end, crID
7980 """
8081 crs : list [CandidateRegion ] = []
8182
@@ -96,13 +97,11 @@ def load_candidate_regions_from_bed(bed_path: Path) -> list[CandidateRegion]:
9697 chrom = fields [0 ]
9798 start = int (fields [1 ])
9899 end = int (fields [2 ])
99- # Parse comma-separated CR IDs
100- cr_ids_str = fields [3 ]
101- cr_ids = [int (cid .strip ()) for cid in cr_ids_str .split ("," )]
100+ cr_id = int (fields [3 ])
102101
103102 crs .append (
104103 CandidateRegion (
105- cr_ids = cr_ids ,
104+ cr_id = cr_id ,
106105 chrom = chrom ,
107106 start = start ,
108107 end = end ,
@@ -116,91 +115,92 @@ def load_candidate_regions_from_bed(bed_path: Path) -> list[CandidateRegion]:
116115 return crs
117116
118117
119- def load_consensus_sequences (consensus_fasta_path : Path ) -> dict [int , list [str ]]:
120- """Load consensus sequences from FASTA file and group by CR ID.
118+ def load_consensus_from_container_results (
119+ container_results_path : Path ,
120+ ) -> tuple [dict [int , list [str ]], dict [str , set [int ]]]:
121+ """Load consensus objects from CRS container results file.
121122
122123 Returns:
123- Dictionary mapping CR ID to list of consensus sequence IDs
124+ Tuple of:
125+ - Dictionary mapping CR ID to list of consensus IDs
126+ - Dictionary mapping consensus ID to set of all CR IDs that contributed to it
124127 """
125128 consensus_by_cr : dict [int , list [str ]] = {}
129+ consensus_to_crs : dict [str , set [int ]] = {}
126130
127- # Handle both gzipped and regular FASTA files
128- if str (consensus_fasta_path ).endswith (".gz" ):
129- handle = gzip .open (consensus_fasta_path , "rt" )
130- else :
131- handle = open (consensus_fasta_path , "r" )
131+ log .info (f"Parsing CRS container results from { container_results_path } " )
132132
133- try :
134- for record in SeqIO .parse (handle , "fasta" ):
135- # Parse consensus ID (e.g., "13.0" -> CR ID 13, consensus index 0)
136- consensus_id = record .id
137- try :
138- # Split by '.' to get CR ID
139- cr_id_str = consensus_id .split ("." )[0 ]
140- cr_id = int (cr_id_str )
133+ for crs_container in consensus_align .parse_crs_container_results (
134+ container_results_path
135+ ):
136+ # Process each Consensus object in this container
137+ for consensus_id , consensus_obj in crs_container .consensus_dicts .items ():
138+ # Record the consensus ID and which CRs contributed to it
139+ consensus_to_crs [consensus_id ] = set (consensus_obj .crIDs )
141140
141+ # For each CR ID that contributed, record this consensus
142+ for cr_id in consensus_obj .crIDs :
142143 if cr_id not in consensus_by_cr :
143144 consensus_by_cr [cr_id ] = []
144145 consensus_by_cr [cr_id ].append (consensus_id )
145- except (ValueError , IndexError ) as e :
146- log .warning (f"Could not parse consensus ID '{ consensus_id } ': { e } " )
147- continue
148- finally :
149- handle .close ()
146+
147+ total_consensus = sum (len (v ) for v in consensus_by_cr .values ())
148+ unique_consensus = len (consensus_to_crs )
150149
151150 log .info (
152- f"Loaded { sum (len (v ) for v in consensus_by_cr .values ())} consensus sequences "
151+ f"Loaded { unique_consensus } unique consensus sequences "
152+ f"(total { total_consensus } CR-to-consensus mappings) "
153153 f"for { len (consensus_by_cr )} candidate regions"
154154 )
155- return consensus_by_cr
155+ return consensus_by_cr , consensus_to_crs
156156
157157
158158def compare_crs_to_consensus (
159- crs_bed_path : Path , consensus_fasta_path : Path
159+ crs_bed_path : Path , container_results_path : Path
160160) -> tuple [list [CRConsensusMatch ], dict [str , int ]]:
161161 """Compare candidate regions to consensus sequences and return detailed results.
162162
163163 This function is suitable for use in unit tests.
164164
165165 Args:
166166 crs_bed_path: Path to candidate regions BED file
167- consensus_fasta_path : Path to consensus FASTA file
167+ container_results_path : Path to CRS container results file
168168
169169 Returns:
170170 Tuple of (results, statistics) where:
171171 - results: List of CRConsensusMatch objects for each candidate region
172- - statistics: Dict with keys 'total', 'found', 'missing', 'total_consensus'
172+ - statistics: Dict with keys 'total', 'found', 'missing', 'total_consensus', 'merged'
173173 """
174174 candidate_regions = load_candidate_regions_from_bed (crs_bed_path )
175- consensus_by_cr = load_consensus_sequences (consensus_fasta_path )
175+ consensus_by_cr , consensus_to_crs = load_consensus_from_container_results (
176+ container_results_path
177+ )
176178
177179 results : list [CRConsensusMatch ] = []
178180
179181 for cr in candidate_regions :
180- cr_ids = cr .cr_ids
182+ cr_id = cr .cr_id
181183 cr_region = cr .region_string ()
182184
183- # Collect consensus IDs for all CR IDs in this region (in case of merged CRs)
184- all_consensus_ids = []
185- for cr_id in cr_ids :
186- all_consensus_ids .extend (consensus_by_cr .get (cr_id , []))
187-
188- # Remove duplicates while preserving order
189- seen = set ()
190- unique_consensus_ids = []
191- for cid in all_consensus_ids :
192- if cid not in seen :
193- seen .add (cid )
194- unique_consensus_ids .append (cid )
185+ # Get consensus IDs for this CR
186+ consensus_ids = consensus_by_cr .get (cr_id , [])
187+ found = len (consensus_ids ) > 0
195188
196- found = len (unique_consensus_ids ) > 0
189+ # Determine which other CRs this one was merged with
190+ merged_with_crs = set ()
191+ for consensus_id in consensus_ids :
192+ # Get all CRs that contributed to this consensus
193+ contributing_crs = consensus_to_crs .get (consensus_id , set ())
194+ # Add all other CRs (excluding the current one)
195+ merged_with_crs .update (crid for crid in contributing_crs if crid != cr_id )
197196
198197 match = CRConsensusMatch (
199- cr_ids = cr_ids ,
198+ cr_id = cr_id ,
200199 cr_region = cr_region ,
201200 found = found ,
202- consensus_count = len (unique_consensus_ids ),
203- consensus_ids = unique_consensus_ids ,
201+ consensus_count = len (consensus_ids ),
202+ consensus_ids = consensus_ids ,
203+ merged_with_crs = merged_with_crs ,
204204 )
205205 results .append (match )
206206
@@ -209,61 +209,79 @@ def compare_crs_to_consensus(
209209 found = sum (1 for r in results if r .found )
210210 missing = total - found
211211 total_consensus = sum (r .consensus_count for r in results )
212+ merged = sum (1 for r in results if r .merged_with_crs )
212213
213214 statistics = {
214215 "total" : total ,
215216 "found" : found ,
216217 "missing" : missing ,
217218 "total_consensus" : total_consensus ,
219+ "merged" : merged ,
218220 }
219221
220222 return results , statistics
221223
222224
223225def check_crs_consensus_completeness (
224- crs_bed_path : Path , consensus_fasta_path : Path , output_path : Path | None = None
226+ crs_bed_path : Path , container_results_path : Path , output_path : Path | None = None
225227) -> None :
226228 """Check candidate regions consensus completeness."""
227229 log .info (f"Reading candidate regions from { crs_bed_path } " )
228- log .info (f"Reading consensus sequences from { consensus_fasta_path } " )
230+ log .info (f"Reading consensus from container results { container_results_path } " )
229231
230- results , statistics = compare_crs_to_consensus (crs_bed_path , consensus_fasta_path )
232+ results , statistics = compare_crs_to_consensus (crs_bed_path , container_results_path )
231233
232234 total = statistics ["total" ]
233235 found = statistics ["found" ]
234236 missing = statistics ["missing" ]
235237 total_consensus = statistics ["total_consensus" ]
238+ merged = statistics ["merged" ]
236239
237240 # Output results
238241 output_file = open (output_path , "w" ) if output_path else sys .stdout
239242
240243 try :
241244 # Write header
242- output_file .write ("CR_ID\t REGION\t STATUS\t CONSENSUS_COUNT\t CONSENSUS_IDS\n " )
245+ output_file .write (
246+ "CR_ID\t REGION\t STATUS\t CONSENSUS_COUNT\t MERGED_WITH_CRS\t CONSENSUS_IDS\n "
247+ )
243248
244249 # Write results
245250 for result in results :
246251 output_file .write (str (result ) + "\n " )
247252 # Print missing CRs to terminal
248253 if not result .found :
249- cr_ids_str = "," .join (str (cid ) for cid in result .cr_ids )
250254 log .warning (
251- f"Missing consensus for CR(s) { cr_ids_str } : { result .cr_region } "
255+ f"Missing consensus for CR { result . cr_id } : { result .cr_region } "
252256 )
257+ # Print merged CRs to terminal
258+ # elif result.merged_with_crs:
259+ # merged_str = ",".join(str(cid) for cid in sorted(result.merged_with_crs))
260+ # log.info(
261+ # f"CR {result.cr_id} was merged with CR(s) {merged_str}"
262+ # )
253263
254264 # Summary statistics
255265 output_file .write ("\n # SUMMARY\n " )
256266 output_file .write (f"# Total candidate regions: { total } \n " )
257- output_file .write (f"# With consensus: { found } ({ found / total * 100 :.1f} %)\n " )
267+ found_percentage = (found / total * 100 ) if total > 0 else 0
268+ output_file .write (f"# With consensus: { found } ({ found_percentage :.1f} %)\n " )
269+ missing_percentage = (missing / total * 100 ) if total > 0 else 0
270+ output_file .write (
271+ f"# Without consensus: { missing } ({ missing_percentage :.1f} %)\n "
272+ )
273+ merged_percentage = (merged / total * 100 ) if total > 0 else 0
258274 output_file .write (
259- f"# Without consensus : { missing } ({ missing / total * 100 :.1f} %)\n "
275+ f"# Merged with other CRs : { merged } ({ merged_percentage :.1f} %)\n "
260276 )
261277 output_file .write (f"# Total consensus sequences: { total_consensus } \n " )
278+ average_consensus = (total_consensus / found ) if found > 0 else 0
262279 output_file .write (
263- f"# Average consensus per CR: { total_consensus / total :.2f} \n "
280+ f"# Average consensus per CR (with consensus): { average_consensus :.2f} \n "
264281 )
265282
266283 log .info (f"Candidate regions with consensus: { found } /{ total } " )
284+ log .info (f"Candidate regions merged with others: { merged } " )
267285 log .info (f"Total consensus sequences: { total_consensus } " )
268286
269287 finally :
@@ -283,14 +301,14 @@ def build_parser() -> argparse.ArgumentParser:
283301 "--crs-bed" ,
284302 type = Path ,
285303 required = True ,
286- help = "Path to candidate regions BED file (chrom, start, end, cr_ids )" ,
304+ help = "Path to candidate regions BED file (chrom, start, end, crID )" ,
287305 )
288306 parser .add_argument (
289307 "-c" ,
290- "--consensus-fasta " ,
308+ "--container-results " ,
291309 type = Path ,
292310 required = True ,
293- help = "Path to consensus FASTA file (can be gzipped )" ,
311+ help = "Path to CRS container results file (JSON lines format )" ,
294312 )
295313 parser .add_argument (
296314 "-o" ,
@@ -322,7 +340,7 @@ def main() -> None:
322340
323341 check_crs_consensus_completeness (
324342 crs_bed_path = args .crs_bed ,
325- consensus_fasta_path = args .consensus_fasta ,
343+ container_results_path = args .container_results ,
326344 output_path = args .output ,
327345 )
328346
0 commit comments