Skip to content

Commit aab585c

Browse files
committed
Improve plot-circos INS track
1 parent 00fcf8e commit aab585c

2 files changed

Lines changed: 57 additions & 11 deletions

File tree

src/octopusv/cli/plot_circos.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ def plot_circos(
7373
tra_support: int = typer.Option(
7474
3,
7575
"--tra-support",
76-
help="Minimum SUPPORT for TRA (interchromosomal) links.",
76+
help="Minimum SUPPORT for TRA (interchromosomal) links. Set 0 to disable this filter (e.g. merged short-read SVCF with SUPPORT=.).",
7777
),
7878
intra_support: int = typer.Option(
7979
3,
8080
"--intra-support",
81-
help="Minimum SUPPORT for intra DEL/DUP/INV links.",
81+
help="Minimum SUPPORT for intra DEL/DUP/INV links. Set 0 to disable this filter (e.g. merged short-read SVCF with SUPPORT=.).",
8282
),
8383
intra_min_span: int = typer.Option(
8484
5000,
@@ -101,7 +101,15 @@ def plot_circos(
101101
ins_support: int = typer.Option(
102102
3,
103103
"--ins-support",
104-
help="Minimum SUPPORT for INS when --include-ins is set.",
104+
help="Minimum SUPPORT for INS when --include-ins is set. Set 0 to disable this filter (e.g. merged short-read SVCF with SUPPORT=.).",
105+
),
106+
ins_in_density: bool = typer.Option(
107+
False,
108+
"--ins-in-density",
109+
help=(
110+
"Fold INS breakpoints into the shared density histogram (legacy "
111+
"behavior). By default INS are drawn on their own marker track."
112+
),
105113
),
106114
bin_size: int = typer.Option(
107115
5_000_000,
@@ -226,6 +234,7 @@ def plot_circos(
226234
intra_max_span=intra_max_span,
227235
include_ins=include_ins,
228236
ins_support=ins_support,
237+
ins_in_density=ins_in_density,
229238
bin_size=bin_size,
230239
drop_del=drop_del,
231240
drop_dup=drop_dup,
@@ -258,7 +267,8 @@ def plot_circos(
258267
typer.echo(f"Circos figure written to {output_file}")
259268
typer.echo(
260269
f"Plotted {n_links} links (intra {n_intra}, TRA {n_tra}); "
261-
f"{len(plotter.breakpoints)} breakpoints in density layer."
270+
f"{len(plotter.breakpoints)} breakpoints in density layer; "
271+
f"{len(plotter.ins_markers)} INS markers."
262272
)
263273
typer.echo(
264274
f"Oversized intra table ({len(plotter.oversized)} events > "

src/octopusv/vis/circos_plotter.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
}
3737
INTRA_TYPES = ("DEL", "DUP", "INV")
3838

39+
# INS is a single-breakpoint event; drawn as its own marker tick (not a link,
40+
# and by default not folded into the shared breakpoint-density histogram).
41+
INS_COLOR = "#E8862A" # orange, distinct from DEL/DUP/INV/TRA
42+
3943

4044
def normalize_chrom(chrom):
4145
"""Normalize a chromosome name to 'chrN' form; return as-is if unrecognized."""
@@ -93,6 +97,7 @@ def __init__(
9397
intra_max_span=50_000_000,
9498
include_ins=False,
9599
ins_support=3,
100+
ins_in_density=False,
96101
bin_size=5_000_000,
97102
drop_del=False,
98103
drop_dup=False,
@@ -108,6 +113,7 @@ def __init__(
108113
self.intra_max_span = intra_max_span
109114
self.include_ins = include_ins
110115
self.ins_support = ins_support
116+
self.ins_in_density = ins_in_density
111117
self.bin_size = bin_size
112118
self.drop_del = drop_del
113119
self.drop_dup = drop_dup
@@ -116,6 +122,7 @@ def __init__(
116122

117123
self.links = [] # list of dicts: chrom1,pos1,chrom2,pos2,svtype,support
118124
self.breakpoints = [] # list of (chrom, pos) for density
125+
self.ins_markers = [] # list of (chrom, pos) for the INS marker track
119126
self.oversized = [] # intra events > intra_max_span
120127
self.stats = defaultdict(int)
121128

@@ -168,14 +175,20 @@ def extract(self):
168175
if not self.include_ins:
169176
self.stats["drop_ins"] += 1
170177
continue
171-
if support is None or support < self.ins_support:
178+
if self.ins_support > 0 and (support is None or support < self.ins_support):
172179
self.stats["drop_ins_lowsupport"] += 1
173180
continue
174-
if self._on_primary(c1) and 1 <= p1 <= self.chrom_sizes[c1]:
181+
if not (self._on_primary(c1) and 1 <= p1 <= self.chrom_sizes[c1]):
182+
self.stats["drop_ins_offchrom"] += 1
183+
continue
184+
if self.ins_in_density:
185+
# Legacy behavior: fold INS into the shared density histogram.
175186
self.breakpoints.append((c1, p1))
176187
self.stats["ins_density"] += 1
177188
else:
178-
self.stats["drop_ins_offchrom"] += 1
189+
# Default: INS gets its own marker tick, kept out of density.
190+
self.ins_markers.append((c1, p1))
191+
self.stats["ins_marker"] += 1
179192
continue
180193

181194
# ---- type on/off switches ----
@@ -196,7 +209,7 @@ def extract(self):
196209

197210
# ---- TRA / interchromosomal ----
198211
if svtype == "TRA":
199-
if support is None or support < self.tra_support:
212+
if self.tra_support > 0 and (support is None or support < self.tra_support):
200213
self.stats["drop_tra_lowsupport"] += 1
201214
continue
202215
if not (self._on_primary(c1) and self._on_primary(c2)):
@@ -230,7 +243,7 @@ def extract(self):
230243
if span < self.intra_min_span:
231244
self.stats["drop_intra_small"] += 1
232245
continue
233-
if support is None or support < self.intra_support:
246+
if self.intra_support > 0 and (support is None or support < self.intra_support):
234247
self.stats["drop_intra_lowsupport"] += 1
235248
continue
236249
if not (1 <= p1 <= self.chrom_sizes[c1] and 1 <= p2 <= self.chrom_sizes[c1]):
@@ -290,9 +303,18 @@ def plot(
290303

291304
circos = Circos(sectors=self.chrom_sizes, space=2)
292305
chr_r = (97, 100)
293-
dens_r = (84, 95)
306+
ins_r = (94.5, 96.5) # INS marker ticks (only used when ins_markers present)
307+
dens_r = (83, 93.5)
294308
link_r = 82
295309

310+
# INS positions per chromosome for the marker track.
311+
ins_per_chrom = defaultdict(list)
312+
for c, p in self.ins_markers:
313+
ins_per_chrom[c].append(p)
314+
# Half-width (bp) of each INS tick, so a single insertion stays visible at
315+
# genome scale (a raw 30 bp INS would be invisible otherwise).
316+
ins_tick_halfwidth = 1_000_000
317+
296318
for sector in circos.sectors:
297319
chrom = sector.name
298320
ct = sector.add_track(chr_r)
@@ -314,6 +336,16 @@ def plot(
314336
dt.bar(mids, counts, width=self.bin_size * 0.9, vmin=0, vmax=max_count,
315337
color="#4D4D4D", ec="#4D4D4D", lw=0, align="center")
316338

339+
# INS marker track: one orange tick per insertion (fixed-width so
340+
# small insertions remain visible; never folded into density here).
341+
ins_pos = ins_per_chrom.get(chrom, [])
342+
if ins_pos:
343+
it = sector.add_track(ins_r)
344+
for p in ins_pos:
345+
start = max(1, p - ins_tick_halfwidth)
346+
end = min(sector.size, p + ins_tick_halfwidth)
347+
it.rect(start, end, fc=INS_COLOR, ec=INS_COLOR, lw=0)
348+
317349
def draw(subset, alpha, lw, height):
318350
for r in subset:
319351
color = SV_COLORS.get(r["svtype"], "#999999")
@@ -332,16 +364,20 @@ def draw(subset, alpha, lw, height):
332364
n_tra = sum(1 for r in self.links if r["svtype"] == "TRA")
333365
n_intra = n - n_tra
334366
n_bp = len(self.breakpoints)
367+
n_ins = len(self.ins_markers)
368+
ins_line = f"{n_ins:,} INS markers\n" if n_ins else ""
335369
circos.text(
336370
f"{sample_name}\n{n:,} links\n(intra {n_intra:,} / TRA {n_tra:,})\n"
337-
f"{n_bp:,} breakpoints\nbin={self.bin_size / 1e6:g} Mb",
371+
f"{n_bp:,} breakpoints\n{ins_line}bin={self.bin_size / 1e6:g} Mb",
338372
r=22, size=9, ha="center", va="center",
339373
)
340374

341375
fig = circos.plotfig()
342376
present = [t for t in ("DEL", "DUP", "INV", "TRA")
343377
if any(r["svtype"] == t for r in self.links)]
344378
handles = [Line2D([0], [0], color=SV_COLORS[t], lw=2, label=t) for t in present]
379+
if self.ins_markers:
380+
handles.append(Line2D([0], [0], color=INS_COLOR, lw=4, label="INS"))
345381
handles.append(Line2D([0], [0], color="#4D4D4D", lw=4,
346382
label=f"{self.bin_size / 1e6:g} Mb breakpoint count"))
347383
circos.ax.legend(handles=handles, loc="lower center", bbox_to_anchor=(0.5, -0.07),

0 commit comments

Comments
 (0)