chatspatial 1.1.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.
Files changed (67) hide show
  1. chatspatial/__init__.py +11 -0
  2. chatspatial/__main__.py +141 -0
  3. chatspatial/cli/__init__.py +7 -0
  4. chatspatial/config.py +53 -0
  5. chatspatial/models/__init__.py +85 -0
  6. chatspatial/models/analysis.py +513 -0
  7. chatspatial/models/data.py +2462 -0
  8. chatspatial/server.py +1763 -0
  9. chatspatial/spatial_mcp_adapter.py +720 -0
  10. chatspatial/tools/__init__.py +3 -0
  11. chatspatial/tools/annotation.py +1903 -0
  12. chatspatial/tools/cell_communication.py +1603 -0
  13. chatspatial/tools/cnv_analysis.py +605 -0
  14. chatspatial/tools/condition_comparison.py +595 -0
  15. chatspatial/tools/deconvolution/__init__.py +402 -0
  16. chatspatial/tools/deconvolution/base.py +318 -0
  17. chatspatial/tools/deconvolution/card.py +244 -0
  18. chatspatial/tools/deconvolution/cell2location.py +326 -0
  19. chatspatial/tools/deconvolution/destvi.py +144 -0
  20. chatspatial/tools/deconvolution/flashdeconv.py +101 -0
  21. chatspatial/tools/deconvolution/rctd.py +317 -0
  22. chatspatial/tools/deconvolution/spotlight.py +216 -0
  23. chatspatial/tools/deconvolution/stereoscope.py +109 -0
  24. chatspatial/tools/deconvolution/tangram.py +135 -0
  25. chatspatial/tools/differential.py +625 -0
  26. chatspatial/tools/embeddings.py +298 -0
  27. chatspatial/tools/enrichment.py +1863 -0
  28. chatspatial/tools/integration.py +807 -0
  29. chatspatial/tools/preprocessing.py +723 -0
  30. chatspatial/tools/spatial_domains.py +808 -0
  31. chatspatial/tools/spatial_genes.py +836 -0
  32. chatspatial/tools/spatial_registration.py +441 -0
  33. chatspatial/tools/spatial_statistics.py +1476 -0
  34. chatspatial/tools/trajectory.py +495 -0
  35. chatspatial/tools/velocity.py +405 -0
  36. chatspatial/tools/visualization/__init__.py +155 -0
  37. chatspatial/tools/visualization/basic.py +393 -0
  38. chatspatial/tools/visualization/cell_comm.py +699 -0
  39. chatspatial/tools/visualization/cnv.py +320 -0
  40. chatspatial/tools/visualization/core.py +684 -0
  41. chatspatial/tools/visualization/deconvolution.py +852 -0
  42. chatspatial/tools/visualization/enrichment.py +660 -0
  43. chatspatial/tools/visualization/integration.py +205 -0
  44. chatspatial/tools/visualization/main.py +164 -0
  45. chatspatial/tools/visualization/multi_gene.py +739 -0
  46. chatspatial/tools/visualization/persistence.py +335 -0
  47. chatspatial/tools/visualization/spatial_stats.py +469 -0
  48. chatspatial/tools/visualization/trajectory.py +639 -0
  49. chatspatial/tools/visualization/velocity.py +411 -0
  50. chatspatial/utils/__init__.py +115 -0
  51. chatspatial/utils/adata_utils.py +1372 -0
  52. chatspatial/utils/compute.py +327 -0
  53. chatspatial/utils/data_loader.py +499 -0
  54. chatspatial/utils/dependency_manager.py +462 -0
  55. chatspatial/utils/device_utils.py +165 -0
  56. chatspatial/utils/exceptions.py +185 -0
  57. chatspatial/utils/image_utils.py +267 -0
  58. chatspatial/utils/mcp_utils.py +137 -0
  59. chatspatial/utils/path_utils.py +243 -0
  60. chatspatial/utils/persistence.py +78 -0
  61. chatspatial/utils/scipy_compat.py +143 -0
  62. chatspatial-1.1.0.dist-info/METADATA +242 -0
  63. chatspatial-1.1.0.dist-info/RECORD +67 -0
  64. chatspatial-1.1.0.dist-info/WHEEL +5 -0
  65. chatspatial-1.1.0.dist-info/entry_points.txt +2 -0
  66. chatspatial-1.1.0.dist-info/licenses/LICENSE +21 -0
  67. chatspatial-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,739 @@
1
+ """
2
+ Multi-gene and ligand-receptor visualization functions.
3
+
4
+ This module contains:
5
+ - Multi-gene spatial visualization
6
+ - Multi-gene UMAP visualization
7
+ - Ligand-receptor pairs visualization
8
+ - Gene correlation visualization
9
+ - Spatial interaction visualization
10
+ """
11
+
12
+ from typing import TYPE_CHECKING, Optional
13
+
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ import scanpy as sc
17
+ from mpl_toolkits.axes_grid1 import make_axes_locatable
18
+
19
+ from ...models.data import VisualizationParameters
20
+ from ...utils.adata_utils import (
21
+ ensure_unique_var_names,
22
+ get_gene_expression,
23
+ get_genes_expression,
24
+ require_spatial_coords,
25
+ )
26
+ from ...utils.exceptions import DataNotFoundError, ParameterError, ProcessingError
27
+ from .core import (
28
+ create_figure,
29
+ get_validated_features,
30
+ plot_spatial_feature,
31
+ setup_multi_panel_figure,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ import anndata as ad
36
+
37
+ from ...spatial_mcp_adapter import ToolContext
38
+
39
+
40
+ # =============================================================================
41
+ # Multi-Gene Visualization
42
+ # =============================================================================
43
+
44
+
45
+ async def create_multi_gene_visualization(
46
+ adata: "ad.AnnData",
47
+ params: VisualizationParameters,
48
+ context: Optional["ToolContext"] = None,
49
+ ) -> plt.Figure:
50
+ """Create multi-gene spatial visualization.
51
+
52
+ Args:
53
+ adata: AnnData object
54
+ params: Visualization parameters
55
+ context: MCP context for logging
56
+
57
+ Returns:
58
+ matplotlib Figure with multi-gene spatial visualization
59
+ """
60
+ # Get validated features
61
+ available_genes = await get_validated_features(
62
+ adata, params, max_features=12, context=context
63
+ )
64
+
65
+ if context:
66
+ await context.info(
67
+ f"Visualizing {len(available_genes)} genes: {available_genes}"
68
+ )
69
+
70
+ # Setup multi-panel figure
71
+ fig, axes = setup_multi_panel_figure(
72
+ n_panels=len(available_genes),
73
+ params=params,
74
+ default_title="",
75
+ use_tight_layout=False,
76
+ )
77
+
78
+ # Use unique temporary column name to avoid conflicts
79
+ temp_feature_key = "multi_gene_expr_temp_viz_99_unique"
80
+
81
+ for i, gene in enumerate(available_genes):
82
+ if i < len(axes):
83
+ ax = axes[i]
84
+ try:
85
+ # Get gene expression using unified utility
86
+ gene_expr = get_gene_expression(adata, gene)
87
+
88
+ # Apply color scaling
89
+ if params.color_scale == "log":
90
+ gene_expr = np.log1p(gene_expr)
91
+ elif params.color_scale == "sqrt":
92
+ gene_expr = np.sqrt(gene_expr)
93
+
94
+ # Add temporary column
95
+ adata.obs[temp_feature_key] = gene_expr
96
+
97
+ # Create spatial plot
98
+ if "spatial" in adata.obsm:
99
+ plot_spatial_feature(
100
+ adata,
101
+ ax=ax,
102
+ feature=temp_feature_key,
103
+ params=params,
104
+ show_colorbar=False,
105
+ )
106
+
107
+ # Set color limits
108
+ vmin = (
109
+ params.vmin
110
+ if params.vmin is not None
111
+ else np.percentile(gene_expr, 1)
112
+ )
113
+ vmax = (
114
+ params.vmax
115
+ if params.vmax is not None
116
+ else np.percentile(gene_expr, 99)
117
+ )
118
+
119
+ # Update colorbar limits
120
+ scatter = ax.collections[0] if ax.collections else None
121
+ if scatter:
122
+ scatter.set_clim(vmin, vmax)
123
+
124
+ # Add colorbar
125
+ if params.show_colorbar:
126
+ divider = make_axes_locatable(ax)
127
+ cax = divider.append_axes(
128
+ "right",
129
+ size=params.colorbar_size,
130
+ pad=params.colorbar_pad,
131
+ )
132
+ plt.colorbar(scatter, cax=cax)
133
+
134
+ ax.invert_yaxis()
135
+ if params.add_gene_labels:
136
+ ax.set_title(gene, fontsize=12)
137
+ else:
138
+ # Fallback: histogram
139
+ ax.hist(gene_expr, bins=30, alpha=params.alpha, color="steelblue")
140
+ ax.set_xlabel("Expression")
141
+ ax.set_ylabel("Frequency")
142
+ if params.add_gene_labels:
143
+ ax.set_title(gene, fontsize=12)
144
+
145
+ except Exception as e:
146
+ ax.text(
147
+ 0.5,
148
+ 0.5,
149
+ f"Error plotting {gene}:\n{e}",
150
+ ha="center",
151
+ va="center",
152
+ transform=ax.transAxes,
153
+ )
154
+ if params.add_gene_labels:
155
+ ax.set_title(f"{gene} (Error)", fontsize=12)
156
+
157
+ # Clean up temporary column
158
+ if temp_feature_key in adata.obs:
159
+ del adata.obs[temp_feature_key]
160
+
161
+ # Adjust spacing
162
+ fig.subplots_adjust(top=0.92, wspace=0.1, hspace=0.3, right=0.98)
163
+ return fig
164
+
165
+
166
+ async def create_multi_gene_umap_visualization(
167
+ adata: "ad.AnnData",
168
+ params: VisualizationParameters,
169
+ context: Optional["ToolContext"] = None,
170
+ ) -> plt.Figure:
171
+ """Create multi-gene UMAP visualization.
172
+
173
+ Args:
174
+ adata: AnnData object
175
+ params: Visualization parameters
176
+ context: MCP context for logging
177
+
178
+ Returns:
179
+ matplotlib Figure with multi-gene UMAP visualization
180
+ """
181
+ # Get validated features
182
+ available_genes = await get_validated_features(
183
+ adata, params, max_features=12, context=context
184
+ )
185
+
186
+ if context:
187
+ await context.info(
188
+ f"Creating multi-panel UMAP plot for features: {available_genes}"
189
+ )
190
+
191
+ # Setup multi-panel figure
192
+ fig, axes = setup_multi_panel_figure(
193
+ n_panels=len(available_genes),
194
+ params=params,
195
+ default_title="",
196
+ )
197
+
198
+ # Use unique temporary column name
199
+ temp_feature_key = "umap_gene_temp_viz_99_unique"
200
+
201
+ for i, gene in enumerate(available_genes):
202
+ if i < len(axes):
203
+ ax = axes[i]
204
+ try:
205
+ # Get gene expression using unified utility
206
+ gene_expr = get_gene_expression(adata, gene)
207
+
208
+ # Apply color scaling
209
+ if params.color_scale == "log":
210
+ gene_expr = np.log1p(gene_expr)
211
+ elif params.color_scale == "sqrt":
212
+ gene_expr = np.sqrt(gene_expr)
213
+
214
+ # Add temporary column
215
+ adata.obs[temp_feature_key] = gene_expr
216
+
217
+ # Set color scale
218
+ vmin = 0
219
+ vmax = max(gene_expr.max(), 0.1)
220
+
221
+ # Use percentile-based scaling for sparse data
222
+ if np.sum(gene_expr > 0) > 10:
223
+ vmax = np.percentile(gene_expr[gene_expr > 0], 95)
224
+
225
+ sc.pl.umap(
226
+ adata,
227
+ color=temp_feature_key,
228
+ cmap=params.colormap,
229
+ ax=ax,
230
+ show=False,
231
+ frameon=False,
232
+ vmin=vmin,
233
+ vmax=vmax,
234
+ colorbar_loc="right",
235
+ )
236
+ ax.set_title(gene)
237
+
238
+ except Exception as e:
239
+ ax.text(
240
+ 0.5,
241
+ 0.5,
242
+ f"Error plotting {gene}:\n{e}",
243
+ ha="center",
244
+ va="center",
245
+ transform=ax.transAxes,
246
+ )
247
+ ax.set_title(f"{gene} (Error)")
248
+
249
+ # Clean up temporary column
250
+ if temp_feature_key in adata.obs:
251
+ del adata.obs[temp_feature_key]
252
+
253
+ return fig
254
+
255
+
256
+ # =============================================================================
257
+ # Ligand-Receptor Pairs Visualization
258
+ # =============================================================================
259
+
260
+
261
+ def _parse_lr_pairs(
262
+ adata: "ad.AnnData",
263
+ params: VisualizationParameters,
264
+ ) -> list[tuple[str, str]]:
265
+ """Parse ligand-receptor pairs from various sources.
266
+
267
+ Args:
268
+ adata: AnnData object
269
+ params: Visualization parameters
270
+
271
+ Returns:
272
+ List of (ligand, receptor) tuples
273
+
274
+ Raises:
275
+ DataNotFoundError: If no LR pairs found
276
+ """
277
+ lr_pairs = []
278
+
279
+ # 1. Check for explicit lr_pairs parameter
280
+ if params.lr_pairs:
281
+ lr_pairs = params.lr_pairs
282
+ else:
283
+ # 2. Try to parse from feature parameter
284
+ feature_list = (
285
+ params.feature
286
+ if isinstance(params.feature, list)
287
+ else ([params.feature] if params.feature else [])
288
+ )
289
+
290
+ if feature_list:
291
+ has_special_format = any(
292
+ "^" in str(f) or ("_" in str(f) and not str(f).startswith("_"))
293
+ for f in feature_list
294
+ )
295
+
296
+ if has_special_format:
297
+ for item in feature_list:
298
+ # Handle "Ligand^Receptor" format from LIANA
299
+ if "^" in str(item):
300
+ ligand, receptor = str(item).split("^", 1)
301
+ lr_pairs.append((ligand, receptor))
302
+ # Handle "Ligand_Receptor" format
303
+ elif "_" in str(item) and not str(item).startswith("_"):
304
+ parts = str(item).split("_")
305
+ if len(parts) == 2:
306
+ lr_pairs.append((parts[0], parts[1]))
307
+
308
+ # 3. Try to get from stored analysis results
309
+ if not lr_pairs and hasattr(adata, "uns"):
310
+ if "detected_lr_pairs" in adata.uns:
311
+ lr_pairs = adata.uns["detected_lr_pairs"]
312
+ elif "cell_communication_results" in adata.uns:
313
+ comm_results = adata.uns["cell_communication_results"]
314
+ if "top_lr_pairs" in comm_results:
315
+ for pair_str in comm_results["top_lr_pairs"]:
316
+ if "^" in pair_str:
317
+ ligand, receptor = pair_str.split("^", 1)
318
+ lr_pairs.append((ligand, receptor))
319
+ elif "_" in pair_str:
320
+ parts = pair_str.split("_")
321
+ if len(parts) == 2:
322
+ lr_pairs.append((parts[0], parts[1]))
323
+
324
+ # No hardcoded defaults - scientific integrity
325
+ if not lr_pairs:
326
+ raise DataNotFoundError(
327
+ "No ligand-receptor pairs to visualize.\n\n"
328
+ "Options:\n"
329
+ "1. Run cell communication analysis first\n"
330
+ "2. Specify lr_pairs parameter: lr_pairs=[('Ligand', 'Receptor')]\n"
331
+ "3. Use LIANA format: feature=['Ligand^Receptor']\n"
332
+ "4. Use underscore format: feature=['Ligand_Receptor']"
333
+ )
334
+
335
+ return lr_pairs
336
+
337
+
338
+ async def create_lr_pairs_visualization(
339
+ adata: "ad.AnnData",
340
+ params: VisualizationParameters,
341
+ context: Optional["ToolContext"] = None,
342
+ ) -> plt.Figure:
343
+ """Create ligand-receptor pairs visualization.
344
+
345
+ Args:
346
+ adata: AnnData object
347
+ params: Visualization parameters
348
+ context: MCP context for logging
349
+
350
+ Returns:
351
+ matplotlib Figure with LR pairs visualization
352
+ """
353
+ from scipy.stats import kendalltau, pearsonr, spearmanr
354
+
355
+ # Ensure unique gene names
356
+ ensure_unique_var_names(adata)
357
+
358
+ # Parse LR pairs
359
+ lr_pairs = _parse_lr_pairs(adata, params)
360
+
361
+ # Filter pairs where both genes exist
362
+ available_pairs = []
363
+ for ligand, receptor in lr_pairs:
364
+ if ligand in adata.var_names and receptor in adata.var_names:
365
+ available_pairs.append((ligand, receptor))
366
+
367
+ if not available_pairs:
368
+ raise DataNotFoundError(
369
+ f"None of the specified LR pairs found in data: {lr_pairs}"
370
+ )
371
+
372
+ # Limit to avoid overly large plots
373
+ max_pairs = 4
374
+ if len(available_pairs) > max_pairs:
375
+ if context:
376
+ await context.warning(
377
+ f"Too many LR pairs ({len(available_pairs)}). Limiting to first {max_pairs}."
378
+ )
379
+ available_pairs = available_pairs[:max_pairs]
380
+
381
+ if context:
382
+ await context.info(
383
+ f"Visualizing {len(available_pairs)} LR pairs: {available_pairs}"
384
+ )
385
+
386
+ # Each pair gets 3 panels: ligand, receptor, correlation
387
+ n_panels = len(available_pairs) * 3
388
+
389
+ fig, axes = setup_multi_panel_figure(
390
+ n_panels=n_panels,
391
+ params=params,
392
+ default_title=f"Ligand-Receptor Pairs ({len(available_pairs)} pairs)",
393
+ use_tight_layout=True,
394
+ )
395
+
396
+ # Use unique temporary column name
397
+ temp_feature_key = "lr_expr_temp_viz_99_unique"
398
+ ax_idx = 0
399
+
400
+ for _pair_idx, (ligand, receptor) in enumerate(available_pairs):
401
+ try:
402
+ # Get expression data using unified utility
403
+ ligand_expr = get_gene_expression(adata, ligand)
404
+ receptor_expr = get_gene_expression(adata, receptor)
405
+
406
+ # Apply color scaling
407
+ if params.color_scale == "log":
408
+ ligand_expr = np.log1p(ligand_expr)
409
+ receptor_expr = np.log1p(receptor_expr)
410
+ elif params.color_scale == "sqrt":
411
+ ligand_expr = np.sqrt(ligand_expr)
412
+ receptor_expr = np.sqrt(receptor_expr)
413
+
414
+ # Plot ligand
415
+ if ax_idx < len(axes) and "spatial" in adata.obsm:
416
+ ax = axes[ax_idx]
417
+ adata.obs[temp_feature_key] = ligand_expr
418
+ plot_spatial_feature(
419
+ adata,
420
+ ax=ax,
421
+ feature=temp_feature_key,
422
+ params=params,
423
+ show_colorbar=False,
424
+ )
425
+
426
+ if params.show_colorbar and ax.collections:
427
+ divider = make_axes_locatable(ax)
428
+ cax = divider.append_axes(
429
+ "right",
430
+ size=params.colorbar_size,
431
+ pad=params.colorbar_pad,
432
+ )
433
+ plt.colorbar(ax.collections[-1], cax=cax)
434
+
435
+ ax.invert_yaxis()
436
+ if params.add_gene_labels:
437
+ ax.set_title(f"{ligand} (Ligand)", fontsize=10)
438
+ ax_idx += 1
439
+
440
+ # Plot receptor
441
+ if ax_idx < len(axes) and "spatial" in adata.obsm:
442
+ ax = axes[ax_idx]
443
+ adata.obs[temp_feature_key] = receptor_expr
444
+ plot_spatial_feature(
445
+ adata,
446
+ ax=ax,
447
+ feature=temp_feature_key,
448
+ params=params,
449
+ show_colorbar=False,
450
+ )
451
+
452
+ if params.show_colorbar and ax.collections:
453
+ divider = make_axes_locatable(ax)
454
+ cax = divider.append_axes(
455
+ "right",
456
+ size=params.colorbar_size,
457
+ pad=params.colorbar_pad,
458
+ )
459
+ plt.colorbar(ax.collections[-1], cax=cax)
460
+
461
+ ax.invert_yaxis()
462
+ if params.add_gene_labels:
463
+ ax.set_title(f"{receptor} (Receptor)", fontsize=10)
464
+ ax_idx += 1
465
+
466
+ # Plot correlation
467
+ if ax_idx < len(axes):
468
+ ax = axes[ax_idx]
469
+
470
+ # Calculate correlation
471
+ if params.correlation_method == "pearson":
472
+ corr, p_value = pearsonr(ligand_expr, receptor_expr)
473
+ elif params.correlation_method == "spearman":
474
+ corr, p_value = spearmanr(ligand_expr, receptor_expr)
475
+ else: # kendall
476
+ corr, p_value = kendalltau(ligand_expr, receptor_expr)
477
+
478
+ # Create scatter plot
479
+ ax.scatter(ligand_expr, receptor_expr, alpha=params.alpha, s=20)
480
+ ax.set_xlabel(f"{ligand} Expression")
481
+ ax.set_ylabel(f"{receptor} Expression")
482
+
483
+ if params.show_correlation_stats:
484
+ ax.set_title(
485
+ f"Correlation: {corr:.3f}\np-value: {p_value:.2e}",
486
+ fontsize=10,
487
+ )
488
+ else:
489
+ ax.set_title(f"{ligand} vs {receptor}", fontsize=10)
490
+
491
+ # Add trend line
492
+ z = np.polyfit(ligand_expr, receptor_expr, 1)
493
+ p = np.poly1d(z)
494
+ ax.plot(ligand_expr, p(ligand_expr), "r--", alpha=0.8)
495
+
496
+ ax_idx += 1
497
+
498
+ except Exception as e:
499
+ if ax_idx < len(axes):
500
+ ax = axes[ax_idx]
501
+ ax.text(
502
+ 0.5,
503
+ 0.5,
504
+ f"Error plotting {ligand}-{receptor}:\n{e}",
505
+ ha="center",
506
+ va="center",
507
+ transform=ax.transAxes,
508
+ )
509
+ ax.set_title(f"{ligand}-{receptor} (Error)", fontsize=10)
510
+ ax_idx += 1
511
+
512
+ # Clean up temporary column
513
+ if temp_feature_key in adata.obs:
514
+ del adata.obs[temp_feature_key]
515
+
516
+ # Adjust spacing
517
+ fig.subplots_adjust(top=0.92, wspace=0.1, hspace=0.3, right=0.98)
518
+ return fig
519
+
520
+
521
+ # =============================================================================
522
+ # Gene Correlation Visualization
523
+ # =============================================================================
524
+
525
+
526
+ async def create_gene_correlation_visualization(
527
+ adata: "ad.AnnData",
528
+ params: VisualizationParameters,
529
+ context: Optional["ToolContext"] = None,
530
+ ) -> plt.Figure:
531
+ """Create gene correlation visualization using seaborn clustermap.
532
+
533
+ Args:
534
+ adata: AnnData object
535
+ params: Visualization parameters
536
+ context: MCP context for logging
537
+
538
+ Returns:
539
+ matplotlib Figure with gene correlation clustermap
540
+ """
541
+ import pandas as pd
542
+ import seaborn as sns
543
+
544
+ # Get validated genes
545
+ available_genes = await get_validated_features(
546
+ adata, params, max_features=10, context=context
547
+ )
548
+
549
+ if context:
550
+ await context.info(
551
+ f"Creating gene correlation visualization for {len(available_genes)} genes"
552
+ )
553
+
554
+ # Get expression matrix using unified utility
555
+ expr_matrix = get_genes_expression(adata, available_genes)
556
+
557
+ # Apply color scaling
558
+ if params.color_scale == "log":
559
+ expr_matrix = np.log1p(expr_matrix)
560
+ elif params.color_scale == "sqrt":
561
+ expr_matrix = np.sqrt(expr_matrix)
562
+
563
+ # Create DataFrame for correlation
564
+ expr_df = pd.DataFrame(expr_matrix, columns=available_genes)
565
+
566
+ # Calculate correlation
567
+ corr_df = expr_df.corr(method=params.correlation_method)
568
+
569
+ # Use seaborn clustermap
570
+ figsize = params.figure_size or (
571
+ max(8, len(available_genes)),
572
+ max(8, len(available_genes)),
573
+ )
574
+
575
+ g = sns.clustermap(
576
+ corr_df,
577
+ cmap=params.colormap,
578
+ center=0,
579
+ annot=True,
580
+ fmt=".2f",
581
+ square=True,
582
+ figsize=figsize,
583
+ dendrogram_ratio=0.15,
584
+ cbar_pos=(0.02, 0.8, 0.03, 0.15),
585
+ )
586
+
587
+ title = params.title or f"Gene Correlation ({params.correlation_method.title()})"
588
+ g.fig.suptitle(title, y=1.02, fontsize=14)
589
+
590
+ return g.fig
591
+
592
+
593
+ # =============================================================================
594
+ # Spatial Interaction Visualization
595
+ # =============================================================================
596
+
597
+
598
+ async def create_spatial_interaction_visualization(
599
+ adata: "ad.AnnData",
600
+ params: VisualizationParameters,
601
+ context: Optional["ToolContext"] = None,
602
+ ) -> plt.Figure:
603
+ """Create spatial visualization showing ligand-receptor interactions.
604
+
605
+ Args:
606
+ adata: AnnData object
607
+ params: Visualization parameters
608
+ context: MCP context for logging
609
+
610
+ Returns:
611
+ matplotlib Figure with spatial interaction visualization
612
+ """
613
+ from scipy.spatial.distance import cdist
614
+
615
+ if context:
616
+ await context.info("Creating spatial interaction plot")
617
+
618
+ try:
619
+ # Get spatial coordinates
620
+ spatial_coords = require_spatial_coords(adata)
621
+
622
+ # Validate lr_pairs
623
+ if not params.lr_pairs or len(params.lr_pairs) == 0:
624
+ raise ParameterError(
625
+ "No ligand-receptor pairs provided for spatial interaction visualization"
626
+ )
627
+
628
+ # Create figure
629
+ fig, ax = create_figure(figsize=(12, 10))
630
+
631
+ # Plot all cells as background
632
+ ax.scatter(
633
+ spatial_coords[:, 0],
634
+ spatial_coords[:, 1],
635
+ c="lightgray",
636
+ s=10,
637
+ alpha=0.5,
638
+ label="All cells",
639
+ )
640
+
641
+ # Color mapping for different LR pairs
642
+ colors = plt.get_cmap("Set3")(np.linspace(0, 1, len(params.lr_pairs)))
643
+
644
+ interaction_count = 0
645
+ for i, (ligand, receptor) in enumerate(params.lr_pairs):
646
+ color = colors[i]
647
+
648
+ # Check if genes exist
649
+ if ligand in adata.var_names and receptor in adata.var_names:
650
+ # Get expression using unified utility
651
+ ligand_expr = get_gene_expression(adata, ligand)
652
+ receptor_expr = get_gene_expression(adata, receptor)
653
+
654
+ # Define expression threshold
655
+ ligand_threshold = (
656
+ np.median(ligand_expr[ligand_expr > 0])
657
+ if np.any(ligand_expr > 0)
658
+ else 0
659
+ )
660
+ receptor_threshold = (
661
+ np.median(receptor_expr[receptor_expr > 0])
662
+ if np.any(receptor_expr > 0)
663
+ else 0
664
+ )
665
+
666
+ # Find expressing cells
667
+ ligand_cells = ligand_expr > ligand_threshold
668
+ receptor_cells = receptor_expr > receptor_threshold
669
+
670
+ if np.any(ligand_cells) and np.any(receptor_cells):
671
+ # Plot ligand-expressing cells
672
+ ligand_coords = spatial_coords[ligand_cells]
673
+ ax.scatter(
674
+ ligand_coords[:, 0],
675
+ ligand_coords[:, 1],
676
+ c=[color],
677
+ s=50,
678
+ alpha=0.7,
679
+ marker="o",
680
+ label=f"{ligand}+ (Ligand)",
681
+ )
682
+
683
+ # Plot receptor-expressing cells
684
+ receptor_coords = spatial_coords[receptor_cells]
685
+ ax.scatter(
686
+ receptor_coords[:, 0],
687
+ receptor_coords[:, 1],
688
+ c=[color],
689
+ s=50,
690
+ alpha=0.7,
691
+ marker="^",
692
+ label=f"{receptor}+ (Receptor)",
693
+ )
694
+
695
+ # Draw connections
696
+ if len(ligand_coords) > 0 and len(receptor_coords) > 0:
697
+ distances = cdist(ligand_coords, receptor_coords)
698
+ distance_threshold = np.percentile(distances, 10)
699
+
700
+ ligand_indices, receptor_indices = np.where(
701
+ distances <= distance_threshold
702
+ )
703
+ for li, ri in zip(
704
+ ligand_indices[:50], receptor_indices[:50], strict=False
705
+ ):
706
+ ax.plot(
707
+ [ligand_coords[li, 0], receptor_coords[ri, 0]],
708
+ [ligand_coords[li, 1], receptor_coords[ri, 1]],
709
+ color=color,
710
+ alpha=0.3,
711
+ linewidth=0.5,
712
+ )
713
+ interaction_count += 1
714
+
715
+ elif context:
716
+ await context.warning(
717
+ f"Genes {ligand} or {receptor} not found in expression data"
718
+ )
719
+
720
+ ax.set_xlabel("Spatial X")
721
+ ax.set_ylabel("Spatial Y")
722
+ ax.set_title(
723
+ f"Spatial Ligand-Receptor Interactions\n({interaction_count} connections shown)",
724
+ fontsize=14,
725
+ fontweight="bold",
726
+ )
727
+ ax.set_aspect("equal")
728
+ ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left")
729
+
730
+ plt.tight_layout()
731
+ return fig
732
+
733
+ except ValueError:
734
+ raise
735
+ except Exception as e:
736
+ raise ProcessingError(
737
+ f"Spatial ligand-receptor interaction visualization failed: {e}\n\n"
738
+ f"Check gene names exist and spatial coordinates are available."
739
+ ) from e