Skip to content

Commit dd4717c

Browse files
committed
feat: added alternative fast consensus clustering
1 parent ba3c586 commit dd4717c

20 files changed

Lines changed: 2634 additions & 464 deletions

src/svirlpool/__main__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ def get_parser():
202202
required=False,
203203
action="store_true",
204204
)
205+
parser_run_wf.add_argument(
206+
"--log-level",
207+
help="Set the logging level for all workflow modules that support it.",
208+
required=False,
209+
type=str,
210+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
211+
default="INFO",
212+
)
205213

206214
parser_run_wf.set_defaults(fast=False, func=run_wf.run_wf)
207215

src/svirlpool/debugtools/check_crs_consensus_completeness.py

Lines changed: 105 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,33 @@
22
"""
33
Check 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

2522
from __future__ import annotations
2623

2724
import argparse
28-
import gzip
2925
import logging
3026
import sys
3127
from dataclasses import dataclass
3228
from 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

3633
log = logging.getLogger(__name__)
3734

@@ -40,42 +37,46 @@
4037
class 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
6064
class 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

7576
def 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

158158
def 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

223225
def 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\tREGION\tSTATUS\tCONSENSUS_COUNT\tCONSENSUS_IDS\n")
245+
output_file.write(
246+
"CR_ID\tREGION\tSTATUS\tCONSENSUS_COUNT\tMERGED_WITH_CRS\tCONSENSUS_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

Comments
 (0)