pylocuszoom 0.3.0__py3-none-any.whl → 0.6.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,14 +34,22 @@ Species Support:
34
34
  - Custom: User provides all reference data
35
35
  """
36
36
 
37
- __version__ = "0.1.0"
37
+ __version__ = "0.6.0"
38
38
 
39
39
  # Main plotter class
40
40
  # Backend types
41
41
  from .backends import BackendType, get_backend
42
42
 
43
43
  # Colors and LD
44
- from .colors import LEAD_SNP_COLOR, get_ld_bin, get_ld_color, get_ld_color_palette
44
+ from .colors import (
45
+ LEAD_SNP_COLOR,
46
+ PHEWAS_CATEGORY_COLORS,
47
+ get_ld_bin,
48
+ get_ld_color,
49
+ get_ld_color_palette,
50
+ get_phewas_category_color,
51
+ get_phewas_category_palette,
52
+ )
45
53
 
46
54
  # eQTL support
47
55
  from .eqtl import (
@@ -65,6 +73,9 @@ from .finemapping import (
65
73
  validate_finemapping_df,
66
74
  )
67
75
 
76
+ # Forest plot support
77
+ from .forest import validate_forest_df
78
+
68
79
  # Gene track
69
80
  from .gene_track import get_nearest_gene, plot_gene_track
70
81
 
@@ -74,8 +85,36 @@ from .labels import add_snp_labels
74
85
  # LD calculation
75
86
  from .ld import calculate_ld
76
87
 
88
+ # File format loaders
89
+ from .loaders import (
90
+ load_bed,
91
+ load_bolt_lmm,
92
+ load_caviar,
93
+ load_ensembl_genes,
94
+ load_eqtl_catalogue,
95
+ load_finemap,
96
+ load_gemma,
97
+ # eQTL loaders
98
+ load_gtex_eqtl,
99
+ # Gene annotation loaders
100
+ load_gtf,
101
+ # GWAS loaders
102
+ load_gwas,
103
+ load_gwas_catalog,
104
+ load_matrixeqtl,
105
+ load_plink_assoc,
106
+ load_polyfun,
107
+ load_regenie,
108
+ load_saige,
109
+ # Fine-mapping loaders
110
+ load_susie,
111
+ )
112
+
77
113
  # Logging configuration
78
114
  from .logging import disable_logging, enable_logging
115
+
116
+ # PheWAS support
117
+ from .phewas import validate_phewas_df
79
118
  from .plotter import LocusZoomPlotter
80
119
 
81
120
  # Reference data management
@@ -86,6 +125,9 @@ from .recombination import (
86
125
  load_recombination_map,
87
126
  )
88
127
 
128
+ # Schema validation
129
+ from .schemas import LoaderValidationError
130
+
89
131
  # Validation utilities
90
132
  from .utils import ValidationError, to_pandas
91
133
 
@@ -102,7 +144,10 @@ __all__ = [
102
144
  "get_ld_color",
103
145
  "get_ld_bin",
104
146
  "get_ld_color_palette",
147
+ "get_phewas_category_color",
148
+ "get_phewas_category_palette",
105
149
  "LEAD_SNP_COLOR",
150
+ "PHEWAS_CATEGORY_COLORS",
106
151
  # Gene track
107
152
  "get_nearest_gene",
108
153
  "plot_gene_track",
@@ -136,4 +181,31 @@ __all__ = [
136
181
  # Validation & Utils
137
182
  "ValidationError",
138
183
  "to_pandas",
184
+ # PheWAS
185
+ "validate_phewas_df",
186
+ # Forest plot
187
+ "validate_forest_df",
188
+ # GWAS loaders
189
+ "load_gwas",
190
+ "load_plink_assoc",
191
+ "load_regenie",
192
+ "load_bolt_lmm",
193
+ "load_gemma",
194
+ "load_saige",
195
+ "load_gwas_catalog",
196
+ # eQTL loaders
197
+ "load_gtex_eqtl",
198
+ "load_eqtl_catalogue",
199
+ "load_matrixeqtl",
200
+ # Fine-mapping loaders
201
+ "load_susie",
202
+ "load_finemap",
203
+ "load_caviar",
204
+ "load_polyfun",
205
+ # Gene annotation loaders
206
+ "load_gtf",
207
+ "load_bed",
208
+ "load_ensembl_genes",
209
+ # Schema validation
210
+ "LoaderValidationError",
139
211
  ]
@@ -341,3 +341,134 @@ class PlotBackend(Protocol):
341
341
  fig: Figure object.
342
342
  """
343
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
+ ...
389
+
390
+ def axvline(
391
+ self,
392
+ ax: Any,
393
+ x: float,
394
+ color: str = "grey",
395
+ linestyle: str = "--",
396
+ linewidth: float = 1.0,
397
+ alpha: float = 1.0,
398
+ zorder: int = 1,
399
+ ) -> Any:
400
+ """Add a vertical line across the axes.
401
+
402
+ Args:
403
+ ax: Axes or panel.
404
+ x: X-value for the line.
405
+ color: Line color.
406
+ linestyle: Line style.
407
+ linewidth: Line width.
408
+ alpha: Line transparency (0-1).
409
+ zorder: Drawing order.
410
+
411
+ Returns:
412
+ The line object.
413
+ """
414
+ ...
415
+
416
+ def hbar(
417
+ self,
418
+ ax: Any,
419
+ y: pd.Series,
420
+ width: pd.Series,
421
+ height: float = 0.8,
422
+ left: Union[float, pd.Series] = 0,
423
+ color: Union[str, List[str]] = "blue",
424
+ edgecolor: str = "black",
425
+ linewidth: float = 0.5,
426
+ zorder: int = 2,
427
+ ) -> Any:
428
+ """Create horizontal bar chart.
429
+
430
+ Args:
431
+ ax: Axes or panel.
432
+ y: Y positions for bars.
433
+ width: Bar widths (x-extent).
434
+ height: Bar height.
435
+ left: Left edge positions.
436
+ color: Bar colors.
437
+ edgecolor: Edge color.
438
+ linewidth: Edge width.
439
+ zorder: Drawing order.
440
+
441
+ Returns:
442
+ The bar collection object.
443
+ """
444
+ ...
445
+
446
+ def errorbar_h(
447
+ self,
448
+ ax: Any,
449
+ x: pd.Series,
450
+ y: pd.Series,
451
+ xerr_lower: pd.Series,
452
+ xerr_upper: pd.Series,
453
+ color: str = "black",
454
+ linewidth: float = 1.5,
455
+ capsize: float = 3,
456
+ zorder: int = 3,
457
+ ) -> Any:
458
+ """Add horizontal error bars (for forest plots).
459
+
460
+ Args:
461
+ ax: Axes or panel.
462
+ x: X positions (effect sizes).
463
+ y: Y positions.
464
+ xerr_lower: Lower error (distance from x).
465
+ xerr_upper: Upper error (distance from x).
466
+ color: Line color.
467
+ linewidth: Line width.
468
+ capsize: Cap size in points.
469
+ zorder: Drawing order.
470
+
471
+ Returns:
472
+ The errorbar object.
473
+ """
474
+ ...
@@ -19,15 +19,24 @@ class BokehBackend:
19
19
  applications and dashboards.
20
20
  """
21
21
 
22
+ # Class constants for style mappings
23
+ _MARKER_MAP = {
24
+ "o": "circle",
25
+ "D": "diamond",
26
+ "s": "square",
27
+ "^": "triangle",
28
+ "v": "inverted_triangle",
29
+ }
30
+ _DASH_MAP = {
31
+ "-": "solid",
32
+ "--": "dashed",
33
+ ":": "dotted",
34
+ "-.": "dashdot",
35
+ }
36
+
22
37
  def __init__(self) -> None:
23
38
  """Initialize the bokeh backend."""
24
- self._marker_map = {
25
- "o": "circle",
26
- "D": "diamond",
27
- "s": "square",
28
- "^": "triangle",
29
- "v": "inverted_triangle",
30
- }
39
+ pass
31
40
 
32
41
  def create_figure(
33
42
  self,
@@ -67,9 +76,13 @@ class BokehBackend:
67
76
  toolbar_location="above" if i == 0 else None,
68
77
  )
69
78
 
70
- # Style
71
- p.grid.grid_line_alpha = 0.3
79
+ # Style - no grid lines, black axes for clean LocusZoom appearance
80
+ p.grid.visible = False
72
81
  p.outline_line_color = None
82
+ p.xaxis.axis_line_color = "black"
83
+ p.yaxis.axis_line_color = "black"
84
+ p.xaxis.minor_tick_line_color = None
85
+ p.yaxis.minor_tick_line_color = None
73
86
 
74
87
  figures.append(p)
75
88
 
@@ -115,9 +128,9 @@ class BokehBackend:
115
128
  for col in hover_data.columns:
116
129
  data[col] = hover_data[col].values
117
130
  col_lower = col.lower()
118
- if col_lower == "p-value" or col_lower == "pval" or col_lower == "p_value":
131
+ if col_lower in ("p-value", "pval", "p_value"):
119
132
  tooltips.append((col, "@{" + col + "}{0.2e}"))
120
- elif "r2" in col_lower or "r²" in col_lower or "ld" in col_lower:
133
+ elif any(x in col_lower for x in ("r2", "r²", "ld")):
121
134
  tooltips.append((col, "@{" + col + "}{0.3f}"))
122
135
  elif "pos" in col_lower:
123
136
  tooltips.append((col, "@{" + col + "}{0,0}"))
@@ -127,7 +140,7 @@ class BokehBackend:
127
140
  source = ColumnDataSource(data)
128
141
 
129
142
  # Get marker type for scatter()
130
- marker_type = self._marker_map.get(marker, "circle")
143
+ marker_type = self._MARKER_MAP.get(marker, "circle")
131
144
 
132
145
  # Create scatter using scatter() method (Bokeh 3.4+ preferred API)
133
146
  scatter_kwargs = {
@@ -167,14 +180,7 @@ class BokehBackend:
167
180
  label: Optional[str] = None,
168
181
  ) -> Any:
169
182
  """Create a line plot on the given figure."""
170
- # Convert linestyle
171
- dash_map = {
172
- "-": "solid",
173
- "--": "dashed",
174
- ":": "dotted",
175
- "-.": "dashdot",
176
- }
177
- line_dash = dash_map.get(linestyle, "solid")
183
+ line_dash = self._DASH_MAP.get(linestyle, "solid")
178
184
 
179
185
  line_kwargs = {
180
186
  "line_color": color,
@@ -229,8 +235,7 @@ class BokehBackend:
229
235
  zorder: int = 1,
230
236
  ) -> Any:
231
237
  """Add a horizontal line across the figure."""
232
- dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
233
- line_dash = dash_map.get(linestyle, "dashed")
238
+ line_dash = self._DASH_MAP.get(linestyle, "dashed")
234
239
 
235
240
  span = Span(
236
241
  location=y,
@@ -351,6 +356,16 @@ class BokehBackend:
351
356
  ax.yaxis.axis_label = label
352
357
  ax.yaxis.axis_label_text_font_size = f"{fontsize}pt"
353
358
 
359
+ def _get_legend_location(self, loc: str, default: str = "top_left") -> str:
360
+ """Map matplotlib-style legend location to Bokeh location."""
361
+ loc_map = {
362
+ "upper left": "top_left",
363
+ "upper right": "top_right",
364
+ "lower left": "bottom_left",
365
+ "lower right": "bottom_right",
366
+ }
367
+ return loc_map.get(loc, default)
368
+
354
369
  def _convert_label(self, label: str) -> str:
355
370
  """Convert LaTeX-style labels to Unicode for Bokeh display."""
356
371
  conversions = [
@@ -397,8 +412,7 @@ class BokehBackend:
397
412
  yaxis_name: str = "secondary",
398
413
  ) -> Any:
399
414
  """Create a line plot on secondary y-axis."""
400
- dash_map = {"-": "solid", "--": "dashed", ":": "dotted", "-.": "dashdot"}
401
- line_dash = dash_map.get(linestyle, "solid")
415
+ line_dash = self._DASH_MAP.get(linestyle, "solid")
402
416
 
403
417
  return ax.line(
404
418
  x.values,
@@ -465,60 +479,90 @@ class BokehBackend:
465
479
  label = self._convert_label(label)
466
480
  # Find the secondary axis and update its label
467
481
  for renderer in ax.right:
468
- if hasattr(renderer, "y_range_name") and renderer.y_range_name == yaxis_name:
482
+ if (
483
+ hasattr(renderer, "y_range_name")
484
+ and renderer.y_range_name == yaxis_name
485
+ ):
469
486
  renderer.axis_label = label
470
487
  renderer.axis_label_text_font_size = f"{fontsize}pt"
471
488
  renderer.axis_label_text_color = color
472
489
  renderer.major_label_text_color = color
473
490
  break
474
491
 
475
- def add_ld_legend(
476
- self,
477
- ax: figure,
478
- ld_bins: List[Tuple[float, str, str]],
479
- lead_snp_color: str,
480
- ) -> None:
481
- """Add LD color legend using invisible dummy glyphs.
492
+ def _ensure_legend_range(self, ax: figure) -> Any:
493
+ """Ensure legend range exists and return a dummy data source.
482
494
 
483
- Creates legend entries with dummy renderers that are excluded from
484
- the data range calculation to avoid affecting axis scaling.
495
+ Creates a separate y-range for legend glyphs so they don't affect
496
+ the main plot's axis scaling.
485
497
  """
486
- from bokeh.models import ColumnDataSource, Legend, LegendItem, Range1d, Scatter
487
-
488
- legend_items = []
498
+ from bokeh.models import ColumnDataSource, Range1d
489
499
 
490
- # Create a separate range for legend glyphs that won't affect the main plot
491
500
  if "legend_range" not in ax.extra_y_ranges:
492
501
  ax.extra_y_ranges["legend_range"] = Range1d(start=0, end=1)
502
+ return ColumnDataSource(data={"x": [0], "y": [0]})
493
503
 
494
- # Use coordinates within the legend range
495
- dummy_source = ColumnDataSource(data={"x": [0], "y": [0]})
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])
496
529
 
497
- # Add LD bin markers (no lead SNP - it's shown in the actual plot)
498
- for _, label, color in ld_bins:
499
- glyph = Scatter(
500
- x="x",
501
- y="y",
502
- marker="square",
503
- size=10,
504
- fill_color=color,
505
- line_color="black",
506
- line_width=0.5,
507
- )
508
- renderer = ax.add_glyph(dummy_source, glyph)
509
- renderer.y_range_name = "legend_range"
510
- renderer.visible = False
511
- legend_items.append(LegendItem(label=label, renderers=[renderer]))
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
512
533
 
513
534
  legend = Legend(
514
- items=legend_items,
535
+ items=items,
515
536
  location="top_right",
516
- title="r²",
537
+ title=title,
517
538
  background_fill_alpha=0.9,
518
539
  border_line_color="black",
540
+ spacing=0,
541
+ padding=4,
542
+ label_height=12,
543
+ glyph_height=12,
519
544
  )
520
545
  ax.add_layout(legend)
521
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
+
522
566
  def add_legend(
523
567
  self,
524
568
  ax: figure,
@@ -528,17 +572,7 @@ class BokehBackend:
528
572
  title: Optional[str] = None,
529
573
  ) -> Any:
530
574
  """Configure legend on the figure."""
531
- # Bokeh handles legend automatically from legend_label
532
- # Just configure position
533
-
534
- loc_map = {
535
- "upper left": "top_left",
536
- "upper right": "top_right",
537
- "lower left": "bottom_left",
538
- "lower right": "bottom_right",
539
- }
540
-
541
- ax.legend.location = loc_map.get(loc, "top_left")
575
+ ax.legend.location = self._get_legend_location(loc, "top_left")
542
576
  if title:
543
577
  ax.legend.title = title
544
578
  ax.legend.background_fill_alpha = 0.9
@@ -554,6 +588,11 @@ class BokehBackend:
554
588
  """
555
589
  pass
556
590
 
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
595
+
557
596
  def format_xaxis_mb(self, ax: figure) -> None:
558
597
  """Format x-axis to show megabase values."""
559
598
  from bokeh.models import CustomJSTickFormatter
@@ -593,6 +632,153 @@ class BokehBackend:
593
632
  """Close the figure (no-op for bokeh)."""
594
633
  pass
595
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
+
686
+ def axvline(
687
+ self,
688
+ ax: figure,
689
+ x: float,
690
+ color: str = "grey",
691
+ linestyle: str = "--",
692
+ linewidth: float = 1.0,
693
+ alpha: float = 1.0,
694
+ zorder: int = 1,
695
+ ) -> Any:
696
+ """Add a vertical line across the figure."""
697
+ line_dash = self._DASH_MAP.get(linestyle, "dashed")
698
+
699
+ span = Span(
700
+ location=x,
701
+ dimension="height",
702
+ line_color=color,
703
+ line_dash=line_dash,
704
+ line_width=linewidth,
705
+ line_alpha=alpha,
706
+ )
707
+ ax.add_layout(span)
708
+ return span
709
+
710
+ def hbar(
711
+ self,
712
+ ax: figure,
713
+ y: pd.Series,
714
+ width: pd.Series,
715
+ height: float = 0.8,
716
+ left: Union[float, pd.Series] = 0,
717
+ color: Union[str, List[str]] = "blue",
718
+ edgecolor: str = "black",
719
+ linewidth: float = 0.5,
720
+ zorder: int = 2,
721
+ ) -> Any:
722
+ """Create horizontal bar chart."""
723
+ # Convert left to array if scalar
724
+ if isinstance(left, (int, float)):
725
+ left_arr = [left] * len(y)
726
+ else:
727
+ left_arr = list(left) if hasattr(left, "tolist") else left
728
+
729
+ # Calculate right edge
730
+ right_arr = [left_val + w for left_val, w in zip(left_arr, width)]
731
+
732
+ return ax.hbar(
733
+ y=y.values,
734
+ right=right_arr,
735
+ left=left_arr,
736
+ height=height,
737
+ fill_color=color,
738
+ line_color=edgecolor,
739
+ line_width=linewidth,
740
+ )
741
+
742
+ def errorbar_h(
743
+ self,
744
+ ax: figure,
745
+ x: pd.Series,
746
+ y: pd.Series,
747
+ xerr_lower: pd.Series,
748
+ xerr_upper: pd.Series,
749
+ color: str = "black",
750
+ linewidth: float = 1.5,
751
+ capsize: float = 3,
752
+ zorder: int = 3,
753
+ ) -> Any:
754
+ """Add horizontal error bars."""
755
+ from bokeh.models import Whisker
756
+
757
+ # Calculate bounds
758
+ lower = x - xerr_lower
759
+ upper = x + xerr_upper
760
+
761
+ source = ColumnDataSource(
762
+ data={
763
+ "y": y.values,
764
+ "lower": lower.values,
765
+ "upper": upper.values,
766
+ }
767
+ )
768
+
769
+ # Add horizontal whisker
770
+ whisker = Whisker(
771
+ source=source,
772
+ base="y",
773
+ lower="lower",
774
+ upper="upper",
775
+ dimension="width",
776
+ line_color=color,
777
+ line_width=linewidth,
778
+ )
779
+ ax.add_layout(whisker)
780
+ return whisker
781
+
596
782
  def finalize_layout(
597
783
  self,
598
784
  fig: Any,