pylocuszoom 0.2.0__py3-none-any.whl → 0.5.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.
pylocuszoom/__init__.py CHANGED
@@ -34,7 +34,7 @@ Species Support:
34
34
  - Custom: User provides all reference data
35
35
  """
36
36
 
37
- __version__ = "0.1.0"
37
+ __version__ = "0.3.0"
38
38
 
39
39
  # Main plotter class
40
40
  # Backend types
@@ -74,6 +74,31 @@ from .labels import add_snp_labels
74
74
  # LD calculation
75
75
  from .ld import calculate_ld
76
76
 
77
+ # File format loaders
78
+ from .loaders import (
79
+ load_bed,
80
+ load_bolt_lmm,
81
+ load_caviar,
82
+ load_ensembl_genes,
83
+ load_eqtl_catalogue,
84
+ load_finemap,
85
+ load_gemma,
86
+ # eQTL loaders
87
+ load_gtex_eqtl,
88
+ # Gene annotation loaders
89
+ load_gtf,
90
+ # GWAS loaders
91
+ load_gwas,
92
+ load_gwas_catalog,
93
+ load_matrixeqtl,
94
+ load_plink_assoc,
95
+ load_polyfun,
96
+ load_regenie,
97
+ load_saige,
98
+ # Fine-mapping loaders
99
+ load_susie,
100
+ )
101
+
77
102
  # Logging configuration
78
103
  from .logging import disable_logging, enable_logging
79
104
  from .plotter import LocusZoomPlotter
@@ -86,6 +111,9 @@ from .recombination import (
86
111
  load_recombination_map,
87
112
  )
88
113
 
114
+ # Schema validation
115
+ from .schemas import LoaderValidationError
116
+
89
117
  # Validation utilities
90
118
  from .utils import ValidationError, to_pandas
91
119
 
@@ -136,4 +164,27 @@ __all__ = [
136
164
  # Validation & Utils
137
165
  "ValidationError",
138
166
  "to_pandas",
167
+ # GWAS loaders
168
+ "load_gwas",
169
+ "load_plink_assoc",
170
+ "load_regenie",
171
+ "load_bolt_lmm",
172
+ "load_gemma",
173
+ "load_saige",
174
+ "load_gwas_catalog",
175
+ # eQTL loaders
176
+ "load_gtex_eqtl",
177
+ "load_eqtl_catalogue",
178
+ "load_matrixeqtl",
179
+ # Fine-mapping loaders
180
+ "load_susie",
181
+ "load_finemap",
182
+ "load_caviar",
183
+ "load_polyfun",
184
+ # Gene annotation loaders
185
+ "load_gtf",
186
+ "load_bed",
187
+ "load_ensembl_genes",
188
+ # Schema validation
189
+ "LoaderValidationError",
139
190
  ]
@@ -132,6 +132,7 @@ class PlotBackend(Protocol):
132
132
  color: str = "grey",
133
133
  linestyle: str = "--",
134
134
  linewidth: float = 1.0,
135
+ alpha: float = 1.0,
135
136
  zorder: int = 1,
136
137
  ) -> Any:
137
138
  """Add a horizontal line across the axes.
@@ -142,6 +143,7 @@ class PlotBackend(Protocol):
142
143
  color: Line color.
143
144
  linestyle: Line style.
144
145
  linewidth: Line width.
146
+ alpha: Line transparency (0-1).
145
147
  zorder: Drawing order.
146
148
 
147
149
  Returns:
@@ -339,3 +341,48 @@ class PlotBackend(Protocol):
339
341
  fig: Figure object.
340
342
  """
341
343
  ...
344
+
345
+ def add_eqtl_legend(
346
+ self,
347
+ ax: Any,
348
+ eqtl_positive_bins: List[Tuple[float, float, str, str]],
349
+ eqtl_negative_bins: List[Tuple[float, float, str, str]],
350
+ ) -> None:
351
+ """Add eQTL effect size legend to the axes.
352
+
353
+ Args:
354
+ ax: Axes or panel.
355
+ eqtl_positive_bins: List of (min, max, label, color) for positive effects.
356
+ eqtl_negative_bins: List of (min, max, label, color) for negative effects.
357
+ """
358
+ ...
359
+
360
+ def add_finemapping_legend(
361
+ self,
362
+ ax: Any,
363
+ credible_sets: List[int],
364
+ get_color_func: Any,
365
+ ) -> None:
366
+ """Add fine-mapping credible set legend to the axes.
367
+
368
+ Args:
369
+ ax: Axes or panel.
370
+ credible_sets: List of credible set IDs to show.
371
+ get_color_func: Function that takes CS ID and returns color.
372
+ """
373
+ ...
374
+
375
+ def add_simple_legend(
376
+ self,
377
+ ax: Any,
378
+ label: str,
379
+ loc: str = "upper right",
380
+ ) -> None:
381
+ """Add a simple legend entry for labeled data already in the plot.
382
+
383
+ Args:
384
+ ax: Axes or panel.
385
+ label: Legend label for labeled scatter data.
386
+ loc: Legend location.
387
+ """
388
+ ...
@@ -8,7 +8,7 @@ from typing import Any, List, Optional, Tuple, Union
8
8
  import pandas as pd
9
9
  from bokeh.io import export_png, export_svgs, output_file, save, show
10
10
  from bokeh.layouts import column
11
- from bokeh.models import ColumnDataSource, HoverTool, Span
11
+ from bokeh.models import ColumnDataSource, DataRange1d, HoverTool, Span
12
12
  from bokeh.plotting import figure
13
13
 
14
14
 
@@ -56,29 +56,29 @@ class BokehBackend:
56
56
  heights = [int(total_height * r / total_ratio) for r in height_ratios]
57
57
 
58
58
  figures = []
59
- x_range = None
59
+ x_range = DataRange1d() if sharex else None
60
60
 
61
61
  for i, h in enumerate(heights):
62
62
  p = figure(
63
63
  width=width_px,
64
64
  height=h,
65
- x_range=x_range if sharex and x_range else None,
65
+ x_range=x_range if sharex else DataRange1d(),
66
66
  tools="pan,wheel_zoom,box_zoom,reset,save",
67
67
  toolbar_location="above" if i == 0 else None,
68
68
  )
69
69
 
70
- # Share x_range for subsequent figures
71
- if sharex and x_range is None:
72
- x_range = p.x_range
73
-
74
- # Style
75
- p.grid.grid_line_alpha = 0.3
70
+ # Style - no grid lines, black axes for clean LocusZoom appearance
71
+ p.grid.visible = False
76
72
  p.outline_line_color = None
73
+ p.xaxis.axis_line_color = "black"
74
+ p.yaxis.axis_line_color = "black"
75
+ p.xaxis.minor_tick_line_color = None
76
+ p.yaxis.minor_tick_line_color = None
77
77
 
78
78
  figures.append(p)
79
79
 
80
- # Create column layout
81
- layout = column(*figures, sizing_mode="fixed")
80
+ # Create column layout (use default sizing mode to avoid validation warnings)
81
+ layout = column(*figures)
82
82
 
83
83
  return layout, figures
84
84
 
@@ -118,29 +118,34 @@ class BokehBackend:
118
118
  if hover_data is not None:
119
119
  for col in hover_data.columns:
120
120
  data[col] = hover_data[col].values
121
- if "p" in col.lower():
121
+ col_lower = col.lower()
122
+ if col_lower in ("p-value", "pval", "p_value"):
122
123
  tooltips.append((col, "@{" + col + "}{0.2e}"))
123
- elif "r2" in col.lower() or "ld" in col.lower():
124
+ elif any(x in col_lower for x in ("r2", "r²", "ld")):
124
125
  tooltips.append((col, "@{" + col + "}{0.3f}"))
126
+ elif "pos" in col_lower:
127
+ tooltips.append((col, "@{" + col + "}{0,0}"))
125
128
  else:
126
129
  tooltips.append((col, f"@{col}"))
127
130
 
128
131
  source = ColumnDataSource(data)
129
132
 
130
- # Get marker type
133
+ # Get marker type for scatter()
131
134
  marker_type = self._marker_map.get(marker, "circle")
132
135
 
133
- # Create scatter
134
- renderer = getattr(ax, marker_type)(
135
- "x",
136
- "y",
137
- source=source,
138
- size="size",
139
- fill_color="color",
140
- line_color=edgecolor,
141
- line_width=linewidth,
142
- legend_label=label if label else None,
143
- )
136
+ # Create scatter using scatter() method (Bokeh 3.4+ preferred API)
137
+ scatter_kwargs = {
138
+ "source": source,
139
+ "marker": marker_type,
140
+ "size": "size",
141
+ "fill_color": "color",
142
+ "line_color": edgecolor,
143
+ "line_width": linewidth,
144
+ }
145
+ if label:
146
+ scatter_kwargs["legend_label"] = label
147
+
148
+ renderer = ax.scatter("x", "y", **scatter_kwargs)
144
149
 
145
150
  # Add hover tool if we have hover data
146
151
  if tooltips:
@@ -175,15 +180,16 @@ class BokehBackend:
175
180
  }
176
181
  line_dash = dash_map.get(linestyle, "solid")
177
182
 
178
- return ax.line(
179
- x.values,
180
- y.values,
181
- line_color=color,
182
- line_width=linewidth,
183
- line_alpha=alpha,
184
- line_dash=line_dash,
185
- legend_label=label if label else None,
186
- )
183
+ line_kwargs = {
184
+ "line_color": color,
185
+ "line_width": linewidth,
186
+ "line_alpha": alpha,
187
+ "line_dash": line_dash,
188
+ }
189
+ if label:
190
+ line_kwargs["legend_label"] = label
191
+
192
+ return ax.line(x.values, y.values, **line_kwargs)
187
193
 
188
194
  def fill_between(
189
195
  self,
@@ -223,6 +229,7 @@ class BokehBackend:
223
229
  color: str = "grey",
224
230
  linestyle: str = "--",
225
231
  linewidth: float = 1.0,
232
+ alpha: float = 1.0,
226
233
  zorder: int = 1,
227
234
  ) -> Any:
228
235
  """Add a horizontal line across the figure."""
@@ -235,6 +242,7 @@ class BokehBackend:
235
242
  line_color=color,
236
243
  line_dash=line_dash,
237
244
  line_width=linewidth,
245
+ line_alpha=alpha,
238
246
  )
239
247
  ax.add_layout(span)
240
248
  return span
@@ -303,6 +311,28 @@ class BokehBackend:
303
311
  line_width=linewidth,
304
312
  )
305
313
 
314
+ def add_polygon(
315
+ self,
316
+ ax: figure,
317
+ points: List[List[float]],
318
+ facecolor: str = "blue",
319
+ edgecolor: str = "black",
320
+ linewidth: float = 0.5,
321
+ zorder: int = 2,
322
+ ) -> Any:
323
+ """Add a polygon (e.g., triangle for strand arrows) to the figure."""
324
+ xs = [p[0] for p in points]
325
+ ys = [p[1] for p in points]
326
+
327
+ # Bokeh patch() uses x/y (singular) for single polygon
328
+ return ax.patch(
329
+ x=xs,
330
+ y=ys,
331
+ fill_color=facecolor,
332
+ line_color=edgecolor,
333
+ line_width=linewidth,
334
+ )
335
+
306
336
  def set_xlim(self, ax: figure, left: float, right: float) -> None:
307
337
  """Set x-axis limits."""
308
338
  ax.x_range.start = left
@@ -315,14 +345,41 @@ class BokehBackend:
315
345
 
316
346
  def set_xlabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
317
347
  """Set x-axis label."""
348
+ label = self._convert_label(label)
318
349
  ax.xaxis.axis_label = label
319
350
  ax.xaxis.axis_label_text_font_size = f"{fontsize}pt"
320
351
 
321
352
  def set_ylabel(self, ax: figure, label: str, fontsize: int = 12) -> None:
322
353
  """Set y-axis label."""
354
+ label = self._convert_label(label)
323
355
  ax.yaxis.axis_label = label
324
356
  ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
325
357
 
358
+ def _get_legend_location(self, loc: str, default: str = "top_left") -> str:
359
+ """Map matplotlib-style legend location to Bokeh location."""
360
+ loc_map = {
361
+ "upper left": "top_left",
362
+ "upper right": "top_right",
363
+ "lower left": "bottom_left",
364
+ "lower right": "bottom_right",
365
+ }
366
+ return loc_map.get(loc, default)
367
+
368
+ def _convert_label(self, label: str) -> str:
369
+ """Convert LaTeX-style labels to Unicode for Bokeh display."""
370
+ conversions = [
371
+ (r"$-\log_{10}$ P", "-log₁₀(P)"),
372
+ (r"$-\log_{10}$", "-log₁₀"),
373
+ (r"\log_{10}", "log₁₀"),
374
+ (r"$r^2$", "r²"),
375
+ (r"$R^2$", "R²"),
376
+ ]
377
+ for latex, unicode_str in conversions:
378
+ if latex in label:
379
+ label = label.replace(latex, unicode_str)
380
+ label = label.replace("$", "")
381
+ return label
382
+
326
383
  def set_title(self, ax: figure, title: str, fontsize: int = 14) -> None:
327
384
  """Set figure title."""
328
385
  ax.title.text = title
@@ -341,6 +398,171 @@ class BokehBackend:
341
398
 
342
399
  return "secondary"
343
400
 
401
+ def line_secondary(
402
+ self,
403
+ ax: figure,
404
+ x: pd.Series,
405
+ y: pd.Series,
406
+ color: str = "blue",
407
+ linewidth: float = 1.5,
408
+ alpha: float = 1.0,
409
+ linestyle: str = "-",
410
+ label: Optional[str] = None,
411
+ yaxis_name: str = "secondary",
412
+ ) -> Any:
413
+ """Create a line plot on secondary y-axis."""
414
+ dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
415
+ line_dash = dash_map.get(linestyle, "solid")
416
+
417
+ return ax.line(
418
+ x.values,
419
+ y.values,
420
+ line_color=color,
421
+ line_width=linewidth,
422
+ line_alpha=alpha,
423
+ line_dash=line_dash,
424
+ y_range_name=yaxis_name,
425
+ )
426
+
427
+ def fill_between_secondary(
428
+ self,
429
+ ax: figure,
430
+ x: pd.Series,
431
+ y1: Union[float, pd.Series],
432
+ y2: Union[float, pd.Series],
433
+ color: str = "blue",
434
+ alpha: float = 0.3,
435
+ yaxis_name: str = "secondary",
436
+ ) -> Any:
437
+ """Fill area between two y-values on secondary y-axis."""
438
+ x_arr = x.values
439
+ if isinstance(y1, (int, float)):
440
+ y1_arr = [y1] * len(x_arr)
441
+ else:
442
+ y1_arr = y1.values if hasattr(y1, "values") else list(y1)
443
+
444
+ if isinstance(y2, (int, float)):
445
+ y2_arr = [y2] * len(x_arr)
446
+ else:
447
+ y2_arr = y2.values if hasattr(y2, "values") else list(y2)
448
+
449
+ return ax.varea(
450
+ x=x_arr,
451
+ y1=y1_arr,
452
+ y2=y2_arr,
453
+ fill_color=color,
454
+ fill_alpha=alpha,
455
+ y_range_name=yaxis_name,
456
+ )
457
+
458
+ def set_secondary_ylim(
459
+ self,
460
+ ax: figure,
461
+ bottom: float,
462
+ top: float,
463
+ yaxis_name: str = "secondary",
464
+ ) -> None:
465
+ """Set secondary y-axis limits."""
466
+ if yaxis_name in ax.extra_y_ranges:
467
+ ax.extra_y_ranges[yaxis_name].start = bottom
468
+ ax.extra_y_ranges[yaxis_name].end = top
469
+
470
+ def set_secondary_ylabel(
471
+ self,
472
+ ax: figure,
473
+ label: str,
474
+ color: str = "black",
475
+ fontsize: int = 10,
476
+ yaxis_name: str = "secondary",
477
+ ) -> None:
478
+ """Set secondary y-axis label."""
479
+ label = self._convert_label(label)
480
+ # Find the secondary axis and update its label
481
+ for renderer in ax.right:
482
+ if (
483
+ hasattr(renderer, "y_range_name")
484
+ and renderer.y_range_name == yaxis_name
485
+ ):
486
+ renderer.axis_label = label
487
+ renderer.axis_label_text_font_size = f"{fontsize}pt"
488
+ renderer.axis_label_text_color = color
489
+ renderer.major_label_text_color = color
490
+ break
491
+
492
+ def _ensure_legend_range(self, ax: figure) -> Any:
493
+ """Ensure legend range exists and return a dummy data source.
494
+
495
+ Creates a separate y-range for legend glyphs so they don't affect
496
+ the main plot's axis scaling.
497
+ """
498
+ from bokeh.models import ColumnDataSource, Range1d
499
+
500
+ if "legend_range" not in ax.extra_y_ranges:
501
+ ax.extra_y_ranges["legend_range"] = Range1d(start=0, end=1)
502
+ return ColumnDataSource(data={"x": [0], "y": [0]})
503
+
504
+ def _add_legend_item(
505
+ self,
506
+ ax: figure,
507
+ source: Any,
508
+ label: str,
509
+ color: str,
510
+ marker: str,
511
+ size: int = 10,
512
+ ) -> Any:
513
+ """Create an invisible scatter renderer for a legend entry."""
514
+ from bokeh.models import LegendItem
515
+
516
+ renderer = ax.scatter(
517
+ x="x",
518
+ y="y",
519
+ source=source,
520
+ marker=marker,
521
+ size=size,
522
+ fill_color=color,
523
+ line_color="black",
524
+ line_width=0.5,
525
+ y_range_name="legend_range",
526
+ visible=False,
527
+ )
528
+ return LegendItem(label=label, renderers=[renderer])
529
+
530
+ def _create_legend(self, ax: figure, items: List[Any], title: str) -> None:
531
+ """Create and add a styled legend to the figure."""
532
+ from bokeh.models import Legend
533
+
534
+ legend = Legend(
535
+ items=items,
536
+ location="top_right",
537
+ title=title,
538
+ background_fill_alpha=0.9,
539
+ border_line_color="black",
540
+ spacing=0,
541
+ padding=4,
542
+ label_height=12,
543
+ glyph_height=12,
544
+ )
545
+ ax.add_layout(legend)
546
+
547
+ def add_ld_legend(
548
+ self,
549
+ ax: figure,
550
+ ld_bins: List[Tuple[float, str, str]],
551
+ lead_snp_color: str,
552
+ ) -> None:
553
+ """Add LD color legend using invisible dummy glyphs.
554
+
555
+ Creates legend entries with dummy renderers that are excluded from
556
+ the data range calculation to avoid affecting axis scaling.
557
+ """
558
+ source = self._ensure_legend_range(ax)
559
+ items = [
560
+ self._add_legend_item(ax, source, "Lead SNP", lead_snp_color, "diamond", 12)
561
+ ]
562
+ for _, label, color in ld_bins:
563
+ items.append(self._add_legend_item(ax, source, label, color, "square"))
564
+ self._create_legend(ax, items, "r²")
565
+
344
566
  def add_legend(
345
567
  self,
346
568
  ax: figure,
@@ -350,17 +572,7 @@ class BokehBackend:
350
572
  title: Optional[str] = None,
351
573
  ) -> Any:
352
574
  """Configure legend on the figure."""
353
- # Bokeh handles legend automatically from legend_label
354
- # Just configure position
355
-
356
- loc_map = {
357
- "upper left": "top_left",
358
- "upper right": "top_right",
359
- "lower left": "bottom_left",
360
- "lower right": "bottom_right",
361
- }
362
-
363
- ax.legend.location = loc_map.get(loc, "top_left")
575
+ ax.legend.location = self._get_legend_location(loc, "top_left")
364
576
  if title:
365
577
  ax.legend.title = title
366
578
  ax.legend.background_fill_alpha = 0.9
@@ -369,26 +581,25 @@ class BokehBackend:
369
581
  return ax.legend
370
582
 
371
583
  def hide_spines(self, ax: figure, spines: List[str]) -> None:
372
- """Hide specified axis spines."""
373
- # Bokeh doesn't have spines in the same way
374
- # We can hide axis lines
375
- if "top" in spines:
376
- ax.xaxis.visible = ax.xaxis.visible # Keep visible but could customize
377
- if "right" in spines:
378
- ax.yaxis.visible = ax.yaxis.visible
584
+ """Hide specified axis spines (no-op for Bokeh).
379
585
 
380
- def format_xaxis_mb(self, ax: figure) -> None:
381
- """Format x-axis to show megabase values."""
382
- from bokeh.models import NumeralTickFormatter
586
+ Bokeh doesn't have matplotlib-style spines. This method exists
587
+ for interface compatibility but has no visual effect.
588
+ """
589
+ pass
383
590
 
384
- ax.xaxis.formatter = NumeralTickFormatter(format="0.00")
385
- ax.xaxis.axis_label = ax.xaxis.axis_label or "Position (Mb)"
591
+ def hide_yaxis(self, ax: figure) -> None:
592
+ """Hide y-axis ticks, labels, line, and grid for gene track panels."""
593
+ ax.yaxis.visible = False
594
+ ax.ygrid.visible = False
386
595
 
387
- # We need to scale values or use a custom formatter
388
- # For now, assume values are already in bp and need /1e6
389
- from bokeh.models import FuncTickFormatter
596
+ def format_xaxis_mb(self, ax: figure) -> None:
597
+ """Format x-axis to show megabase values."""
598
+ from bokeh.models import CustomJSTickFormatter
390
599
 
391
- ax.xaxis.formatter = FuncTickFormatter(code="return (tick / 1e6).toFixed(2);")
600
+ ax.xaxis.formatter = CustomJSTickFormatter(
601
+ code="return (tick / 1e6).toFixed(2);"
602
+ )
392
603
 
393
604
  def save(
394
605
  self,
@@ -421,6 +632,57 @@ class BokehBackend:
421
632
  """Close the figure (no-op for bokeh)."""
422
633
  pass
423
634
 
635
+ def add_eqtl_legend(
636
+ self,
637
+ ax: figure,
638
+ eqtl_positive_bins: List[Tuple[float, float, str, str]],
639
+ eqtl_negative_bins: List[Tuple[float, float, str, str]],
640
+ ) -> None:
641
+ """Add eQTL effect size legend using invisible dummy glyphs."""
642
+ source = self._ensure_legend_range(ax)
643
+ items = []
644
+ for _, _, label, color in eqtl_positive_bins:
645
+ items.append(self._add_legend_item(ax, source, label, color, "triangle"))
646
+ for _, _, label, color in eqtl_negative_bins:
647
+ items.append(
648
+ self._add_legend_item(ax, source, label, color, "inverted_triangle")
649
+ )
650
+ self._create_legend(ax, items, "eQTL effect")
651
+
652
+ def add_finemapping_legend(
653
+ self,
654
+ ax: figure,
655
+ credible_sets: List[int],
656
+ get_color_func: Any,
657
+ ) -> None:
658
+ """Add fine-mapping credible set legend using invisible dummy glyphs."""
659
+ if not credible_sets:
660
+ return
661
+
662
+ source = self._ensure_legend_range(ax)
663
+ items = [
664
+ self._add_legend_item(
665
+ ax, source, f"CS{cs_id}", get_color_func(cs_id), "circle"
666
+ )
667
+ for cs_id in credible_sets
668
+ ]
669
+ self._create_legend(ax, items, "Credible sets")
670
+
671
+ def add_simple_legend(
672
+ self,
673
+ ax: figure,
674
+ label: str,
675
+ loc: str = "upper right",
676
+ ) -> None:
677
+ """Configure legend position.
678
+
679
+ Bokeh handles legends automatically from legend_label.
680
+ This just positions the legend.
681
+ """
682
+ ax.legend.location = self._get_legend_location(loc, "top_right")
683
+ ax.legend.background_fill_alpha = 0.9
684
+ ax.legend.border_line_color = "black"
685
+
424
686
  def finalize_layout(
425
687
  self,
426
688
  fig: Any,