Skip to content

Commit 2493da2

Browse files
author
vsc20958
committed
.
1 parent 3f1255d commit 2493da2

3 files changed

Lines changed: 29 additions & 18 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ RecMpox is a command-line tool that **flags potential recombination events** in
1010
2. **Alignment and diagnostic SNPs**: The two reference genomes are aligned using [Squirrel](https://github.com/aineniamh/squirrel), so that the same genomic positions correspond across all sequences. RecMpox then identifies positions where the two references differ at the same coordinates. These positions are defined as diagnostic SNPs, because they distinguish between the reference lineages. Positions where the references are identical are ignored, as they do not provide information for detecting recombination.
1111
3. **Consensus genome classification**: Your consensus genomes are aligned to the same references. At each diagnostic SNP, the base is classified as matching reference 1, reference 2, or other (e.g., gaps or ambiguous bases).
1212
4. **Flagging potential recombinants**: If both references contribute at least 10% of the diagnostic positions in a genome, RecMpox flags it as a potential recombinant, since no single lineage clearly dominates.
13-
5. **Recombination tracts and breakpoints**: By examining the pattern of reference matches along the genome, RecMpox infers recombination tracts and identifies their breakpoints (start and end positions). To reduce false positives, runs of fewer than 2 consecutive diagnostic SNPs are ignored.
13+
5. **Recombination tracts and breakpoints**: By examining the pattern of reference matches along the genome, RecMpox infers recombination tracts and identifies their breakpoints (start and end positions). By default, no consecutive-SNP filtering is applied (minimum run length = 1), but you can ignore single-SNP runs by adding `-breakpoint-snp` (or `-b`), which sets the minimum run length to 2.
1414
6 **Outputs**:
1515
- TSV file: or each genome, reports the number and proportion of diagnostic SNPs matching each reference, the resulting recombinant flag, and summary statistics used for tract inference.
1616
- Interactive HTML report: Provides sortable tables, summary plots, per-sample visualisations, and genome-wide displays of inferred recombination tracts and breakpoints.
@@ -98,7 +98,7 @@ recmpox -i accessions.txt -o output -ref Ia,Ib # one accession per line or com
9898
- `-ref1_g`, `-ref2_g`: Genotype labels for TSV/HTML (default from `-ref` or accession)
9999
- `-include-indels`: Include diagnostic indels (default: SNPs only)
100100
- `-min-indel-size`: Min indel length (bp) when using `-include-indels` (default: 100)
101-
- `-m, --minor-ref-pct`: Minor reference % threshold for calling "potential recombinant" (default: 10). Increase to be more conservative (e.g. 15, 20).
101+
- `-m, -minor-ref-pct`: Minor reference % threshold for calling "potential recombinant" (default: 10). Increase to be more conservative (e.g. 15, 20).
102102
- `-t, --threads`: Number of threads
103103
- `-q, --quiet`: Log to file only
104104

recmpox/diagnostic_snp.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,12 +470,12 @@ def consensus_from_snp_percentages(
470470
def get_runs_and_breakpoints(
471471
positions_allegiances: List[Tuple[int, str]],
472472
diagnostic_snp_positions: List[int],
473-
min_consecutive: int = 2,
473+
min_consecutive: int = 1,
474474
) -> Tuple[List[Tuple[int, int, str, int]], List[Tuple[int, int, str, str]]]:
475475
"""
476476
Build runs of consecutive ia/ib along diagnostic SNPs; "other" (ambiguous) ends a run.
477-
Only call a breakpoint when *both* the run before and the run after have >= min_consecutive SNPs
478-
(avoids false positives from single differing SNPs flanked by the other clade). SNPs classified
477+
Only call a breakpoint when *both* the run before and the run after have >= min_consecutive SNPs.
478+
If min_consecutive is 1, all runs are considered (no consecutive-SNP filtering). SNPs classified
479479
as "other" are ignored for run boundaries.
480480
481481
Returns:

recmpox/recmpox.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ def _write_results_html(
469469
other_explanation: Optional[str] = None,
470470
is_intra_clade: bool = True,
471471
minor_threshold: float = 10.0,
472+
breakpoint_min_consecutive_snps: int = 1,
472473
part_index: Optional[int] = None,
473474
total_parts: Optional[int] = None,
474475
n_diagnostic_snps: Optional[int] = None,
@@ -652,10 +653,10 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
652653
).format(ref1=html_escape(strip_ref1_name), ref2=html_escape(strip_ref2_name))
653654
rec_sites_html = rec_sites_html + sections_html + "</div></div></div></details>"
654655

655-
# Breakpoints per sample: regions (runs) with breakpoints marked (min 2 consecutive SNPs rule; other ignored)
656+
# Breakpoints per sample: regions (runs) with breakpoints marked (optional consecutive-SNP filtering)
656657
breakpoints_section_html = ""
657658
if diagnostic_snp_positions and results:
658-
min_consecutive = 2
659+
min_consecutive = max(1, int(breakpoint_min_consecutive_snps))
659660
no_regions_placeholder = '<span class="threshold-note">No regions</span>'
660661
breakpoints_sections_html = ""
661662
for ri, r in enumerate(results):
@@ -675,9 +676,9 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
675676
merged[-1] = (merged[-1][0], end_pos, clade, merged[-1][3] + n_snps)
676677
else:
677678
merged.append((start_pos, end_pos, clade, n_snps))
678-
# Keep only sustained tracts (>= 2 SNPs); dropping 1-SNP runs can leave consecutive same-clade
679-
sustained = [m for m in merged if m[3] >= 2]
680-
# Merge again: consecutive same-clade in sustained (e.g. IIb, Ib_1SNP_dropped, IIb -> two IIb in a row)
679+
# Keep only sustained tracts (>= min_consecutive SNPs); if min_consecutive=1, keep all tracts.
680+
sustained = [m for m in merged if m[3] >= min_consecutive]
681+
# Merge again: consecutive same-clade in sustained (can occur after dropping short runs when min_consecutive>1)
681682
merged_tracts = []
682683
for (start_pos, end_pos, clade, n_snps) in sustained:
683684
if merged_tracts and merged_tracts[-1][2] == clade:
@@ -705,7 +706,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
705706
if n_tracts == 0:
706707
strip_display = no_regions_placeholder
707708
summary_text = "Show recombination tracts (Number of tracts: 0, breakpoints: 0)"
708-
details_content = '<p class="threshold-note">No sustained tracts (≥2 consecutive SNPs) in this genome.</p>'
709+
details_content = f'<p class="threshold-note">No sustained tracts (≥{min_consecutive} consecutive SNPs) in this genome.</p>'
709710
elif n_tracts == 1:
710711
strip_display = '<span class="threshold-note">No recombination (genome entirely one clade)</span>'
711712
summary_text = "No recombination tracts (genome entirely one clade)"
@@ -737,12 +738,12 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
737738
'<details class="collapsible-section diagnostic-strips-chart" open id="breakpointsStripsSection">'
738739
'<summary><h2>Recombination breakpoints per sample</h2></summary>'
739740
'<div class="section-inner chart-section">'
740-
'<p class="threshold-note">Predicted recombination breakpoints within each genome. We show the beginning and end of each detected tract (first and last diagnostic SNP of that clade). The <strong>breakpoint lies in the region between</strong> the end of one tract and the start of the next; we cannot pinpoint its exact position because those regions have no diagnostic SNPs (genetically identical). Breakpoints require at least 2 consecutive SNPs on both sides (single-SNP runs are ignored). <span id="breakpointsFilterCount" aria-live="polite"></span></p>'
741+
'<p class="threshold-note">Predicted recombination breakpoints within each genome. We show the beginning and end of each detected tract (first and last diagnostic SNP of that clade). The <strong>breakpoint lies in the region between</strong> the end of one tract and the start of the next; we cannot pinpoint its exact position because those regions have no diagnostic SNPs (genetically identical). Minimum consecutive diagnostic SNPs per tract: <strong>{min_consecutive}</strong>. <span id="breakpointsFilterCount" aria-live="polite"></span></p>'
741742
'<p class="threshold-note">{ref1} (blue), {ref2} (orange), breakpoint (red bar).</p>'
742743
'<div class="strip-legend"><span class="strip-legend-ia"></span> {ref1} &nbsp; <span class="strip-legend-ib"></span> {ref2} &nbsp; <span class="strip-legend-breakpoint"></span> breakpoint</div>'
743744
'<div class="strip-strips-container" id="breakpointsStripScrollWrapper">'
744745
'<div id="breakpointsStripsContainer">'
745-
).format(ref1=html_escape(strip_ref1_name), ref2=html_escape(strip_ref2_name))
746+
).format(ref1=html_escape(strip_ref1_name), ref2=html_escape(strip_ref2_name), min_consecutive=min_consecutive)
746747
breakpoints_section_html = breakpoints_section_html + breakpoints_sections_html + "</div></div></div></details>"
747748

748749
# Figure: diagnostic SNP positions on genome – count, histogram, then ruler (exact positions)
@@ -1219,16 +1220,25 @@ def main() -> None:
12191220
required = parser.add_argument_group("required arguments (must specify when running)")
12201221
optional = parser.add_argument_group("optional arguments")
12211222
parser.add_argument("-h", "-help", "--help", action="help", help="show this help message and exit")
1222-
optional.add_argument("--version", action="version", version=f"RecMpox v{__version__}")
1223+
optional.add_argument("-version", action="version", version=f"RecMpox v{__version__}")
12231224
optional.add_argument(
12241225
"-m",
1225-
"--minor-ref-pct",
1226+
"-minor-ref-pct",
12261227
dest="minor_ref_pct",
12271228
type=float,
12281229
default=MINOR_REF_PCT_THRESHOLD,
12291230
metavar="",
12301231
help=f"Minor reference %% threshold for calling 'potential recombinant' (default: {MINOR_REF_PCT_THRESHOLD:g}).",
12311232
)
1233+
optional.add_argument(
1234+
"-b",
1235+
"-breakpoint-snp",
1236+
dest="breakpoint_min_snps",
1237+
action="store_const",
1238+
const=2,
1239+
default=1,
1240+
help="Ignore single-SNP runs when inferring breakpoints (sets minimum consecutive diagnostic SNPs per tract to 2; default: 1).",
1241+
)
12321242
required.add_argument("-i", "-input", dest="input", type=Path, default=None, metavar="", help="FASTA file, directory of .fa/.fasta/.fna, .txt file of accessions (one per line or comma-separated), NCBI accession, or comma-separated accessions (e.g. -i ACC1,ACC2 or -i accessions.txt)")
12331243
required.add_argument("-ref", dest="ref", type=str, default=None, metavar="", help="Reference pair: two comma-separated labels among Ia, Ib, IIa, IIb (e.g. Ia,Ib or Ib,IIb). Uses built-in defaults. Either -ref or both -ref1 and -ref2 are required.")
12341244
required.add_argument("-ref1", type=str, default=None, metavar="", help="First reference: FASTA path or NCBI accession; overrides ref1 when using -ref. Required if -ref is not used.")
@@ -1251,7 +1261,8 @@ def main() -> None:
12511261
if getattr(args, "minor_ref_pct", None) is None:
12521262
args.minor_ref_pct = MINOR_REF_PCT_THRESHOLD
12531263
if args.minor_ref_pct < 0 or args.minor_ref_pct > 100:
1254-
parser.error("--minor-ref-pct must be between 0 and 100")
1264+
parser.error("-minor-ref-pct must be between 0 and 100")
1265+
# breakpoint_min_snps is a fixed 1 (default) or 2 (when -b/-breakpoint-snp is used)
12551266

12561267
if args.input is None:
12571268
parser.error("-i/-input is required")
@@ -1552,14 +1563,14 @@ def row(r: Dict[str, Any]) -> str:
15521563
n_snps = len(diagnostic_snps)
15531564
if len(results) <= HTML_CHUNK_SIZE:
15541565
out_html = args.output / "recmpox_results.html"
1555-
_write_results_html(out_html, results, ref1_label, ref2_label, recombinant_threshold_note, other_explanation, is_intra_clade, minor_threshold, n_diagnostic_snps=n_snps, n_indel_columns=n_indel_columns, ref1_spec=args.ref1, ref2_spec=args.ref2, diagnostic_snp_positions=[p for (p, _, _) in diagnostic_snps], genome_length=ref_len)
1566+
_write_results_html(out_html, results, ref1_label, ref2_label, recombinant_threshold_note, other_explanation, is_intra_clade, minor_threshold, breakpoint_min_consecutive_snps=int(getattr(args, "breakpoint_min_snps", 1)), n_diagnostic_snps=n_snps, n_indel_columns=n_indel_columns, ref1_spec=args.ref1, ref2_spec=args.ref2, diagnostic_snp_positions=[p for (p, _, _) in diagnostic_snps], genome_length=ref_len)
15561567
html_files.append(out_html)
15571568
logger.info("Wrote %s", out_html)
15581569
else:
15591570
chunks = [results[i:i + HTML_CHUNK_SIZE] for i in range(0, len(results), HTML_CHUNK_SIZE)]
15601571
for part, chunk in enumerate(chunks, start=1):
15611572
out_html = args.output / f"recmpox_results_{part}.html"
1562-
_write_results_html(out_html, chunk, ref1_label, ref2_label, recombinant_threshold_note, other_explanation, is_intra_clade, minor_threshold, part_index=part, total_parts=len(chunks), n_diagnostic_snps=n_snps, n_indel_columns=n_indel_columns, ref1_spec=args.ref1, ref2_spec=args.ref2, diagnostic_snp_positions=[p for (p, _, _) in diagnostic_snps], genome_length=ref_len)
1573+
_write_results_html(out_html, chunk, ref1_label, ref2_label, recombinant_threshold_note, other_explanation, is_intra_clade, minor_threshold, breakpoint_min_consecutive_snps=int(getattr(args, "breakpoint_min_snps", 1)), part_index=part, total_parts=len(chunks), n_diagnostic_snps=n_snps, n_indel_columns=n_indel_columns, ref1_spec=args.ref1, ref2_spec=args.ref2, diagnostic_snp_positions=[p for (p, _, _) in diagnostic_snps], genome_length=ref_len)
15631574
html_files.append(out_html)
15641575
logger.info("Wrote %s (%d genomes)", out_html, len(chunk))
15651576
# Index page linking to all parts

0 commit comments

Comments
 (0)