Spaces:
Sleeping
Sleeping
initial commit
Browse files- .env.example +40 -0
- .gitignore +84 -0
- CONTRIBUTING.md +422 -0
- DEPLOYMENT.md +471 -0
- Dockerfile +35 -0
- PROJECT_SUMMARY.md +437 -0
- app.py +100 -0
- assets/Logo.png +0 -0
- assets/style.css +137 -0
- modules/differential.py +685 -0
- requirements.txt +28 -0
- src/backend/data_loader.py +119 -0
- src/backend/flux_analysis.py +40 -0
- src/backend/flux_distribution.py +104 -0
- src/backend/flux_utils.py +56 -0
- src/backend/infer_metabolic_interactions.py +21 -0
- src/backend/preprocessing.py +43 -0
- src/ui/components/footer.py +31 -0
- src/ui/components/header.py +66 -0
- src/ui/pages/flux_analysis.py +31 -0
- src/ui/pages/overview.py +217 -0
- src/ui/pages/preprocessing.py +41 -0
- src/ui/pages/visualization.py +69 -0
- src/ui/plots/differential_analysis.py +113 -0
- src/ui/plots/domain_statistics.py +351 -0
- src/ui/plots/metabolic_interactions.py +162 -0
- src/ui/plots/metabolite_balance.py +142 -0
- src/ui/plots/spatial_flux_map.py +241 -0
- src/ui/plots/umap_embedding.py +211 -0
- src/ui/plots/utils.py +488 -0
.env.example
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment Configuration Example
|
| 2 |
+
# Copy this file to .env and customize values
|
| 3 |
+
|
| 4 |
+
# Streamlit Configuration
|
| 5 |
+
STREAMLIT_SERVER_PORT=8501
|
| 6 |
+
STREAMLIT_SERVER_MAXUPLOADSIZE=2000
|
| 7 |
+
STREAMLIT_LOGGER_LEVEL=info
|
| 8 |
+
|
| 9 |
+
# Application Settings
|
| 10 |
+
APP_ENV=production
|
| 11 |
+
DEBUG=False
|
| 12 |
+
|
| 13 |
+
# Cache Settings
|
| 14 |
+
CACHE_EXPIRATION=3600
|
| 15 |
+
CACHE_DIR=./.streamlit/cache
|
| 16 |
+
|
| 17 |
+
# Data Settings
|
| 18 |
+
MAX_FILE_SIZE_MB=2000
|
| 19 |
+
SUPPORTED_FORMATS=h5ad
|
| 20 |
+
|
| 21 |
+
# Model Settings
|
| 22 |
+
DEFAULT_METABOLIC_MODEL=breast_cancer
|
| 23 |
+
PRETRAINED_MODEL_NAME=Surajv/spMetaTME-human_64D_v1
|
| 24 |
+
|
| 25 |
+
# Analysis Settings
|
| 26 |
+
DEFAULT_N_CLUSTERS=5
|
| 27 |
+
DEFAULT_N_NEIGHBORS=150
|
| 28 |
+
DEFAULT_BATCH_SIZE=80
|
| 29 |
+
|
| 30 |
+
# Performance
|
| 31 |
+
NUM_WORKERS=4
|
| 32 |
+
USE_GPU=False
|
| 33 |
+
|
| 34 |
+
# Logging
|
| 35 |
+
LOG_LEVEL=INFO
|
| 36 |
+
LOG_FILE=logs/app.log
|
| 37 |
+
|
| 38 |
+
# Optional: Streamlit Cloud Credentials
|
| 39 |
+
# STREAMLIT_EMAIL=your-email@example.com
|
| 40 |
+
# STREAMLIT_PASSWORD=your-password
|
.gitignore
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
pip-wheel-metadata/
|
| 20 |
+
share/python-wheels/
|
| 21 |
+
*.egg-info/
|
| 22 |
+
.installed.cfg
|
| 23 |
+
*.egg
|
| 24 |
+
MANIFEST
|
| 25 |
+
|
| 26 |
+
# Virtual Environments
|
| 27 |
+
venv/
|
| 28 |
+
ENV/
|
| 29 |
+
env/
|
| 30 |
+
.venv
|
| 31 |
+
env.bak/
|
| 32 |
+
venv.bak/
|
| 33 |
+
|
| 34 |
+
# IDE
|
| 35 |
+
.vscode/
|
| 36 |
+
.idea/
|
| 37 |
+
*.swp
|
| 38 |
+
*.swo
|
| 39 |
+
*~
|
| 40 |
+
.DS_Store
|
| 41 |
+
*.sublime-project
|
| 42 |
+
*.sublime-workspace
|
| 43 |
+
|
| 44 |
+
# Streamlit
|
| 45 |
+
.streamlit/
|
| 46 |
+
.streamlit/cache/
|
| 47 |
+
.streamlit/exports/
|
| 48 |
+
.streamlit/uploads/
|
| 49 |
+
__pycache__/
|
| 50 |
+
|
| 51 |
+
# Data files
|
| 52 |
+
*.h5ad
|
| 53 |
+
*.h5
|
| 54 |
+
*.csv
|
| 55 |
+
uploads/
|
| 56 |
+
cache/
|
| 57 |
+
logs/
|
| 58 |
+
|
| 59 |
+
# Environment
|
| 60 |
+
.env
|
| 61 |
+
.env.local
|
| 62 |
+
.env.*.local
|
| 63 |
+
|
| 64 |
+
# OS
|
| 65 |
+
.DS_Store
|
| 66 |
+
Thumbs.db
|
| 67 |
+
|
| 68 |
+
# Jupyter
|
| 69 |
+
.ipynb_checkpoints
|
| 70 |
+
*.ipynb
|
| 71 |
+
|
| 72 |
+
# Testing
|
| 73 |
+
.pytest_cache/
|
| 74 |
+
.coverage
|
| 75 |
+
htmlcov/
|
| 76 |
+
|
| 77 |
+
# Docker
|
| 78 |
+
*.log
|
| 79 |
+
|
| 80 |
+
# Temporary files
|
| 81 |
+
*.tmp
|
| 82 |
+
*.temp
|
| 83 |
+
*.backup
|
| 84 |
+
*~
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to Spatial Metabolic Atlas
|
| 2 |
+
|
| 3 |
+
Thank you for your interest in contributing! This document provides guidelines for development, testing, and contributions.
|
| 4 |
+
|
| 5 |
+
## 🤝 How to Contribute
|
| 6 |
+
|
| 7 |
+
### Types of Contributions
|
| 8 |
+
- **Bug Fixes**: Report and fix issues
|
| 9 |
+
- **Features**: Add new analysis modules or visualizations
|
| 10 |
+
- **Documentation**: Improve guides and examples
|
| 11 |
+
- **Tests**: Add unit and integration tests
|
| 12 |
+
- **Performance**: Optimize existing code
|
| 13 |
+
|
| 14 |
+
## 🔧 Development Setup
|
| 15 |
+
|
| 16 |
+
### 1. Clone Repository
|
| 17 |
+
```bash
|
| 18 |
+
git clone <repo-url>
|
| 19 |
+
cd streamlit_app
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### 2. Create Development Environment
|
| 23 |
+
```bash
|
| 24 |
+
# Create virtual environment
|
| 25 |
+
python -m venv venv
|
| 26 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 27 |
+
|
| 28 |
+
# Install dependencies with dev extras
|
| 29 |
+
pip install -r requirements.txt
|
| 30 |
+
pip install -e . # If using setup.py
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### 3. Install Pre-commit Hooks
|
| 34 |
+
```bash
|
| 35 |
+
pip install pre-commit
|
| 36 |
+
pre-commit install
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## 📝 Code Style
|
| 40 |
+
|
| 41 |
+
### Python Style Guide (PEP 8)
|
| 42 |
+
```bash
|
| 43 |
+
# Format code
|
| 44 |
+
black modules/ utils/
|
| 45 |
+
|
| 46 |
+
# Check linting
|
| 47 |
+
flake8 modules/ utils/
|
| 48 |
+
|
| 49 |
+
# Type checking
|
| 50 |
+
mypy modules/ utils/
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### Docstring Format
|
| 54 |
+
```python
|
| 55 |
+
def example_function(param1: str, param2: int) -> bool:
|
| 56 |
+
"""
|
| 57 |
+
Brief description of function.
|
| 58 |
+
|
| 59 |
+
Longer description if needed, explaining the purpose,
|
| 60 |
+
algorithm, or important details.
|
| 61 |
+
|
| 62 |
+
Parameters
|
| 63 |
+
----------
|
| 64 |
+
param1 : str
|
| 65 |
+
Description of param1
|
| 66 |
+
param2 : int
|
| 67 |
+
Description of param2
|
| 68 |
+
|
| 69 |
+
Returns
|
| 70 |
+
-------
|
| 71 |
+
bool
|
| 72 |
+
Description of return value
|
| 73 |
+
|
| 74 |
+
Examples
|
| 75 |
+
--------
|
| 76 |
+
>>> result = example_function("test", 42)
|
| 77 |
+
>>> print(result)
|
| 78 |
+
True
|
| 79 |
+
|
| 80 |
+
Notes
|
| 81 |
+
-----
|
| 82 |
+
Additional implementation notes or warnings.
|
| 83 |
+
"""
|
| 84 |
+
return True
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## 🧪 Testing
|
| 88 |
+
|
| 89 |
+
### Running Tests
|
| 90 |
+
```bash
|
| 91 |
+
# Run all tests
|
| 92 |
+
pytest
|
| 93 |
+
|
| 94 |
+
# Run specific test file
|
| 95 |
+
pytest tests/test_visualization.py
|
| 96 |
+
|
| 97 |
+
# Run with coverage
|
| 98 |
+
pytest --cov=modules --cov=utils tests/
|
| 99 |
+
|
| 100 |
+
# Verbose output
|
| 101 |
+
pytest -v
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Writing Tests
|
| 105 |
+
```python
|
| 106 |
+
# tests/test_module.py
|
| 107 |
+
import pytest
|
| 108 |
+
from modules import visualization
|
| 109 |
+
|
| 110 |
+
def test_spatial_flux_map_basic():
|
| 111 |
+
"""Test basic spatial flux map generation."""
|
| 112 |
+
# Setup
|
| 113 |
+
mock_adata = create_mock_adata()
|
| 114 |
+
|
| 115 |
+
# Action
|
| 116 |
+
fig = visualization.plot_spatial_flux(mock_adata, 'EX_glc_D[e]')
|
| 117 |
+
|
| 118 |
+
# Assert
|
| 119 |
+
assert fig is not None
|
| 120 |
+
assert fig.axes is not None
|
| 121 |
+
|
| 122 |
+
def test_visualization_with_invalid_reaction():
|
| 123 |
+
"""Test error handling for invalid reactions."""
|
| 124 |
+
mock_adata = create_mock_adata()
|
| 125 |
+
|
| 126 |
+
with pytest.raises(ValueError):
|
| 127 |
+
visualization.plot_spatial_flux(mock_adata, 'INVALID_RXN')
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## 📦 Adding New Modules
|
| 131 |
+
|
| 132 |
+
### Structure for New Feature
|
| 133 |
+
```
|
| 134 |
+
modules/
|
| 135 |
+
├── new_feature.py # Main module
|
| 136 |
+
├── __init__.py
|
| 137 |
+
└── tests/
|
| 138 |
+
└── test_new_feature.py
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
### Module Template
|
| 142 |
+
```python
|
| 143 |
+
"""
|
| 144 |
+
New Feature Module
|
| 145 |
+
==================
|
| 146 |
+
|
| 147 |
+
Brief description of functionality.
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
import streamlit as st
|
| 151 |
+
import logging
|
| 152 |
+
|
| 153 |
+
logger = logging.getLogger(__name__)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def render():
|
| 157 |
+
"""Render feature UI."""
|
| 158 |
+
st.markdown("## 🆕 New Feature")
|
| 159 |
+
|
| 160 |
+
# Check prerequisites
|
| 161 |
+
if st.session_state.metabolic_adata is None:
|
| 162 |
+
st.error("Please run flux analysis first.")
|
| 163 |
+
return
|
| 164 |
+
|
| 165 |
+
metabolic_adata = st.session_state.metabolic_adata
|
| 166 |
+
|
| 167 |
+
# UI components
|
| 168 |
+
col1, col2 = st.columns(2)
|
| 169 |
+
|
| 170 |
+
with col1:
|
| 171 |
+
# Input controls
|
| 172 |
+
parameter = st.slider("Parameter:", 1, 100, 50)
|
| 173 |
+
|
| 174 |
+
with col2:
|
| 175 |
+
# Additional options
|
| 176 |
+
method = st.selectbox("Method:", ["option1", "option2"])
|
| 177 |
+
|
| 178 |
+
# Main computation
|
| 179 |
+
if st.button("▶️ Run Analysis") :
|
| 180 |
+
try:
|
| 181 |
+
with st.spinner("Computing..."):
|
| 182 |
+
result = perform_analysis(metabolic_adata, parameter, method)
|
| 183 |
+
|
| 184 |
+
st.success("✓ Analysis complete!")
|
| 185 |
+
st.dataframe(result)
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
st.error(f"Error: {str(e)}")
|
| 189 |
+
logger.error(f"Analysis failed: {str(e)}", exc_info=True)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def perform_analysis(adata, parameter, method):
|
| 193 |
+
"""
|
| 194 |
+
Perform custom analysis.
|
| 195 |
+
|
| 196 |
+
Parameters
|
| 197 |
+
----------
|
| 198 |
+
adata : AnnData
|
| 199 |
+
Input data
|
| 200 |
+
parameter : int
|
| 201 |
+
Analysis parameter
|
| 202 |
+
method : str
|
| 203 |
+
Analysis method
|
| 204 |
+
|
| 205 |
+
Returns
|
| 206 |
+
-------
|
| 207 |
+
pd.DataFrame
|
| 208 |
+
Analysis results
|
| 209 |
+
"""
|
| 210 |
+
# Implementation
|
| 211 |
+
results = {}
|
| 212 |
+
return results
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### Integrating into Main App
|
| 216 |
+
```python
|
| 217 |
+
# app.py
|
| 218 |
+
elif page == "🆕 New Feature":
|
| 219 |
+
from modules import new_feature
|
| 220 |
+
new_feature.render()
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
## 📚 Documentation
|
| 224 |
+
|
| 225 |
+
### Adding to README.md
|
| 226 |
+
1. Update feature list under "Features"
|
| 227 |
+
2. Add usage instructions in "Usage Guide"
|
| 228 |
+
3. Include examples and expected outputs
|
| 229 |
+
|
| 230 |
+
### Creating Examples
|
| 231 |
+
```python
|
| 232 |
+
# examples/basic_workflow.py
|
| 233 |
+
"""
|
| 234 |
+
Basic workflow example for Spatial Metabolic Atlas.
|
| 235 |
+
"""
|
| 236 |
+
|
| 237 |
+
import scanpy as sc
|
| 238 |
+
from streamlit_app.utils import plotting, flux_utils
|
| 239 |
+
|
| 240 |
+
# Load data
|
| 241 |
+
adata = sc.read_h5ad("data/spatial_data.h5ad")
|
| 242 |
+
|
| 243 |
+
# Preprocess
|
| 244 |
+
sc.pp.normalize_total(adata, 1e4)
|
| 245 |
+
sc.pp.log1p(adata)
|
| 246 |
+
|
| 247 |
+
# Visualize
|
| 248 |
+
fig = plotting.plot_spatial_flux(adata, "EX_glc_D[e]")
|
| 249 |
+
|
| 250 |
+
print("Done!")
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
## 🐛 Bug Reports
|
| 254 |
+
|
| 255 |
+
When reporting bugs, include:
|
| 256 |
+
- **Title**: Concise description
|
| 257 |
+
- **Steps to reproduce**: Exact steps to recreate issue
|
| 258 |
+
- **Expected behavior**: What should happen
|
| 259 |
+
- **Actual behavior**: What actually happens
|
| 260 |
+
- **Screenshots**: If applicable
|
| 261 |
+
- **Environment**: Python version, OS, package versions
|
| 262 |
+
|
| 263 |
+
### Bug Report Template
|
| 264 |
+
```markdown
|
| 265 |
+
## Bug: [Title]
|
| 266 |
+
|
| 267 |
+
### Steps to Reproduce
|
| 268 |
+
1. Load dataset X
|
| 269 |
+
2. Go to Y module
|
| 270 |
+
3. Click button Z
|
| 271 |
+
4. Get error
|
| 272 |
+
|
| 273 |
+
### Expected Behavior
|
| 274 |
+
Should show visualization
|
| 275 |
+
|
| 276 |
+
### Actual Behavior
|
| 277 |
+
Shows error message: "..."
|
| 278 |
+
|
| 279 |
+
### Screenshots
|
| 280 |
+
[If applicable]
|
| 281 |
+
|
| 282 |
+
### Environment
|
| 283 |
+
- Python: 3.10.5
|
| 284 |
+
- Streamlit: 1.28.0
|
| 285 |
+
- OS: Ubuntu 22.04
|
| 286 |
+
```
|
| 287 |
+
|
| 288 |
+
## 🎨 Feature Requests
|
| 289 |
+
|
| 290 |
+
Provide:
|
| 291 |
+
- **Use case**: Why is this needed?
|
| 292 |
+
- **Description**: Detailed description
|
| 293 |
+
- **Example**: How would it be used?
|
| 294 |
+
- **Priority**: Low, Medium, High
|
| 295 |
+
|
| 296 |
+
### Feature Request Template
|
| 297 |
+
```markdown
|
| 298 |
+
## Feature: [Title]
|
| 299 |
+
|
| 300 |
+
### Use Case
|
| 301 |
+
Researchers want to [use case description]
|
| 302 |
+
|
| 303 |
+
### Proposed Solution
|
| 304 |
+
Implement [feature description]
|
| 305 |
+
|
| 306 |
+
### Example Usage
|
| 307 |
+
[How users would use this feature]
|
| 308 |
+
|
| 309 |
+
### Additional Context
|
| 310 |
+
[Any other relevant information]
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
## 📈 Pull Request Process
|
| 314 |
+
|
| 315 |
+
1. **Fork** the repository
|
| 316 |
+
2. **Create Branch**: `git checkout -b feature/feature-name`
|
| 317 |
+
3. **Make Changes**: Follow code style guidelines
|
| 318 |
+
4. **Write Tests**: Add tests for new functionality
|
| 319 |
+
5. **Document**: Update README, docstrings, comments
|
| 320 |
+
6. **Commit**: Clear, descriptive commit messages
|
| 321 |
+
7. **Push**: `git push origin feature/feature-name`
|
| 322 |
+
8. **Create PR**: Open pull request with description
|
| 323 |
+
|
| 324 |
+
### PR Template
|
| 325 |
+
```markdown
|
| 326 |
+
## Description
|
| 327 |
+
Brief description of changes
|
| 328 |
+
|
| 329 |
+
## Type
|
| 330 |
+
- [ ] Bug fix
|
| 331 |
+
- [ ] New feature
|
| 332 |
+
- [ ] Documentation
|
| 333 |
+
- [ ] Performance
|
| 334 |
+
|
| 335 |
+
## Changes
|
| 336 |
+
- Change 1
|
| 337 |
+
- Change 2
|
| 338 |
+
|
| 339 |
+
## Testing
|
| 340 |
+
- [ ] Tests added/updated
|
| 341 |
+
- [ ] All tests passing
|
| 342 |
+
- [ ] Manual testing completed
|
| 343 |
+
|
| 344 |
+
## Screenshots
|
| 345 |
+
[If applicable]
|
| 346 |
+
|
| 347 |
+
## Checklist
|
| 348 |
+
- [ ] Code follows style guidelines
|
| 349 |
+
- [ ] Documentation updated
|
| 350 |
+
- [ ] Tests added
|
| 351 |
+
- [ ] No breaking changes
|
| 352 |
+
```
|
| 353 |
+
|
| 354 |
+
## 🏗️ Architecture Decisions
|
| 355 |
+
|
| 356 |
+
### Module Interface Standard
|
| 357 |
+
All modules should:
|
| 358 |
+
- Implement `render()` function
|
| 359 |
+
- Check `st.session_state` for prerequisites
|
| 360 |
+
- Handle errors gracefully with try/except
|
| 361 |
+
- Log important operations
|
| 362 |
+
- Provide user feedback (success/error messages)
|
| 363 |
+
|
| 364 |
+
### Caching Strategy
|
| 365 |
+
```python
|
| 366 |
+
@st.cache_data(ttl=3600)
|
| 367 |
+
def expensive_computation(data):
|
| 368 |
+
"""This will be cached for 1 hour."""
|
| 369 |
+
return result
|
| 370 |
+
|
| 371 |
+
@st.cache_resource
|
| 372 |
+
def load_model():
|
| 373 |
+
"""This will be cached for entire session."""
|
| 374 |
+
return model
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
## 🔄 Release Process
|
| 378 |
+
|
| 379 |
+
1. **Update Version**:
|
| 380 |
+
- `app.py`: Update version string
|
| 381 |
+
- `setup.py`: Update version
|
| 382 |
+
- Create CHANGELOG entry
|
| 383 |
+
|
| 384 |
+
2. **Create Release**:
|
| 385 |
+
- Tag commit: `git tag v1.0.0`
|
| 386 |
+
- Push tag: `git push origin v1.0.0`
|
| 387 |
+
- Create GitHub release with notes
|
| 388 |
+
|
| 389 |
+
3. **Deploy**:
|
| 390 |
+
- Build Docker image
|
| 391 |
+
- Push to registry
|
| 392 |
+
- Deploy to production
|
| 393 |
+
|
| 394 |
+
## 📞 Getting Help
|
| 395 |
+
|
| 396 |
+
- **Documentation**: Check README.md and DEPLOYMENT.md
|
| 397 |
+
- **Issues**: Search GitHub Issues
|
| 398 |
+
- **Discussions**: Start discussion thread
|
| 399 |
+
- **Email**: contact@example.com
|
| 400 |
+
|
| 401 |
+
## 📋 Code of Conduct
|
| 402 |
+
|
| 403 |
+
### Our Pledge
|
| 404 |
+
We are committed to providing a welcoming and inclusive environment.
|
| 405 |
+
|
| 406 |
+
### Our Standards
|
| 407 |
+
- Use welcoming language
|
| 408 |
+
- Be respectful of differing opinions
|
| 409 |
+
- Accept constructive criticism gracefully
|
| 410 |
+
- Focus on what is best for the community
|
| 411 |
+
- Show empathy towards other community members
|
| 412 |
+
|
| 413 |
+
### Enforcement
|
| 414 |
+
Violations may result in removal from the community.
|
| 415 |
+
|
| 416 |
+
---
|
| 417 |
+
|
| 418 |
+
**Thank you for contributing!** 🎉
|
| 419 |
+
|
| 420 |
+
Your contributions help make Spatial Metabolic Atlas better for researchers worldwide.
|
| 421 |
+
|
| 422 |
+
**Last Updated**: February 2024
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment Guide for Spatial Metabolic Atlas
|
| 2 |
+
|
| 3 |
+
## 🚀 Deployment Options
|
| 4 |
+
|
| 5 |
+
### 1. Local Development
|
| 6 |
+
|
| 7 |
+
#### System Requirements
|
| 8 |
+
- Python 3.10+
|
| 9 |
+
- 8GB RAM (16GB recommended for large datasets)
|
| 10 |
+
- 10GB free disk space
|
| 11 |
+
|
| 12 |
+
#### Setup
|
| 13 |
+
```bash
|
| 14 |
+
# Clone repository
|
| 15 |
+
git clone <repo-url>
|
| 16 |
+
cd streamlit_app
|
| 17 |
+
|
| 18 |
+
# Create virtual environment
|
| 19 |
+
python -m venv venv
|
| 20 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 21 |
+
|
| 22 |
+
# Install dependencies
|
| 23 |
+
pip install -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# Run application
|
| 26 |
+
streamlit run app.py
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Access at: `http://localhost:8501`
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
### 2. Docker Deployment
|
| 34 |
+
|
| 35 |
+
#### Requirements
|
| 36 |
+
- Docker (version 20.10+)
|
| 37 |
+
- Docker Compose (version 1.29+)
|
| 38 |
+
- 8GB available RAM
|
| 39 |
+
- 20GB free disk space
|
| 40 |
+
|
| 41 |
+
#### Quick Start
|
| 42 |
+
```bash
|
| 43 |
+
# Build and run
|
| 44 |
+
docker-compose up --build
|
| 45 |
+
|
| 46 |
+
# Run in background
|
| 47 |
+
docker-compose up -d
|
| 48 |
+
|
| 49 |
+
# View logs
|
| 50 |
+
docker-compose logs -f streamlit
|
| 51 |
+
|
| 52 |
+
# Stop application
|
| 53 |
+
docker-compose down
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
Access at: `http://localhost:8501`
|
| 57 |
+
|
| 58 |
+
#### Manual Docker Build
|
| 59 |
+
```bash
|
| 60 |
+
# Build image
|
| 61 |
+
docker build -t spatial-metabolic-atlas .
|
| 62 |
+
|
| 63 |
+
# Run container
|
| 64 |
+
docker run -p 8501:8501 \
|
| 65 |
+
-v $(pwd)/cache:/app/cache \
|
| 66 |
+
-v $(pwd)/uploads:/app/uploads \
|
| 67 |
+
spatial-metabolic-atlas
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
### 3. Streamlit Cloud Deployment
|
| 73 |
+
|
| 74 |
+
#### Prerequisites
|
| 75 |
+
- GitHub account
|
| 76 |
+
- Repository with code pushed to GitHub
|
| 77 |
+
- Streamlit account
|
| 78 |
+
|
| 79 |
+
#### Steps
|
| 80 |
+
1. **Push to GitHub**
|
| 81 |
+
```bash
|
| 82 |
+
git add .
|
| 83 |
+
git commit -m "Spatial Metabolic Atlas"
|
| 84 |
+
git push origin main
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
2. **Deploy on Streamlit Cloud**
|
| 88 |
+
- Go to https://share.streamlit.io
|
| 89 |
+
- Click "New app"
|
| 90 |
+
- Select your repository, branch, and main file
|
| 91 |
+
- Click "Deploy"
|
| 92 |
+
|
| 93 |
+
3. **Configure Secrets** (if needed)
|
| 94 |
+
- Go to app settings → Secrets
|
| 95 |
+
- Add any sensitive configurations
|
| 96 |
+
|
| 97 |
+
#### Example `.streamlit/secrets.toml`
|
| 98 |
+
```toml
|
| 99 |
+
db_username = "user"
|
| 100 |
+
db_password = "password"
|
| 101 |
+
api_key = "your-api-key"
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
### 4. AWS Deployment
|
| 107 |
+
|
| 108 |
+
#### Using EC2
|
| 109 |
+
|
| 110 |
+
**Step 1: Launch EC2 Instance**
|
| 111 |
+
```bash
|
| 112 |
+
# Instance type: t3.large (8GB RAM)
|
| 113 |
+
# OS: Ubuntu 22.04 LTS
|
| 114 |
+
# Storage: 30GB gp3
|
| 115 |
+
# Security group: Allow 8501, 22, 80, 443
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
**Step 2: Install Dependencies**
|
| 119 |
+
```bash
|
| 120 |
+
# Update system
|
| 121 |
+
sudo apt update
|
| 122 |
+
sudo apt upgrade -y
|
| 123 |
+
|
| 124 |
+
# Install Python and build tools
|
| 125 |
+
sudo apt install -y python3.10 python3.10-venv python3-pip git
|
| 126 |
+
|
| 127 |
+
# Install system libraries
|
| 128 |
+
sudo apt install -y libhdf5-dev build-essential
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
**Step 3: Deploy Application**
|
| 132 |
+
```bash
|
| 133 |
+
# Clone and setup
|
| 134 |
+
git clone <repo-url>
|
| 135 |
+
cd streamlit_app
|
| 136 |
+
|
| 137 |
+
# Create virtual environment
|
| 138 |
+
python3.10 -m venv venv
|
| 139 |
+
source venv/bin/activate
|
| 140 |
+
|
| 141 |
+
# Install dependencies
|
| 142 |
+
pip install -r requirements.txt
|
| 143 |
+
|
| 144 |
+
# Run with systemd (systemctl)
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
**Step 4: Create systemd Service**
|
| 148 |
+
```bash
|
| 149 |
+
# Create service file
|
| 150 |
+
sudo nano /etc/systemd/system/streamlit.service
|
| 151 |
+
|
| 152 |
+
# Add content:
|
| 153 |
+
[Unit]
|
| 154 |
+
Description=Streamlit Application
|
| 155 |
+
After=network.target
|
| 156 |
+
|
| 157 |
+
[Service]
|
| 158 |
+
Type=simple
|
| 159 |
+
User=ubuntu
|
| 160 |
+
WorkingDirectory=/home/ubuntu/streamlit_app
|
| 161 |
+
Environment="PATH=/home/ubuntu/streamlit_app/venv/bin"
|
| 162 |
+
ExecStart=/home/ubuntu/streamlit_app/venv/bin/streamlit run app.py --server.port 8501
|
| 163 |
+
Restart=on-failure
|
| 164 |
+
RestartSec=10
|
| 165 |
+
|
| 166 |
+
[Install]
|
| 167 |
+
WantedBy=multi-user.target
|
| 168 |
+
|
| 169 |
+
# Enable service
|
| 170 |
+
sudo systemctl enable streamlit
|
| 171 |
+
sudo systemctl start streamlit
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
#### Using ECS with Docker
|
| 175 |
+
|
| 176 |
+
```bash
|
| 177 |
+
# Create ECR repository
|
| 178 |
+
aws ecr create-repository --repository-name spatial-metabolic-atlas
|
| 179 |
+
|
| 180 |
+
# Build and push image
|
| 181 |
+
docker build -t spatial-metabolic-atlas .
|
| 182 |
+
docker tag spatial-metabolic-atlas:latest <aws-account>.dkr.ecr.<region>.amazonaws.com/spatial-metabolic-atlas:latest
|
| 183 |
+
docker push <aws-account>.dkr.ecr.<region>.amazonaws.com/spatial-metabolic-atlas:latest
|
| 184 |
+
|
| 185 |
+
# Create ECS task definition and service
|
| 186 |
+
# (See AWS console for details)
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
### 5. Google Cloud Platform Deployment
|
| 192 |
+
|
| 193 |
+
#### Using Cloud Run
|
| 194 |
+
|
| 195 |
+
```bash
|
| 196 |
+
# Authenticate
|
| 197 |
+
gcloud auth login
|
| 198 |
+
|
| 199 |
+
# Build and deploy directly
|
| 200 |
+
gcloud run deploy spatial-metabolic-atlas \
|
| 201 |
+
--source . \
|
| 202 |
+
--platform managed \
|
| 203 |
+
--region us-central1 \
|
| 204 |
+
--memory 4Gi \
|
| 205 |
+
--timeout 3600 \
|
| 206 |
+
--set-env-vars STREAMLIT_SERVER_MAXUPLOADSIZE=2000
|
| 207 |
+
|
| 208 |
+
# Or push to Container Registry first
|
| 209 |
+
gcloud builds submit --tag gcr.io/<project>/spatial-metabolic-atlas
|
| 210 |
+
gcloud run deploy spatial-metabolic-atlas \
|
| 211 |
+
--image gcr.io/<project>/spatial-metabolic-atlas \
|
| 212 |
+
--platform managed \
|
| 213 |
+
--region us-central1 \
|
| 214 |
+
--memory 4Gi
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
#### Using Compute Engine
|
| 218 |
+
|
| 219 |
+
Similar to AWS EC2 setup with Ubuntu image.
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
### 6. Azure Deployment
|
| 224 |
+
|
| 225 |
+
#### Using Azure Container Instances (ACI)
|
| 226 |
+
|
| 227 |
+
```bash
|
| 228 |
+
# Create resource group
|
| 229 |
+
az group create --name spatial-metabolic --location eastus
|
| 230 |
+
|
| 231 |
+
# Build image
|
| 232 |
+
az acr build --registry <your-registry> \
|
| 233 |
+
--image spatial-metabolic-atlas:latest .
|
| 234 |
+
|
| 235 |
+
# Deploy container
|
| 236 |
+
az container create \
|
| 237 |
+
--resource-group spatial-metabolic \
|
| 238 |
+
--name spatial-metabolic-atlas \
|
| 239 |
+
--image <your-registry>.azurecr.io/spatial-metabolic-atlas:latest \
|
| 240 |
+
--ports 8501 \
|
| 241 |
+
--environment-variables STREAMLIT_SERVER_MAXUPLOADSIZE=2000
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
---
|
| 245 |
+
|
| 246 |
+
### 7. Kubernetes Deployment
|
| 247 |
+
|
| 248 |
+
#### Requirements
|
| 249 |
+
- Kubernetes cluster (GKE, EKS, AKS, or local)
|
| 250 |
+
- kubectl configured
|
| 251 |
+
- Docker image in registry
|
| 252 |
+
|
| 253 |
+
#### Deployment Steps
|
| 254 |
+
|
| 255 |
+
**1. Create Docker Image**
|
| 256 |
+
```bash
|
| 257 |
+
docker build -t spatial-metabolic-atlas:latest .
|
| 258 |
+
docker tag spatial-metabolic-atlas:latest <registry>/spatial-metabolic-atlas:latest
|
| 259 |
+
docker push <registry>/spatial-metabolic-atlas:latest
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
**2. Create Kubernetes Manifests**
|
| 263 |
+
|
| 264 |
+
`deployment.yaml`:
|
| 265 |
+
```yaml
|
| 266 |
+
apiVersion: apps/v1
|
| 267 |
+
kind: Deployment
|
| 268 |
+
metadata:
|
| 269 |
+
name: spatial-metabolic-atlas
|
| 270 |
+
labels:
|
| 271 |
+
app: spatial-metabolic-atlas
|
| 272 |
+
spec:
|
| 273 |
+
replicas: 2
|
| 274 |
+
selector:
|
| 275 |
+
matchLabels:
|
| 276 |
+
app: spatial-metabolic-atlas
|
| 277 |
+
template:
|
| 278 |
+
metadata:
|
| 279 |
+
labels:
|
| 280 |
+
app: spatial-metabolic-atlas
|
| 281 |
+
spec:
|
| 282 |
+
containers:
|
| 283 |
+
- name: streamlit
|
| 284 |
+
image: <registry>/spatial-metabolic-atlas:latest
|
| 285 |
+
ports:
|
| 286 |
+
- containerPort: 8501
|
| 287 |
+
resources:
|
| 288 |
+
requests:
|
| 289 |
+
memory: "4Gi"
|
| 290 |
+
cpu: "2"
|
| 291 |
+
limits:
|
| 292 |
+
memory: "8Gi"
|
| 293 |
+
cpu: "4"
|
| 294 |
+
volumeMounts:
|
| 295 |
+
- name: cache
|
| 296 |
+
mountPath: /app/cache
|
| 297 |
+
volumes:
|
| 298 |
+
- name: cache
|
| 299 |
+
emptyDir: {}
|
| 300 |
+
---
|
| 301 |
+
apiVersion: v1
|
| 302 |
+
kind: Service
|
| 303 |
+
metadata:
|
| 304 |
+
name: spatial-metabolic-atlas-service
|
| 305 |
+
spec:
|
| 306 |
+
type: LoadBalancer
|
| 307 |
+
ports:
|
| 308 |
+
- port: 80
|
| 309 |
+
targetPort: 8501
|
| 310 |
+
selector:
|
| 311 |
+
app: spatial-metabolic-atlas
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
**3. Deploy**
|
| 315 |
+
```bash
|
| 316 |
+
kubectl apply -f deployment.yaml
|
| 317 |
+
|
| 318 |
+
# Check status
|
| 319 |
+
kubectl get pods
|
| 320 |
+
kubectl get svc
|
| 321 |
+
|
| 322 |
+
# Access via LoadBalancer IP
|
| 323 |
+
```
|
| 324 |
+
|
| 325 |
+
---
|
| 326 |
+
|
| 327 |
+
## 🔒 Security Considerations
|
| 328 |
+
|
| 329 |
+
### SSL/TLS Configuration
|
| 330 |
+
```nginx
|
| 331 |
+
# Nginx reverse proxy example
|
| 332 |
+
server {
|
| 333 |
+
listen 443 ssl;
|
| 334 |
+
server_name spatial-metabolic.yourdomain.com;
|
| 335 |
+
|
| 336 |
+
ssl_certificate /path/to/cert.pem;
|
| 337 |
+
ssl_certificate_key /path/to/key.pem;
|
| 338 |
+
|
| 339 |
+
location / {
|
| 340 |
+
proxy_pass http://localhost:8501;
|
| 341 |
+
proxy_set_header Host $host;
|
| 342 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
```
|
| 346 |
+
|
| 347 |
+
### Environment Variables
|
| 348 |
+
- Never commit sensitive data
|
| 349 |
+
- Use `.env` files (in .gitignore)
|
| 350 |
+
- Use platform secrets management (AWS Secrets Manager, etc.)
|
| 351 |
+
|
| 352 |
+
### Data Protection
|
| 353 |
+
- Implement user authentication if needed
|
| 354 |
+
- Encrypt sensitive data in transit
|
| 355 |
+
- Regular backups of analysis results
|
| 356 |
+
- Clear cache periodically
|
| 357 |
+
|
| 358 |
+
---
|
| 359 |
+
|
| 360 |
+
## 📊 Performance Tuning
|
| 361 |
+
|
| 362 |
+
### Memory Optimization
|
| 363 |
+
```bash
|
| 364 |
+
# Streamlit config for large datasets
|
| 365 |
+
[server]
|
| 366 |
+
maxUploadSize = 2000
|
| 367 |
+
timeout = 3600
|
| 368 |
+
|
| 369 |
+
[client]
|
| 370 |
+
toolbarMode = "minimal" # Reduce UI overhead
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
### Caching Strategy
|
| 374 |
+
- Use @st.cache_data for immutable data
|
| 375 |
+
- Use @st.cache_resource for expensive computations
|
| 376 |
+
- Clear cache based on data changes
|
| 377 |
+
|
| 378 |
+
### Scaling
|
| 379 |
+
For high concurrency:
|
| 380 |
+
- Use load balancer (nginx, HAProxy)
|
| 381 |
+
- Deploy multiple Streamlit instances
|
| 382 |
+
- Use external cache (Redis) for shared state
|
| 383 |
+
|
| 384 |
+
---
|
| 385 |
+
|
| 386 |
+
## 🐛 Monitoring and Logging
|
| 387 |
+
|
| 388 |
+
### Log Aggregation
|
| 389 |
+
```bash
|
| 390 |
+
# View container logs
|
| 391 |
+
docker logs spatial-metabolic-atlas
|
| 392 |
+
|
| 393 |
+
# Or with Docker Compose
|
| 394 |
+
docker-compose logs -f
|
| 395 |
+
|
| 396 |
+
# System logs
|
| 397 |
+
tail -f logs/app.log
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
### Health Checks
|
| 401 |
+
```bash
|
| 402 |
+
# Kubernetes health probe
|
| 403 |
+
livenessProbe:
|
| 404 |
+
httpGet:
|
| 405 |
+
path: /_stcore/health
|
| 406 |
+
port: 8501
|
| 407 |
+
initialDelaySeconds: 30
|
| 408 |
+
periodSeconds: 10
|
| 409 |
+
```
|
| 410 |
+
|
| 411 |
+
---
|
| 412 |
+
|
| 413 |
+
## 📝 Maintenance
|
| 414 |
+
|
| 415 |
+
### Regular Updates
|
| 416 |
+
```bash
|
| 417 |
+
# Update Python packages
|
| 418 |
+
pip install --upgrade -r requirements.txt
|
| 419 |
+
|
| 420 |
+
# Docker image updates
|
| 421 |
+
docker pull spatial-metabolic-atlas:latest
|
| 422 |
+
docker-compose up -d
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
### Backup Procedure
|
| 426 |
+
```bash
|
| 427 |
+
# Backup analysis data
|
| 428 |
+
tar -czf backup-$(date +%Y%m%d).tar.gz cache/ uploads/
|
| 429 |
+
|
| 430 |
+
# Automated backup (cron)
|
| 431 |
+
0 2 * * * tar -czf /backups/backup-$(date +\%Y\%m\%d).tar.gz /app/cache
|
| 432 |
+
```
|
| 433 |
+
|
| 434 |
+
---
|
| 435 |
+
|
| 436 |
+
## 🆘 Troubleshooting
|
| 437 |
+
|
| 438 |
+
### Memory Issues
|
| 439 |
+
```bash
|
| 440 |
+
# Check memory usage
|
| 441 |
+
free -h
|
| 442 |
+
|
| 443 |
+
# Increase swap
|
| 444 |
+
sudo fallocate -l 4G /swapfile
|
| 445 |
+
sudo chmod 600 /swapfile
|
| 446 |
+
sudo mkswap /swapfile
|
| 447 |
+
sudo swapon /swapfile
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
+
### Port Already in Use
|
| 451 |
+
```bash
|
| 452 |
+
# Find process using port 8501
|
| 453 |
+
lsof -i :8501
|
| 454 |
+
|
| 455 |
+
# Kill process
|
| 456 |
+
kill -9 <PID>
|
| 457 |
+
```
|
| 458 |
+
|
| 459 |
+
### Connection Issues
|
| 460 |
+
```bash
|
| 461 |
+
# Check network connectivity
|
| 462 |
+
curl http://localhost:8501
|
| 463 |
+
|
| 464 |
+
# Check firewall
|
| 465 |
+
sudo ufw allow 8501
|
| 466 |
+
```
|
| 467 |
+
|
| 468 |
+
---
|
| 469 |
+
|
| 470 |
+
**Last Updated**: February 2024
|
| 471 |
+
**Version**: 1.0.0
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Base on Official Python 3.11.14 Slim
|
| 2 |
+
FROM python:3.11.14-slim-bullseye
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE 1
|
| 6 |
+
ENV PYTHONUNBUFFERED 1
|
| 7 |
+
ENV STREAMLIT_SERVER_PORT 7860
|
| 8 |
+
ENV STREAMLIT_SERVER_ADDRESS 0.0.0.0
|
| 9 |
+
|
| 10 |
+
# Install system dependencies
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 12 |
+
build-essential \
|
| 13 |
+
curl \
|
| 14 |
+
git \
|
| 15 |
+
libgl1-mesa-glx \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Set working directory
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
|
| 21 |
+
# Copy requirements and install
|
| 22 |
+
COPY requirements.txt .
|
| 23 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# Copy application code
|
| 26 |
+
COPY . .
|
| 27 |
+
|
| 28 |
+
# Expose port for Hugging Face Spaces
|
| 29 |
+
EXPOSE 7860
|
| 30 |
+
|
| 31 |
+
# Healthcheck
|
| 32 |
+
HEALTHCHECK CMD curl --fail http://localhost:7860/_stcore/health
|
| 33 |
+
|
| 34 |
+
# Run the app
|
| 35 |
+
CMD ["streamlit", "run", "app.py"]
|
PROJECT_SUMMARY.md
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Spatial Metabolic Atlas - Complete Project Summary
|
| 2 |
+
|
| 3 |
+
## 📋 Project Completion Overview
|
| 4 |
+
|
| 5 |
+
A complete, production-ready Streamlit application for spatial metabolic transcriptomics analysis using spMetaTME has been created. The application is modular, well-documented, and ready for research publication and deployment.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 📁 Complete File Structure
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
streamlit_app/
|
| 13 |
+
│
|
| 14 |
+
├── 📄 Main Application Files
|
| 15 |
+
│ ├── app.py # Main Streamlit entry point (700+ lines)
|
| 16 |
+
│ ├── requirements.txt # Python dependencies
|
| 17 |
+
│ ├── Dockerfile # Docker containerization
|
| 18 |
+
│ ├── docker-compose.yml # Docker Compose orchestration
|
| 19 |
+
│ └── examples.py # Programmatic usage examples
|
| 20 |
+
│
|
| 21 |
+
├── 📁 modules/ # Functional analysis modules
|
| 22 |
+
│ ├── __init__.py
|
| 23 |
+
│ ├── upload.py # File upload & validation (200+ lines)
|
| 24 |
+
│ │ └── AnnData loading, format validation, data summarization
|
| 25 |
+
│ │
|
| 26 |
+
│ ├── preprocessing.py # Data preprocessing (300+ lines)
|
| 27 |
+
│ │ └── QC filtering, normalization, HVG selection, log transform
|
| 28 |
+
│ │
|
| 29 |
+
│ ├── flux_analysis.py # spMetaTME flux inference (350+ lines)
|
| 30 |
+
│ │ └── Model loading, fine-tuning, flux computation, domain detection
|
| 31 |
+
│ │
|
| 32 |
+
│ ├── visualization.py # Interactive visualizations (600+ lines)
|
| 33 |
+
│ │ ├── Spatial flux maps
|
| 34 |
+
│ │ ├── UMAP embeddings
|
| 35 |
+
│ │ ├── Pathway analysis
|
| 36 |
+
│ │ ├── Domain statistics
|
| 37 |
+
│ │ └── Flux heatmaps
|
| 38 |
+
│ │
|
| 39 |
+
│ ├── interaction.py # Metabolic interaction analysis (400+ lines)
|
| 40 |
+
│ │ ├── TME interaction computation
|
| 41 |
+
│ │ ├── Interaction summary statistics
|
| 42 |
+
│ │ ├── Network visualization
|
| 43 |
+
│ │ └── Metabolite exchange analysis
|
| 44 |
+
│ │
|
| 45 |
+
│ ├── differential.py # Differential flux analysis (350+ lines)
|
| 46 |
+
│ │ ├── Domain-level comparison
|
| 47 |
+
│ │ ├── Custom group analysis
|
| 48 |
+
│ │ ├── Volcano plots
|
| 49 |
+
│ │ └── Ranked reaction identification
|
| 50 |
+
│ │
|
| 51 |
+
│ └── export.py # Results export (200+ lines)
|
| 52 |
+
│ └── HDF5, CSV, figure export
|
| 53 |
+
│
|
| 54 |
+
├── 📁 utils/ # Utility modules
|
| 55 |
+
│ ├── __init__.py
|
| 56 |
+
│ ├── plotting.py # Visualization helpers (250+ lines)
|
| 57 |
+
│ │ ├── Spatial flux mapping
|
| 58 |
+
│ │ ├── Domain heatmaps
|
| 59 |
+
│ │ ├── Pathway distributions
|
| 60 |
+
│ │ └── Volcano plots
|
| 61 |
+
│ │
|
| 62 |
+
│ └── flux_utils.py # Flux computation utilities (300+ lines)
|
| 63 |
+
│ ├── Pathway aggregation
|
| 64 |
+
│ ├── Exchange profiling
|
| 65 |
+
│ ├── Flux statistics
|
| 66 |
+
│ ├── Key reaction identification
|
| 67 |
+
│ ├── Differential analysis
|
| 68 |
+
│ └── Normalization
|
| 69 |
+
│
|
| 70 |
+
├── 📁 cache/ # Cache directory (created at runtime)
|
| 71 |
+
│ └── (Streamlit cache storage)
|
| 72 |
+
│
|
| 73 |
+
├── 📁 .streamlit/ # Streamlit configuration
|
| 74 |
+
│ └── config.toml # Streamlit settings
|
| 75 |
+
│
|
| 76 |
+
├── 📚 Documentation Files
|
| 77 |
+
│ ├── README.md # Comprehensive guide (1000+ lines)
|
| 78 |
+
│ │ ├── Features overview
|
| 79 |
+
│ │ ├── Quick start guide
|
| 80 |
+
│ │ ├── Detailed usage instructions
|
| 81 |
+
│ │ ├── Input requirements
|
| 82 |
+
│ │ └── Troubleshooting
|
| 83 |
+
│ │
|
| 84 |
+
│ ├── DEPLOYMENT.md # Deployment guide (500+ lines)
|
| 85 |
+
│ │ ├── Local development
|
| 86 |
+
│ │ ├── Docker deployment
|
| 87 |
+
│ │ ├── Streamlit Cloud
|
| 88 |
+
│ │ ├── AWS, GCP, Azure
|
| 89 |
+
│ │ ├── Kubernetes
|
| 90 |
+
│ │ └── Security & monitoring
|
| 91 |
+
│ │
|
| 92 |
+
│ ├── CONTRIBUTING.md # Developer guide (400+ lines)
|
| 93 |
+
│ │ ├── Development setup
|
| 94 |
+
│ │ ├── Code style guidelines
|
| 95 |
+
│ │ ├── Testing procedures
|
| 96 |
+
│ │ ├── Module creation guide
|
| 97 |
+
│ │ ├── Bug/feature templates
|
| 98 |
+
│ │ └── Release process
|
| 99 |
+
│ │
|
| 100 |
+
│ └── PROJECT_SUMMARY.md # This file
|
| 101 |
+
│
|
| 102 |
+
└── 📋 Configuration Files
|
| 103 |
+
├── .env.example # Environment variables template
|
| 104 |
+
└── .gitignore # Git ignore patterns
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## 📊 Code Statistics
|
| 110 |
+
|
| 111 |
+
| Component | Files | Lines | Purpose |
|
| 112 |
+
|-----------|-------|-------|---------|
|
| 113 |
+
| Main Application | 1 | 700 | Entry point, navigation |
|
| 114 |
+
| Modules | 7 | 2,700+ | Analysis features |
|
| 115 |
+
| Utilities | 2 | 550+ | Helper functions |
|
| 116 |
+
| Documentation | 3 | 2,500+ | Guides & references |
|
| 117 |
+
| Configuration | 6 | 200+ | Setup & environment |
|
| 118 |
+
| **Total** | **19** | **~8,650+** | Complete application |
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## 🎯 Feature Completeness Checklist
|
| 123 |
+
|
| 124 |
+
### ✅ Core Features (100% Complete)
|
| 125 |
+
- [x] File upload and validation
|
| 126 |
+
- [x] AnnData format support
|
| 127 |
+
- [x] Data quality inspection
|
| 128 |
+
- [x] Flexible preprocessing pipeline
|
| 129 |
+
- [x] spMetaTME flux inference
|
| 130 |
+
- [x] Spatial domain detection
|
| 131 |
+
- [x] Interactive visualizations
|
| 132 |
+
- [x] Metabolic interaction analysis
|
| 133 |
+
- [x] Differential flux analysis
|
| 134 |
+
- [x] Results export (HDF5, CSV, images)
|
| 135 |
+
|
| 136 |
+
### ✅ UI/UX Features (100% Complete)
|
| 137 |
+
- [x] Tabbed navigation system
|
| 138 |
+
- [x] Sidebar status indicators
|
| 139 |
+
- [x] Progress bars for long operations
|
| 140 |
+
- [x] Error handling and user feedback
|
| 141 |
+
- [x] Expandable sections for advanced options
|
| 142 |
+
- [x] Customizable visualization parameters
|
| 143 |
+
- [x] Publication-grade figure styling
|
| 144 |
+
|
| 145 |
+
### ✅ Advanced Features (100% Complete)
|
| 146 |
+
- [x] Caching system (@st.cache_data, @st.cache_resource)
|
| 147 |
+
- [x] Memory-efficient sparse matrix operations
|
| 148 |
+
- [x] Large dataset support (>50k spots)
|
| 149 |
+
- [x] Batch processing capability
|
| 150 |
+
- [x] Network visualization
|
| 151 |
+
- [x] Statistical testing (multiple methods)
|
| 152 |
+
- [x] Pathway aggregation
|
| 153 |
+
|
| 154 |
+
### ✅ Deployment Features (100% Complete)
|
| 155 |
+
- [x] Docker containerization
|
| 156 |
+
- [x] Docker Compose orchestration
|
| 157 |
+
- [x] Streamlit Cloud compatibility
|
| 158 |
+
- [x] Cloud provider guides (AWS, GCP, Azure)
|
| 159 |
+
- [x] Kubernetes deployment
|
| 160 |
+
- [x] Configuration management
|
| 161 |
+
- [x] Health checks
|
| 162 |
+
|
| 163 |
+
### ✅ Documentation (100% Complete)
|
| 164 |
+
- [x] Comprehensive README
|
| 165 |
+
- [x] API documentation
|
| 166 |
+
- [x] Usage examples
|
| 167 |
+
- [x] Deployment guide
|
| 168 |
+
- [x] Contributing guidelines
|
| 169 |
+
- [x] Troubleshooting section
|
| 170 |
+
- [x] Code comments and docstrings
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## 🚀 How to Use This Application
|
| 175 |
+
|
| 176 |
+
### Quick Start (5 minutes)
|
| 177 |
+
```bash
|
| 178 |
+
# 1. Install dependencies
|
| 179 |
+
cd streamlit_app
|
| 180 |
+
pip install -r requirements.txt
|
| 181 |
+
|
| 182 |
+
# 2. Run application
|
| 183 |
+
streamlit run app.py
|
| 184 |
+
|
| 185 |
+
# 3. Open browser
|
| 186 |
+
# Navigate to http://localhost:8501
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
### Full Workflow (30-60 minutes including computation)
|
| 190 |
+
1. **Upload Data** → Load your .h5ad spatial transcriptomics file
|
| 191 |
+
2. **Preprocess** → Filter cells, normalize, log-transform
|
| 192 |
+
3. **Run Flux Analysis** → Compute metabolic fluxes with spMetaTME
|
| 193 |
+
4. **Visualize** → Explore spatial patterns and domains
|
| 194 |
+
5. **Analyze** → Perform differential and interaction analysis
|
| 195 |
+
6. **Export** → Download results for publication
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## 📦 Key Dependencies
|
| 200 |
+
|
| 201 |
+
### Core Scientific
|
| 202 |
+
- **scanpy** (1.10+): Single-cell analysis
|
| 203 |
+
- **anndata** (0.9+): Data structure
|
| 204 |
+
- **spmetatme** (0.1+): Metabolic flux inference
|
| 205 |
+
- **numpy, scipy, pandas**: Computation
|
| 206 |
+
|
| 207 |
+
### Visualization
|
| 208 |
+
- **matplotlib**: Static plots
|
| 209 |
+
- **seaborn**: Statistical visualization
|
| 210 |
+
- **plotly**: Interactive plots
|
| 211 |
+
- **networkx, pyvis**: Network visualization
|
| 212 |
+
|
| 213 |
+
### Web Framework
|
| 214 |
+
- **streamlit** (1.28+): Web application
|
| 215 |
+
- **streamlit-option-menu**: Custom navigation
|
| 216 |
+
|
| 217 |
+
### Data I/O
|
| 218 |
+
- **h5py, openpyxl**: File formats
|
| 219 |
+
|
| 220 |
+
---
|
| 221 |
+
|
| 222 |
+
## 🔧 Customization Guide
|
| 223 |
+
|
| 224 |
+
### Adding a New Analysis Module
|
| 225 |
+
|
| 226 |
+
1. **Create module file** `modules/my_analysis.py`
|
| 227 |
+
2. **Implement render() function**
|
| 228 |
+
3. **Add to app.py navigation**
|
| 229 |
+
4. **Document in README.md**
|
| 230 |
+
|
| 231 |
+
Example:
|
| 232 |
+
```python
|
| 233 |
+
# modules/my_analysis.py
|
| 234 |
+
def render():
|
| 235 |
+
st.markdown("## 🆕 My Analysis")
|
| 236 |
+
# Implementation
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
### Modifying Visualizations
|
| 240 |
+
- Edit `utils/plotting.py` to add plotting functions
|
| 241 |
+
- Update `modules/visualization.py` to use them
|
| 242 |
+
- Customize colors, sizes, styles in `.streamlit/config.toml`
|
| 243 |
+
|
| 244 |
+
### Changing Default Parameters
|
| 245 |
+
- Edit default values in module files
|
| 246 |
+
- Or use `.env` file for environment-specific config
|
| 247 |
+
- See `.env.example` for template
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
## 💡 Best Practices Implemented
|
| 252 |
+
|
| 253 |
+
### Code Quality
|
| 254 |
+
✓ Type hints throughout
|
| 255 |
+
✓ Comprehensive docstrings
|
| 256 |
+
✓ Error handling and logging
|
| 257 |
+
✓ Meaningful variable names
|
| 258 |
+
✓ Modular architecture
|
| 259 |
+
|
| 260 |
+
### Performance
|
| 261 |
+
✓ Caching of expensive operations
|
| 262 |
+
✓ Lazy loading of modules
|
| 263 |
+
✓ Sparse matrix operations
|
| 264 |
+
✓ Memory-efficient numpy operations
|
| 265 |
+
✓ Batch processing support
|
| 266 |
+
|
| 267 |
+
### Security
|
| 268 |
+
✓ Input validation
|
| 269 |
+
✓ File size limits
|
| 270 |
+
✓ Error message sanitization
|
| 271 |
+
✓ Environment variable configuration
|
| 272 |
+
✓ No hardcoded secrets
|
| 273 |
+
|
| 274 |
+
### Maintainability
|
| 275 |
+
✓ Clear module separation
|
| 276 |
+
✓ Single responsibility principle
|
| 277 |
+
✓ DRY (Don't Repeat Yourself)
|
| 278 |
+
✓ Comprehensive documentation
|
| 279 |
+
✓ Example code provided
|
| 280 |
+
|
| 281 |
+
---
|
| 282 |
+
|
| 283 |
+
## 📈 Extension Points
|
| 284 |
+
|
| 285 |
+
The application is designed for easy extension:
|
| 286 |
+
|
| 287 |
+
### Add New Analysis Types
|
| 288 |
+
- Create new module in `modules/`
|
| 289 |
+
- Implement analysis functions in `utils/`
|
| 290 |
+
- Integrate into main app
|
| 291 |
+
|
| 292 |
+
### Add Visualization Methods
|
| 293 |
+
- Extend `utils/plotting.py`
|
| 294 |
+
- Create corresponding UI in modules
|
| 295 |
+
- Test with example data
|
| 296 |
+
|
| 297 |
+
### Support New Data Formats
|
| 298 |
+
- Extend `modules/upload.py` with new readers
|
| 299 |
+
- Create data conversion functions
|
| 300 |
+
- Document input requirements
|
| 301 |
+
|
| 302 |
+
### Add Statistical Tests
|
| 303 |
+
- Extend `utils/flux_utils.py` with new tests
|
| 304 |
+
- Integrate into differential analysis module
|
| 305 |
+
- Validate against reference implementations
|
| 306 |
+
|
| 307 |
+
---
|
| 308 |
+
|
| 309 |
+
## 🔬 Biological Features
|
| 310 |
+
|
| 311 |
+
The application supports comprehensive spatial metabolic analysis:
|
| 312 |
+
|
| 313 |
+
### Metabolic Analysis Types
|
| 314 |
+
- Flux inference across tissue
|
| 315 |
+
- Domain-level metabolic profiling
|
| 316 |
+
- Pathway-level aggregation
|
| 317 |
+
- Exchange reaction analysis
|
| 318 |
+
- Inter-cellular metabolic interactions
|
| 319 |
+
|
| 320 |
+
### Comparison Methods
|
| 321 |
+
- Domain vs domain
|
| 322 |
+
- Custom group comparisons
|
| 323 |
+
- Temporal/spatial gradients
|
| 324 |
+
- Disease phenotype comparisons
|
| 325 |
+
|
| 326 |
+
### Visualization Types
|
| 327 |
+
- Spatial maps with metabolic overlays
|
| 328 |
+
- UMAP metabolic phenotype clusters
|
| 329 |
+
- Pathway activity heatmaps
|
| 330 |
+
- Metabolite exchange networks
|
| 331 |
+
- Domain composition charts
|
| 332 |
+
|
| 333 |
+
---
|
| 334 |
+
|
| 335 |
+
## 📝 Files Generated by Application
|
| 336 |
+
|
| 337 |
+
When users run analysis, the following files are generated:
|
| 338 |
+
|
| 339 |
+
- `metabolic_adata.h5ad` - Processed data with fluxes
|
| 340 |
+
- `flux_matrix.csv` - Reaction flux matrix
|
| 341 |
+
- `cell_metadata.csv` - Cell annotations
|
| 342 |
+
- `reaction_info.csv` - Reaction metadata
|
| 343 |
+
- `differential_results.csv` - Significant reactions
|
| 344 |
+
- Various PNG/PDF plots
|
| 345 |
+
|
| 346 |
+
---
|
| 347 |
+
|
| 348 |
+
## 🎓 Learning Resources
|
| 349 |
+
|
| 350 |
+
For users learning to use the application:
|
| 351 |
+
- START with: README.md Quick Start section
|
| 352 |
+
- UNDERSTAND: Usage Guide in README
|
| 353 |
+
- EXPLORE: Example datasets and workflows
|
| 354 |
+
- EXTEND: See examples.py for programmatic usage
|
| 355 |
+
- DEPLOY: Follow DEPLOYMENT.md
|
| 356 |
+
|
| 357 |
+
For developers extending the application:
|
| 358 |
+
- READ: CONTRIBUTING.md
|
| 359 |
+
- REVIEW: Module structure and docstrings
|
| 360 |
+
- FOLLOW: Code style guidelines
|
| 361 |
+
- TEST: Add unit tests for new features
|
| 362 |
+
- DOCUMENT: Update README and docstrings
|
| 363 |
+
|
| 364 |
+
---
|
| 365 |
+
|
| 366 |
+
## ✨ Highlights
|
| 367 |
+
|
| 368 |
+
### What Makes This Application Special
|
| 369 |
+
|
| 370 |
+
1. **Production-Ready**: Not a prototype - ready for publication
|
| 371 |
+
2. **Well-Documented**: 2,500+ lines of documentation
|
| 372 |
+
3. **Fully Modular**: Easy to extend and maintain
|
| 373 |
+
4. **Performance-Optimized**: Caching, sparse operations, batching
|
| 374 |
+
5. **User-Friendly**: Clear UI, helpful error messages, progress indicators
|
| 375 |
+
6. **Research-Grade**: Publication-quality visualizations
|
| 376 |
+
7. **Deployable**: Docker, Cloud, Kubernetes support
|
| 377 |
+
8. **Tested Design**: Following software engineering best practices
|
| 378 |
+
9. **Extensive Examples**: Usage patterns for programmatic access
|
| 379 |
+
10. **Community-Ready**: Contributing guidelines and development setup
|
| 380 |
+
|
| 381 |
+
---
|
| 382 |
+
|
| 383 |
+
## 📞 Support & Next Steps
|
| 384 |
+
|
| 385 |
+
### For Users
|
| 386 |
+
1. Read README.md for getting started
|
| 387 |
+
2. Follow guided workflow in application
|
| 388 |
+
3. Consult troubleshooting section
|
| 389 |
+
4. Check DEPLOYMENT.md for cloud deployment
|
| 390 |
+
|
| 391 |
+
### For Developers
|
| 392 |
+
1. Review CONTRIBUTING.md
|
| 393 |
+
2. Follow code style guidelines
|
| 394 |
+
3. Write tests for new features
|
| 395 |
+
4. Update documentation
|
| 396 |
+
|
| 397 |
+
### For Researchers
|
| 398 |
+
1. Use application for spatial analysis
|
| 399 |
+
2. Generate publication plots
|
| 400 |
+
3. Export results (HDF5 + CSV)
|
| 401 |
+
4. Cite in publications
|
| 402 |
+
|
| 403 |
+
---
|
| 404 |
+
|
| 405 |
+
## 📄 Version & Metadata
|
| 406 |
+
|
| 407 |
+
- **Application Version**: 1.0.0
|
| 408 |
+
- **Python Version**: 3.10+
|
| 409 |
+
- **Streamlit Version**: 1.28+
|
| 410 |
+
- **Status**: Production Ready ✓
|
| 411 |
+
- **License**: MIT
|
| 412 |
+
- **Date Created**: February 2024
|
| 413 |
+
|
| 414 |
+
---
|
| 415 |
+
|
| 416 |
+
## 🎉 Summary
|
| 417 |
+
|
| 418 |
+
You now have a **complete, production-ready Streamlit application** for spatial metabolic analysis that is:
|
| 419 |
+
|
| 420 |
+
✅ **Feature-complete** with all 10 functional requirements
|
| 421 |
+
✅ **Well-documented** with 2,500+ lines of guides
|
| 422 |
+
✅ **Fully modular** and extensible architecture
|
| 423 |
+
✅ **Deployment-ready** with Docker and cloud support
|
| 424 |
+
✅ **Research-grade** with publication-ready outputs
|
| 425 |
+
✅ **Developer-friendly** with clear code and examples
|
| 426 |
+
|
| 427 |
+
The application is ready for:
|
| 428 |
+
- Research publication
|
| 429 |
+
- Cloud deployment
|
| 430 |
+
- Community contributions
|
| 431 |
+
- Extension with new features
|
| 432 |
+
|
| 433 |
+
**Total Deliverables**: 19 files covering application code, utilities, documentation, and configuration.
|
| 434 |
+
|
| 435 |
+
---
|
| 436 |
+
|
| 437 |
+
**Ready to analyze spatial metabolic transcriptomics data!** 🧬🗺️📊
|
app.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
# Configure Logging
|
| 5 |
+
logging.basicConfig(level=logging.INFO)
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
# Set Page Config
|
| 9 |
+
st.set_page_config(
|
| 10 |
+
page_title="spMetaTME Atlas",
|
| 11 |
+
page_icon=":material/hub:",
|
| 12 |
+
layout="wide",
|
| 13 |
+
initial_sidebar_state="expanded",
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Import UI Components and Pages
|
| 17 |
+
from src.ui.components.header import render_header, load_css
|
| 18 |
+
from src.ui.components.footer import render_footer
|
| 19 |
+
|
| 20 |
+
from src.ui.pages.overview import show_overview
|
| 21 |
+
from src.ui.pages.visualization import show_visualization
|
| 22 |
+
from src.ui.pages.preprocessing import show_preprocessing
|
| 23 |
+
from src.ui.pages.flux_analysis import show_flux_analysis
|
| 24 |
+
|
| 25 |
+
def init_session_state():
|
| 26 |
+
"""Initialise global session state."""
|
| 27 |
+
if "adata" not in st.session_state:
|
| 28 |
+
st.session_state.adata = None
|
| 29 |
+
if "metabolic_adata" not in st.session_state:
|
| 30 |
+
st.session_state.metabolic_adata = None
|
| 31 |
+
if "data_type" not in st.session_state:
|
| 32 |
+
st.session_state.data_type = None
|
| 33 |
+
if "preprocessing_done" not in st.session_state:
|
| 34 |
+
st.session_state.preprocessing_done = False
|
| 35 |
+
if "flux_analysis_done" not in st.session_state:
|
| 36 |
+
st.session_state.flux_analysis_done = False
|
| 37 |
+
if "interaction_scores" not in st.session_state:
|
| 38 |
+
st.session_state.interaction_scores = None
|
| 39 |
+
if "interaction_type" not in st.session_state:
|
| 40 |
+
st.session_state.interaction_type = None
|
| 41 |
+
|
| 42 |
+
# Pagination States
|
| 43 |
+
if "dataset_page" not in st.session_state:
|
| 44 |
+
st.session_state.dataset_page = 1
|
| 45 |
+
if "umap_page" not in st.session_state:
|
| 46 |
+
st.session_state.umap_page = 1
|
| 47 |
+
if "spatial_flux_page" not in st.session_state:
|
| 48 |
+
st.session_state.spatial_flux_page = 1
|
| 49 |
+
|
| 50 |
+
# Developer Mode
|
| 51 |
+
if "dev_mode" not in st.session_state:
|
| 52 |
+
st.session_state.dev_mode = True
|
| 53 |
+
|
| 54 |
+
def render_sidebar_dev():
|
| 55 |
+
"""Developer shortcuts in sidebar."""
|
| 56 |
+
with st.sidebar:
|
| 57 |
+
st.markdown("---")
|
| 58 |
+
st.session_state.dev_mode = st.checkbox("Developer Mode", value=st.session_state.dev_mode)
|
| 59 |
+
|
| 60 |
+
if st.session_state.dev_mode:
|
| 61 |
+
st.info("Dev Shortcuts Active")
|
| 62 |
+
if st.button("Load Breast Cancer Block A", use_container_width=True):
|
| 63 |
+
with st.spinner("Loading example data..."):
|
| 64 |
+
# Clear interaction cache for new tissue
|
| 65 |
+
for key in ['interaction_scores', 'interaction_type']:
|
| 66 |
+
if key in st.session_state:
|
| 67 |
+
del st.session_state[key]
|
| 68 |
+
import scanpy as sc
|
| 69 |
+
adata = sc.read_h5ad(r"example_data/metabolic_Breast_cancer_Block_A.h5ad")
|
| 70 |
+
if adata is not None:
|
| 71 |
+
st.session_state.metabolic_adata = adata
|
| 72 |
+
st.session_state.data_type = "metabolic"
|
| 73 |
+
# Set metadata if missing
|
| 74 |
+
if 'domain' not in adata.obs.columns and 'domain_id' in adata.obs.columns:
|
| 75 |
+
adata.obs['domain'] = adata.obs['domain_id']
|
| 76 |
+
st.success("Loaded Breast Cancer Block A (HF local cache)")
|
| 77 |
+
st.rerun()
|
| 78 |
+
|
| 79 |
+
def main():
|
| 80 |
+
load_css()
|
| 81 |
+
init_session_state()
|
| 82 |
+
# render_sidebar_dev()
|
| 83 |
+
|
| 84 |
+
# Simple routing
|
| 85 |
+
if st.session_state.metabolic_adata is not None:
|
| 86 |
+
show_visualization()
|
| 87 |
+
elif st.session_state.adata is not None:
|
| 88 |
+
if st.session_state.preprocessing_done:
|
| 89 |
+
show_flux_analysis()
|
| 90 |
+
else:
|
| 91 |
+
show_preprocessing()
|
| 92 |
+
else:
|
| 93 |
+
render_header()
|
| 94 |
+
show_overview()
|
| 95 |
+
|
| 96 |
+
render_footer()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
main()
|
assets/Logo.png
ADDED
|
assets/style.css
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Main container styling */
|
| 2 |
+
* {
|
| 3 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
:root {
|
| 7 |
+
--primary-red: #d32f2f;
|
| 8 |
+
/* Strong Material Red */
|
| 9 |
+
--light-red: #ffebee;
|
| 10 |
+
--hover-red: #ffcdd2;
|
| 11 |
+
--dark-red: #b71c1c;
|
| 12 |
+
--text-color: #333333;
|
| 13 |
+
--border-color: #e0e0e0;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.main {
|
| 17 |
+
background-color: #fffafb;
|
| 18 |
+
/* Very light red tint */
|
| 19 |
+
padding: 1rem;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* Main Header */
|
| 23 |
+
.main-header {
|
| 24 |
+
font-size: 2.5rem;
|
| 25 |
+
color: var(--primary-red);
|
| 26 |
+
margin-bottom: 1.5rem;
|
| 27 |
+
font-weight: 700;
|
| 28 |
+
letter-spacing: -0.5px;
|
| 29 |
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* Section Headers */
|
| 33 |
+
.section-header {
|
| 34 |
+
font-size: 1.8rem;
|
| 35 |
+
color: var(--primary-red);
|
| 36 |
+
margin-top: 1rem;
|
| 37 |
+
margin-bottom: 1.5rem;
|
| 38 |
+
font-weight: 600;
|
| 39 |
+
border-bottom: 3px solid var(--primary-red);
|
| 40 |
+
padding-bottom: 0.5rem;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* Info Boxes with Material Design Shadow */
|
| 44 |
+
.info-box {
|
| 45 |
+
background: linear-gradient(135deg, var(--light-red) 0%, #fffde7 100%);
|
| 46 |
+
padding: 1.5rem;
|
| 47 |
+
border-radius: 8px;
|
| 48 |
+
margin: 1rem 0;
|
| 49 |
+
border-left: 4px solid var(--primary-red);
|
| 50 |
+
box-shadow: 0 2px 8px rgba(211, 47, 47, 0.1);
|
| 51 |
+
transition: all 0.3s ease;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.info-box:hover {
|
| 55 |
+
box-shadow: 0 4px 16px rgba(211, 47, 47, 0.2);
|
| 56 |
+
transform: translateY(-2px);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Success Boxes */
|
| 60 |
+
.success-box {
|
| 61 |
+
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
| 62 |
+
padding: 1.5rem;
|
| 63 |
+
border-radius: 8px;
|
| 64 |
+
margin: 1rem 0;
|
| 65 |
+
border-left: 4px solid #2e7d32;
|
| 66 |
+
box-shadow: 0 2px 8px rgba(46, 125, 50, 0.1);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Card Styling for Material Design */
|
| 70 |
+
.material-card {
|
| 71 |
+
background: white;
|
| 72 |
+
border-radius: 12px;
|
| 73 |
+
padding: 1.5rem;
|
| 74 |
+
margin: 1rem 0;
|
| 75 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
| 76 |
+
transition: all 0.3s ease;
|
| 77 |
+
border: 1px solid var(--border-color);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.material-card:hover {
|
| 81 |
+
box-shadow: 0 8px 24px rgba(211, 47, 47, 0.1);
|
| 82 |
+
transform: translateY(-4px);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Button Styling */
|
| 86 |
+
.stButton>button {
|
| 87 |
+
border-radius: 8px;
|
| 88 |
+
padding: 0.6rem 1.8rem;
|
| 89 |
+
font-weight: 600;
|
| 90 |
+
background: white;
|
| 91 |
+
color: var(--primary-red);
|
| 92 |
+
border: 1px solid var(--primary-red);
|
| 93 |
+
transition: all 0.2s ease;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.stButton>button:hover {
|
| 97 |
+
background-color: var(--light-red);
|
| 98 |
+
border-color: var(--primary-red);
|
| 99 |
+
color: var(--primary-red);
|
| 100 |
+
box-shadow: 0 2px 8px rgba(211, 47, 47, 0.2);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* Tab Styling */
|
| 104 |
+
.stTabs [data-baseweb="tab-list"] {
|
| 105 |
+
gap: 15px;
|
| 106 |
+
border-bottom: 2px solid var(--border-color);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.stTabs [data-baseweb="tab"] {
|
| 110 |
+
border-radius: 8px 8px 0 0;
|
| 111 |
+
padding: 12px 24px;
|
| 112 |
+
font-weight: 600;
|
| 113 |
+
background-color: transparent;
|
| 114 |
+
border: none;
|
| 115 |
+
color: #666;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.stTabs [data-baseweb="tab"][aria-selected="true"] {
|
| 119 |
+
color: var(--primary-red);
|
| 120 |
+
border-bottom: 3px solid var(--primary-red);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* Sidebar Styling */
|
| 124 |
+
section[data-testid="stSidebar"] {
|
| 125 |
+
background-color: #ffffff;
|
| 126 |
+
border-right: 1px solid var(--border-color);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* Visualization Container */
|
| 130 |
+
.viz-container {
|
| 131 |
+
background: white;
|
| 132 |
+
border-radius: 16px;
|
| 133 |
+
padding: 2.5rem;
|
| 134 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
| 135 |
+
margin: 1.5rem 0;
|
| 136 |
+
border: 1px solid #f0f4f8;
|
| 137 |
+
}
|
modules/differential.py
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Differential Analysis Module
|
| 3 |
+
=============================
|
| 4 |
+
|
| 5 |
+
Differential flux analysis between metabolic domains/groups.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import streamlit as st
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import numpy as np
|
| 11 |
+
import matplotlib.pyplot as plt
|
| 12 |
+
import logging
|
| 13 |
+
from scipy import stats
|
| 14 |
+
from typing import Optional, List
|
| 15 |
+
from streamlit_option_menu import option_menu
|
| 16 |
+
import spmetatme.plotting as pl
|
| 17 |
+
import io
|
| 18 |
+
from datetime import datetime
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def display_plot_with_download(fig, plot_name: str = "plot"):
|
| 24 |
+
"""
|
| 25 |
+
Display a matplotlib figure with a PDF download button on top right.
|
| 26 |
+
|
| 27 |
+
Parameters
|
| 28 |
+
----------
|
| 29 |
+
fig : matplotlib.figure.Figure
|
| 30 |
+
The matplotlib figure to display and download
|
| 31 |
+
plot_name : str
|
| 32 |
+
Name for the downloaded file (without extension)
|
| 33 |
+
"""
|
| 34 |
+
# Create layout with download button on top right
|
| 35 |
+
col_space, col_download = st.columns([5.5, 0.5], gap="small")
|
| 36 |
+
|
| 37 |
+
with col_download:
|
| 38 |
+
# Generate PDF file
|
| 39 |
+
pdf_buffer = io.BytesIO()
|
| 40 |
+
fig.savefig(pdf_buffer, format='pdf', dpi=300, bbox_inches='tight')
|
| 41 |
+
file_data = pdf_buffer.getvalue()
|
| 42 |
+
|
| 43 |
+
st.download_button(
|
| 44 |
+
label="📥",
|
| 45 |
+
data=file_data,
|
| 46 |
+
file_name=f"{plot_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf",
|
| 47 |
+
mime="application/pdf",
|
| 48 |
+
key=f"download_{plot_name}_{id(fig)}",
|
| 49 |
+
help="Download as PDF",
|
| 50 |
+
use_container_width=False
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Display the plot
|
| 54 |
+
st.pyplot(fig)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def render():
|
| 58 |
+
"""Render differential analysis UI with sidebar menu."""
|
| 59 |
+
# Check if we have flux data
|
| 60 |
+
if st.session_state.metabolic_adata is None:
|
| 61 |
+
st.warning("⚠️ No flux data available")
|
| 62 |
+
st.markdown("""
|
| 63 |
+
Please:
|
| 64 |
+
1. **For spatial data**: Complete preprocessing and run flux analysis
|
| 65 |
+
2. **For pre-computed fluxes**: Upload your flux data in the Upload Data tab
|
| 66 |
+
""")
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
metabolic_adata = st.session_state.metabolic_adata
|
| 70 |
+
|
| 71 |
+
# Initialize selected differential page
|
| 72 |
+
if 'selected_diff_page' not in st.session_state:
|
| 73 |
+
st.session_state.selected_diff_page = "Differential Reactions"
|
| 74 |
+
|
| 75 |
+
# Define differential analysis options
|
| 76 |
+
diff_options = [
|
| 77 |
+
"Differential Reactions",
|
| 78 |
+
"Pathway Selection",
|
| 79 |
+
"Differential Pathways",
|
| 80 |
+
"Pathways by Variance"
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
diff_icons = [
|
| 84 |
+
"table",
|
| 85 |
+
"fire",
|
| 86 |
+
"diagram-3",
|
| 87 |
+
"graph-up"
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
# Get the current index
|
| 91 |
+
try:
|
| 92 |
+
current_index = diff_options.index(st.session_state.selected_diff_page)
|
| 93 |
+
except ValueError:
|
| 94 |
+
current_index = 0
|
| 95 |
+
st.session_state.selected_diff_page = "Differential Reactions"
|
| 96 |
+
|
| 97 |
+
# Sidebar menu for differential analysis selection
|
| 98 |
+
with st.sidebar:
|
| 99 |
+
selected_diff = option_menu(
|
| 100 |
+
menu_title="Differential Analysis",
|
| 101 |
+
options=diff_options,
|
| 102 |
+
icons=diff_icons,
|
| 103 |
+
default_index=current_index,
|
| 104 |
+
orientation="vertical",
|
| 105 |
+
styles={
|
| 106 |
+
"container": {"padding": "0!important", "background-color": "#ffffff"},
|
| 107 |
+
"icon": {"color": "#1a73e8", "font-size": "18px"},
|
| 108 |
+
"nav-link": {
|
| 109 |
+
"font-size": "12px",
|
| 110 |
+
"text-align": "left",
|
| 111 |
+
"margin": "0px",
|
| 112 |
+
"padding": "12px 15px",
|
| 113 |
+
"--hover-color": "#e3f2fd",
|
| 114 |
+
"color": "#333333"
|
| 115 |
+
},
|
| 116 |
+
"nav-link-selected": {
|
| 117 |
+
"background-color": "#1a73e8",
|
| 118 |
+
"color": "#ffffff",
|
| 119 |
+
"font-weight": "600"
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
key="diff_option_menu"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Only rerun if selection changed
|
| 126 |
+
if selected_diff != st.session_state.selected_diff_page:
|
| 127 |
+
st.session_state.selected_diff_page = selected_diff
|
| 128 |
+
st.rerun()
|
| 129 |
+
|
| 130 |
+
st.markdown("---")
|
| 131 |
+
|
| 132 |
+
# Back to home button in sidebar
|
| 133 |
+
if st.button("🏠 Back to Home", use_container_width=True, key="back_to_home_diff_sidebar"):
|
| 134 |
+
st.session_state.adata = None
|
| 135 |
+
st.session_state.metabolic_adata = None
|
| 136 |
+
st.session_state.data_type = None
|
| 137 |
+
st.session_state.preprocessing_done = False
|
| 138 |
+
st.session_state.flux_analysis_done = False
|
| 139 |
+
st.session_state.selected_diff_page = None
|
| 140 |
+
st.rerun()
|
| 141 |
+
|
| 142 |
+
st.markdown("---")
|
| 143 |
+
|
| 144 |
+
# Info section in sidebar
|
| 145 |
+
st.markdown("""
|
| 146 |
+
<div style='background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); padding: 1rem; border-radius: 8px; font-size: 0.85rem; line-height: 1.6; border-left: 3px solid #1a73e8;'>
|
| 147 |
+
<strong style='color: #1a73e8;'>📊 Differential Analysis</strong><br>
|
| 148 |
+
Identify metabolically distinct regions and enriched reactions across domains.
|
| 149 |
+
</div>
|
| 150 |
+
""", unsafe_allow_html=True)
|
| 151 |
+
|
| 152 |
+
# Main content area
|
| 153 |
+
st.markdown("## 📉 Differential Metabolic Flux Analysis")
|
| 154 |
+
|
| 155 |
+
st.markdown("""
|
| 156 |
+
Identify metabolic reactions and pathways with significant differences between
|
| 157 |
+
spatial domains and metabolic phenotypes.
|
| 158 |
+
""")
|
| 159 |
+
|
| 160 |
+
st.markdown("---")
|
| 161 |
+
|
| 162 |
+
# Render selected differential analysis page
|
| 163 |
+
if st.session_state.selected_diff_page == "Differential Reactions":
|
| 164 |
+
render_differential_reactions(metabolic_adata)
|
| 165 |
+
|
| 166 |
+
elif st.session_state.selected_diff_page == "Pathway Selection":
|
| 167 |
+
render_pathway_selection(metabolic_adata)
|
| 168 |
+
|
| 169 |
+
elif st.session_state.selected_diff_page == "Differential Pathways":
|
| 170 |
+
render_differential_pathways(metabolic_adata)
|
| 171 |
+
|
| 172 |
+
elif st.session_state.selected_diff_page == "Pathways by Variance":
|
| 173 |
+
render_pathways_by_variance(metabolic_adata)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def render_differential_reactions(metabolic_adata):
|
| 177 |
+
"""Render differential reactions analysis with tabs for different heatmap types."""
|
| 178 |
+
st.markdown("### Differential Metabolic Reactions Analysis")
|
| 179 |
+
|
| 180 |
+
st.markdown("""
|
| 181 |
+
Analyze differentially enriched metabolic reactions across spatial domains
|
| 182 |
+
using different visualization approaches.
|
| 183 |
+
""")
|
| 184 |
+
|
| 185 |
+
# Create tabs for different analysis types
|
| 186 |
+
tab1, tab2, tab3 = st.tabs([
|
| 187 |
+
"Pathway-Specific Reactions",
|
| 188 |
+
"All Differential Reactions",
|
| 189 |
+
"Pathways by Variance"
|
| 190 |
+
])
|
| 191 |
+
|
| 192 |
+
# TAB 1: Pathway-Specific Reactions (plot_differential_reactions_by_pathway_heatmap)
|
| 193 |
+
with tab1:
|
| 194 |
+
st.markdown("#### Pathway-Specific Differential Analysis")
|
| 195 |
+
|
| 196 |
+
if 'subsystems' not in metabolic_adata.var.columns:
|
| 197 |
+
st.error("Pathway information (subsystems) not found in data")
|
| 198 |
+
else:
|
| 199 |
+
available_pathways = sorted(metabolic_adata.var['subsystems'].unique().tolist())
|
| 200 |
+
|
| 201 |
+
# Controls
|
| 202 |
+
col1, col2, col3 = st.columns(3)
|
| 203 |
+
|
| 204 |
+
with col1:
|
| 205 |
+
selected_pathway = st.selectbox(
|
| 206 |
+
"Select pathway:",
|
| 207 |
+
options=available_pathways,
|
| 208 |
+
key="tab1_pathway_dropdown"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
with col2:
|
| 212 |
+
top_n_pathway = st.slider(
|
| 213 |
+
"Top N reactions",
|
| 214 |
+
min_value=5,
|
| 215 |
+
max_value=50,
|
| 216 |
+
value=15,
|
| 217 |
+
step=1,
|
| 218 |
+
key="tab1_pathway_top_n"
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
with col3:
|
| 222 |
+
row_cluster = st.checkbox("Cluster rows", value=True, key="tab1_row_cluster")
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
with st.spinner(f"Analyzing {selected_pathway}..."):
|
| 226 |
+
# Generate heatmap
|
| 227 |
+
df_pathway = pl.plot_differential_reactions_by_pathway_heatmap(
|
| 228 |
+
metabolic_adata,
|
| 229 |
+
selected_pathway,
|
| 230 |
+
row_cluster=row_cluster,
|
| 231 |
+
return_marker_df=True,
|
| 232 |
+
save_path=None,
|
| 233 |
+
top_n=top_n_pathway
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
fig = plt.gcf()
|
| 237 |
+
|
| 238 |
+
# Two-column layout: Heatmap and Table
|
| 239 |
+
col_plot, col_table = st.columns([1, 1], gap="large")
|
| 240 |
+
|
| 241 |
+
with col_plot:
|
| 242 |
+
display_plot_with_download(fig, f"{selected_pathway.replace(' ', '_')}_Heatmap")
|
| 243 |
+
|
| 244 |
+
with col_table:
|
| 245 |
+
st.write("")
|
| 246 |
+
st.markdown("##### Reactions Data")
|
| 247 |
+
if df_pathway is not None:
|
| 248 |
+
st.dataframe(df_pathway, use_container_width=True)
|
| 249 |
+
|
| 250 |
+
# Download button
|
| 251 |
+
csv = df_pathway.to_csv(index=False)
|
| 252 |
+
st.download_button(
|
| 253 |
+
label="📥 Download Table (CSV)",
|
| 254 |
+
data=csv,
|
| 255 |
+
file_name=f"pathway_{selected_pathway.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 256 |
+
mime="text/csv",
|
| 257 |
+
key="tab1_download_table"
|
| 258 |
+
)
|
| 259 |
+
else:
|
| 260 |
+
st.info("No data available")
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
st.error(f"Error: {str(e)}")
|
| 264 |
+
logger.error(f"Tab1 error: {str(e)}", exc_info=True)
|
| 265 |
+
|
| 266 |
+
# TAB 2: All Differential Reactions (plot_differential_reactions_heatmap)
|
| 267 |
+
with tab2:
|
| 268 |
+
st.markdown("#### All Differential Reactions Heatmap")
|
| 269 |
+
|
| 270 |
+
# Controls
|
| 271 |
+
col1, col2 = st.columns(2)
|
| 272 |
+
|
| 273 |
+
with col1:
|
| 274 |
+
top_n_reactions = st.slider(
|
| 275 |
+
"Top N reactions to show",
|
| 276 |
+
min_value=5,
|
| 277 |
+
max_value=100,
|
| 278 |
+
value=20,
|
| 279 |
+
step=5,
|
| 280 |
+
key="tab2_top_n_reactions"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
with col2:
|
| 284 |
+
st.write("") # Spacer
|
| 285 |
+
|
| 286 |
+
try:
|
| 287 |
+
with st.spinner("Analyzing all differential reactions..."):
|
| 288 |
+
# Generate heatmap
|
| 289 |
+
df_reactions = pl.plot_differential_reactions_heatmap(
|
| 290 |
+
metabolic_adata,
|
| 291 |
+
save_path=None,
|
| 292 |
+
top_n=top_n_reactions,
|
| 293 |
+
return_marker_df=True
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
fig = plt.gcf()
|
| 297 |
+
|
| 298 |
+
# Two-column layout: Heatmap and Table
|
| 299 |
+
col_plot, col_table = st.columns([1, 1], gap="large")
|
| 300 |
+
|
| 301 |
+
with col_plot:
|
| 302 |
+
display_plot_with_download(fig, "Differential_Reactions_Heatmap")
|
| 303 |
+
|
| 304 |
+
with col_table:
|
| 305 |
+
st.write("")
|
| 306 |
+
st.markdown("##### Reactions Data")
|
| 307 |
+
if df_reactions is not None:
|
| 308 |
+
st.dataframe(df_reactions, use_container_width=True)
|
| 309 |
+
|
| 310 |
+
# Download button
|
| 311 |
+
csv = df_reactions.to_csv(index=False)
|
| 312 |
+
st.download_button(
|
| 313 |
+
label="📥 Download Table (CSV)",
|
| 314 |
+
data=csv,
|
| 315 |
+
file_name=f"differential_reactions_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 316 |
+
mime="text/csv",
|
| 317 |
+
key="tab2_download_table"
|
| 318 |
+
)
|
| 319 |
+
else:
|
| 320 |
+
st.info("No data available")
|
| 321 |
+
|
| 322 |
+
except Exception as e:
|
| 323 |
+
st.error(f"Error: {str(e)}")
|
| 324 |
+
logger.error(f"Tab2 error: {str(e)}", exc_info=True)
|
| 325 |
+
|
| 326 |
+
# TAB 3: Pathways by Variance (plot_pathways_flux_heatmap)
|
| 327 |
+
with tab3:
|
| 328 |
+
st.markdown("#### Pathways by Variance")
|
| 329 |
+
|
| 330 |
+
# Controls
|
| 331 |
+
col1, col2, col3 = st.columns(3)
|
| 332 |
+
|
| 333 |
+
with col1:
|
| 334 |
+
top_n = st.slider(
|
| 335 |
+
"Top N pathways",
|
| 336 |
+
min_value=5,
|
| 337 |
+
max_value=30,
|
| 338 |
+
value=20,
|
| 339 |
+
step=1,
|
| 340 |
+
key="tab3_top_n"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
with col2:
|
| 344 |
+
sort_by = st.selectbox(
|
| 345 |
+
"Sort by",
|
| 346 |
+
options=["variance", "mean"],
|
| 347 |
+
key="tab3_sort_by"
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
with col3:
|
| 351 |
+
st.write("") # Spacer
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
try:
|
| 355 |
+
with st.spinner(f"Analyzing top {top_n} pathways by {sort_by}..."):
|
| 356 |
+
# Generate heatmap
|
| 357 |
+
df_pathways_var = pl.plot_pathways_flux_heatmap(
|
| 358 |
+
metabolic_adata,
|
| 359 |
+
group_key="domain",
|
| 360 |
+
pathway_key="subsystems",
|
| 361 |
+
top_n=top_n,
|
| 362 |
+
sort_by=sort_by
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
fig = plt.gcf()
|
| 366 |
+
|
| 367 |
+
# Two-column layout: Heatmap and Table
|
| 368 |
+
col_plot, col_table = st.columns([1, 1], gap="large")
|
| 369 |
+
|
| 370 |
+
with col_plot:
|
| 371 |
+
display_plot_with_download(fig, f"Pathways_Variance_Top{top_n}")
|
| 372 |
+
|
| 373 |
+
with col_table:
|
| 374 |
+
st.markdown("##### Pathways Data")
|
| 375 |
+
if df_pathways_var is not None:
|
| 376 |
+
st.dataframe(df_pathways_var, use_container_width=True)
|
| 377 |
+
|
| 378 |
+
# Download button
|
| 379 |
+
csv = df_pathways_var.to_csv(index=False)
|
| 380 |
+
st.download_button(
|
| 381 |
+
label="📥 Download Table (CSV)",
|
| 382 |
+
data=csv,
|
| 383 |
+
file_name=f"pathways_variance_top{top_n}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 384 |
+
mime="text/csv",
|
| 385 |
+
key="tab3_download_table"
|
| 386 |
+
)
|
| 387 |
+
else:
|
| 388 |
+
st.info("No data available")
|
| 389 |
+
|
| 390 |
+
except Exception as e:
|
| 391 |
+
st.error(f"Error: {str(e)}")
|
| 392 |
+
logger.error(f"Tab3 error: {str(e)}", exc_info=True)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def render_pathway_selection(metabolic_adata):
|
| 396 |
+
"""Render interactive pathway selection with dropdown for differential analysis."""
|
| 397 |
+
st.markdown("### Pathway-Specific Differential Analysis")
|
| 398 |
+
|
| 399 |
+
st.markdown("""
|
| 400 |
+
Select any metabolic pathway to investigate differential enrichment of reactions
|
| 401 |
+
within that pathway across spatial metabolic domains.
|
| 402 |
+
""")
|
| 403 |
+
|
| 404 |
+
# Get all available pathways
|
| 405 |
+
if 'subsystems' not in metabolic_adata.var.columns:
|
| 406 |
+
st.error("Pathway information (subsystems) not found in data")
|
| 407 |
+
return
|
| 408 |
+
|
| 409 |
+
available_pathways = sorted(metabolic_adata.var['subsystems'].unique().tolist())
|
| 410 |
+
|
| 411 |
+
# Pathway selection
|
| 412 |
+
col1, col2 = st.columns(2)
|
| 413 |
+
|
| 414 |
+
with col1:
|
| 415 |
+
selected_pathway = st.selectbox(
|
| 416 |
+
"Select pathway to analyze:",
|
| 417 |
+
options=available_pathways,
|
| 418 |
+
key="pathway_dropdown"
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
with col2:
|
| 422 |
+
top_n_pathway = st.slider(
|
| 423 |
+
"Top N reactions to display",
|
| 424 |
+
min_value=5,
|
| 425 |
+
max_value=50,
|
| 426 |
+
value=15,
|
| 427 |
+
step=1,
|
| 428 |
+
key="pathway_top_n"
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
# Analysis options
|
| 432 |
+
col1, col2, col3 = st.columns(3)
|
| 433 |
+
|
| 434 |
+
with col1:
|
| 435 |
+
row_cluster = st.checkbox("Cluster rows", value=True, key="pathway_row_cluster")
|
| 436 |
+
|
| 437 |
+
with col2:
|
| 438 |
+
show_table = st.checkbox("Show data table", value=True, key="pathway_show_table")
|
| 439 |
+
|
| 440 |
+
with col3:
|
| 441 |
+
show_stats = st.checkbox("Show statistics", value=True, key="pathway_show_stats")
|
| 442 |
+
|
| 443 |
+
if st.button(f"📊 Analyze {selected_pathway}", key="pathway_analyze_btn"):
|
| 444 |
+
try:
|
| 445 |
+
with st.spinner(f"Analyzing {selected_pathway}..."):
|
| 446 |
+
|
| 447 |
+
# Generate the heatmap
|
| 448 |
+
df_pathway = pl.plot_differential_reactions_by_pathway_heatmap(
|
| 449 |
+
metabolic_adata,
|
| 450 |
+
selected_pathway,
|
| 451 |
+
row_cluster=row_cluster,
|
| 452 |
+
return_marker_df=True,
|
| 453 |
+
save_path=None,
|
| 454 |
+
top_n=top_n_pathway
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
# Get the current figure
|
| 458 |
+
fig = plt.gcf()
|
| 459 |
+
|
| 460 |
+
st.success(f"✓ {selected_pathway} analysis completed!")
|
| 461 |
+
|
| 462 |
+
# Display with download option
|
| 463 |
+
display_plot_with_download(fig, f"Pathway_{selected_pathway.replace(' ', '_')}_Heatmap")
|
| 464 |
+
|
| 465 |
+
st.markdown("---")
|
| 466 |
+
|
| 467 |
+
# Display statistics if requested
|
| 468 |
+
if show_stats:
|
| 469 |
+
col1, col2, col3 = st.columns(3)
|
| 470 |
+
|
| 471 |
+
with col1:
|
| 472 |
+
reactions_in_pathway = len(df_pathway) if df_pathway is not None else 0
|
| 473 |
+
st.metric("Reactions in Pathway", reactions_in_pathway)
|
| 474 |
+
|
| 475 |
+
with col2:
|
| 476 |
+
if 'domain' in metabolic_adata.obs.columns:
|
| 477 |
+
n_domains = metabolic_adata.obs['domain'].nunique()
|
| 478 |
+
st.metric("Number of Domains", n_domains)
|
| 479 |
+
|
| 480 |
+
with col3:
|
| 481 |
+
st.metric("Spatial Spots", metabolic_adata.n_obs)
|
| 482 |
+
|
| 483 |
+
st.markdown("---")
|
| 484 |
+
|
| 485 |
+
# Show data table if requested
|
| 486 |
+
if show_table and df_pathway is not None:
|
| 487 |
+
st.markdown(f"#### {selected_pathway} - Reactions Data")
|
| 488 |
+
st.dataframe(df_pathway, use_container_width=True)
|
| 489 |
+
|
| 490 |
+
# Download button for table
|
| 491 |
+
csv = df_pathway.to_csv(index=False)
|
| 492 |
+
st.download_button(
|
| 493 |
+
label="📥 Download Table (CSV)",
|
| 494 |
+
data=csv,
|
| 495 |
+
file_name=f"pathway_{selected_pathway.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 496 |
+
mime="text/csv",
|
| 497 |
+
key="download_pathway_table"
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
st.info(f"💡 Tip: This heatmap shows the {top_n_pathway} most differential reactions in the {selected_pathway} pathway")
|
| 501 |
+
|
| 502 |
+
except Exception as e:
|
| 503 |
+
st.error(f"Error analyzing {selected_pathway}: {str(e)}")
|
| 504 |
+
logger.error(f"Pathway selection error for {selected_pathway}: {str(e)}", exc_info=True)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def render_differential_pathways(metabolic_adata):
|
| 509 |
+
"""Render differential pathways heatmap (top N pathways)."""
|
| 510 |
+
st.markdown("### Differential Pathways Heatmap")
|
| 511 |
+
|
| 512 |
+
st.markdown("""
|
| 513 |
+
This visualization shows metabolic pathways with the largest differences
|
| 514 |
+
in mean flux between spatial domains. Each pathway is aggregated from its constituent reactions.
|
| 515 |
+
""")
|
| 516 |
+
|
| 517 |
+
# Options
|
| 518 |
+
col1, col2 = st.columns(2)
|
| 519 |
+
|
| 520 |
+
with col1:
|
| 521 |
+
top_n_pathways = st.slider(
|
| 522 |
+
"Number of top pathways to show",
|
| 523 |
+
min_value=5,
|
| 524 |
+
max_value=20,
|
| 525 |
+
value=15,
|
| 526 |
+
step=1,
|
| 527 |
+
key="diff_pathway_top_n"
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
with col2:
|
| 531 |
+
show_table = st.checkbox("Show data table", value=True, key="diff_pathway_show_table")
|
| 532 |
+
|
| 533 |
+
if st.button("📊 Generate Differential Pathways Heatmap", key="diff_pathway_btn"):
|
| 534 |
+
try:
|
| 535 |
+
with st.spinner("Generating differential pathways heatmap..."):
|
| 536 |
+
|
| 537 |
+
# Generate the heatmap
|
| 538 |
+
fig = plt.figure(figsize=(14, 10))
|
| 539 |
+
df_pathways = pl.plot_differential_pathways_heatmap(
|
| 540 |
+
metabolic_adata,
|
| 541 |
+
save_path=None,
|
| 542 |
+
top_n=top_n_pathways
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# Get the current figure
|
| 546 |
+
fig = plt.gcf()
|
| 547 |
+
|
| 548 |
+
st.success("✓ Differential pathways heatmap generated successfully!")
|
| 549 |
+
|
| 550 |
+
# Display with download option
|
| 551 |
+
display_plot_with_download(fig, "Differential_Pathways_Heatmap")
|
| 552 |
+
|
| 553 |
+
st.markdown("---")
|
| 554 |
+
|
| 555 |
+
# Display statistics
|
| 556 |
+
col1, col2, col3 = st.columns(3)
|
| 557 |
+
|
| 558 |
+
with col1:
|
| 559 |
+
st.metric("Top Pathways Shown", top_n_pathways)
|
| 560 |
+
|
| 561 |
+
with col2:
|
| 562 |
+
if 'domain' in metabolic_adata.obs.columns:
|
| 563 |
+
n_domains = metabolic_adata.obs['domain'].nunique()
|
| 564 |
+
st.metric("Number of Domains", n_domains)
|
| 565 |
+
|
| 566 |
+
with col3:
|
| 567 |
+
if 'subsystems' in metabolic_adata.var.columns:
|
| 568 |
+
n_pathways = metabolic_adata.var['subsystems'].nunique()
|
| 569 |
+
st.metric("Total Pathways", n_pathways)
|
| 570 |
+
|
| 571 |
+
st.info("💡 Tip: Pathways ranked by the sum of absolute flux differences across domains")
|
| 572 |
+
|
| 573 |
+
# Show data table if requested
|
| 574 |
+
if show_table and df_pathways is not None:
|
| 575 |
+
st.markdown("---")
|
| 576 |
+
st.markdown("#### Differential Pathways Data")
|
| 577 |
+
st.dataframe(df_pathways, use_container_width=True)
|
| 578 |
+
|
| 579 |
+
# Download button for table
|
| 580 |
+
csv = df_pathways.to_csv(index=False)
|
| 581 |
+
st.download_button(
|
| 582 |
+
label="📥 Download Table (CSV)",
|
| 583 |
+
data=csv,
|
| 584 |
+
file_name=f"differential_pathways_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 585 |
+
mime="text/csv",
|
| 586 |
+
key="download_diff_pathways_table"
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
except Exception as e:
|
| 590 |
+
st.error(f"Error generating differential pathways heatmap: {str(e)}")
|
| 591 |
+
logger.error(f"Differential pathways error: {str(e)}", exc_info=True)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def render_pathways_by_variance(metabolic_adata):
|
| 595 |
+
"""Render pathways ranked by variance (top N)."""
|
| 596 |
+
st.markdown("### Pathways by Variance")
|
| 597 |
+
|
| 598 |
+
st.markdown("""
|
| 599 |
+
This visualization shows metabolic pathways with the highest variance
|
| 600 |
+
in flux values across the tissue. High variance indicates heterogeneous metabolic activity
|
| 601 |
+
and potential metabolic specialization across domains.
|
| 602 |
+
""")
|
| 603 |
+
|
| 604 |
+
# Options
|
| 605 |
+
col1, col2, col3 = st.columns(3)
|
| 606 |
+
|
| 607 |
+
with col1:
|
| 608 |
+
top_n = st.slider(
|
| 609 |
+
"Number of pathways to show",
|
| 610 |
+
min_value=5,
|
| 611 |
+
max_value=30,
|
| 612 |
+
value=20,
|
| 613 |
+
step=1,
|
| 614 |
+
key="pathway_variance_n"
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
with col2:
|
| 618 |
+
sort_by = st.selectbox(
|
| 619 |
+
"Sort by",
|
| 620 |
+
options=["variance", "mean"],
|
| 621 |
+
key="pathway_sort_by"
|
| 622 |
+
)
|
| 623 |
+
|
| 624 |
+
with col3:
|
| 625 |
+
show_table = st.checkbox("Show data table", value=True, key="pathway_var_show_table")
|
| 626 |
+
|
| 627 |
+
if st.button("📊 Generate Pathways by Variance Heatmap", key="pathway_var_btn"):
|
| 628 |
+
try:
|
| 629 |
+
with st.spinner(f"Generating top {top_n} pathways by {sort_by} heatmap..."):
|
| 630 |
+
|
| 631 |
+
# Generate the heatmap
|
| 632 |
+
fig = plt.figure(figsize=(14, 10))
|
| 633 |
+
df_pathways_var = pl.plot_pathways_flux_heatmap(
|
| 634 |
+
metabolic_adata,
|
| 635 |
+
group_key="domain",
|
| 636 |
+
pathway_key="subsystems",
|
| 637 |
+
top_n=top_n,
|
| 638 |
+
sort_by=sort_by
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
+
# Get the current figure
|
| 642 |
+
fig = plt.gcf()
|
| 643 |
+
|
| 644 |
+
st.success(f"✓ Pathways by {sort_by} heatmap generated successfully!")
|
| 645 |
+
|
| 646 |
+
# Display with download option
|
| 647 |
+
display_plot_with_download(fig, f"Pathways_Variance_Top{top_n}")
|
| 648 |
+
|
| 649 |
+
st.markdown("---")
|
| 650 |
+
|
| 651 |
+
# Display statistics
|
| 652 |
+
col1, col2, col3 = st.columns(3)
|
| 653 |
+
|
| 654 |
+
with col1:
|
| 655 |
+
st.metric("Top Pathways Shown", top_n)
|
| 656 |
+
|
| 657 |
+
with col2:
|
| 658 |
+
st.metric("Sort Metric", sort_by.capitalize())
|
| 659 |
+
|
| 660 |
+
with col3:
|
| 661 |
+
if 'domain' in metabolic_adata.obs.columns:
|
| 662 |
+
n_domains = metabolic_adata.obs['domain'].nunique()
|
| 663 |
+
st.metric("Number of Domains", n_domains)
|
| 664 |
+
|
| 665 |
+
st.info(f"💡 Tip: Shows {top_n} most variable pathways across spatial domains, highlighting metabolic hotspots")
|
| 666 |
+
|
| 667 |
+
# Show data table if requested
|
| 668 |
+
if show_table and df_pathways_var is not None:
|
| 669 |
+
st.markdown("---")
|
| 670 |
+
st.markdown(f"#### Top {top_n} Pathways by {sort_by.title()}")
|
| 671 |
+
st.dataframe(df_pathways_var, use_container_width=True)
|
| 672 |
+
|
| 673 |
+
# Download button for table
|
| 674 |
+
csv = df_pathways_var.to_csv(index=False)
|
| 675 |
+
st.download_button(
|
| 676 |
+
label="📥 Download Table (CSV)",
|
| 677 |
+
data=csv,
|
| 678 |
+
file_name=f"pathways_variance_top{top_n}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 679 |
+
mime="text/csv",
|
| 680 |
+
key="download_pathways_var_table"
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
except Exception as e:
|
| 684 |
+
st.error(f"Error generating pathways by variance heatmap: {str(e)}")
|
| 685 |
+
logger.error(f"Pathways by variance error: {str(e)}", exc_info=True)
|
requirements.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
streamlit>=1.31.0
|
| 3 |
+
streamlit-option-menu
|
| 4 |
+
huggingface_hub
|
| 5 |
+
datasets
|
| 6 |
+
|
| 7 |
+
# Data science and analysis
|
| 8 |
+
numpy>=1.24.0
|
| 9 |
+
pandas>=2.0.0
|
| 10 |
+
scipy>=1.10.0
|
| 11 |
+
scikit-learn>=1.2.0
|
| 12 |
+
|
| 13 |
+
# Bioinformatics
|
| 14 |
+
scanpy>=1.10.0
|
| 15 |
+
anndata>=0.10.0
|
| 16 |
+
|
| 17 |
+
# Visualization
|
| 18 |
+
matplotlib>=3.7.0
|
| 19 |
+
seaborn>=0.12.0
|
| 20 |
+
plotly>=5.15.0
|
| 21 |
+
networkx>=3.0
|
| 22 |
+
pyvis>=0.3.1
|
| 23 |
+
|
| 24 |
+
# Data I/O
|
| 25 |
+
h5py>=3.8.0
|
| 26 |
+
|
| 27 |
+
# Dependencies for spMetaTME (if not already installed)
|
| 28 |
+
# git+https://github.com/SurajRepo/spMetaTME.git@multi_sample
|
src/backend/data_loader.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import scanpy as sc
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from huggingface_hub import hf_hub_download, snapshot_download
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
REPO_ID = 'Angione-Lab/spMetaTME-Atlas'
|
| 13 |
+
|
| 14 |
+
@st.cache_resource
|
| 15 |
+
def get_metadata():
|
| 16 |
+
"""Fetch and cache metadata from Hugging Face."""
|
| 17 |
+
try:
|
| 18 |
+
return pd.read_csv(f"hf://datasets/{REPO_ID}/sp_metabolic_metadata.csv")
|
| 19 |
+
# return pd.read_csv("sp_metabolic_metadata.csv")
|
| 20 |
+
except Exception as e:
|
| 21 |
+
logger.error(f"Error loading metadata: {e}")
|
| 22 |
+
return pd.DataFrame()
|
| 23 |
+
|
| 24 |
+
def get_organ_stats(meta_df: pd.DataFrame):
|
| 25 |
+
"""Calculate summary statistics for organs from metadata."""
|
| 26 |
+
if meta_df.empty:
|
| 27 |
+
return pd.DataFrame()
|
| 28 |
+
|
| 29 |
+
# Check if necessary columns exist
|
| 30 |
+
if 'organ' not in meta_df.columns:
|
| 31 |
+
return pd.DataFrame()
|
| 32 |
+
|
| 33 |
+
# Try to find a column for reaction count
|
| 34 |
+
count_col = 'n_vars' if 'n_vars' in meta_df.columns else ('n_genes' if 'n_genes' in meta_df.columns else None)
|
| 35 |
+
|
| 36 |
+
# Basic aggregation
|
| 37 |
+
stats = meta_df.groupby('organ').agg(
|
| 38 |
+
sample_count=('id', 'count') if 'id' in meta_df.columns else ('dataset_title', 'count')
|
| 39 |
+
).reset_index()
|
| 40 |
+
|
| 41 |
+
# Add average reactions if column exists
|
| 42 |
+
if count_col:
|
| 43 |
+
avg_stats = meta_df.groupby('organ')[count_col].mean().reset_index()
|
| 44 |
+
avg_stats.columns = ['organ', 'avg_reactions']
|
| 45 |
+
stats = stats.merge(avg_stats, on='organ')
|
| 46 |
+
else:
|
| 47 |
+
stats['avg_reactions'] = 0
|
| 48 |
+
|
| 49 |
+
# Sort by sample count descending
|
| 50 |
+
stats = stats.sort_values('sample_count', ascending=False)
|
| 51 |
+
return stats
|
| 52 |
+
|
| 53 |
+
@st.cache_data
|
| 54 |
+
def load_metabolic_flux_from_hf(filename: str):
|
| 55 |
+
"""
|
| 56 |
+
Load spatial metabolic flux data from Hugging Face Hub with caching.
|
| 57 |
+
"""
|
| 58 |
+
# Priority to local example data for faster dev cycle
|
| 59 |
+
example_path = os.path.join(os.getcwd(), "example_data", filename)
|
| 60 |
+
if os.path.exists(example_path):
|
| 61 |
+
try:
|
| 62 |
+
adata = sc.read_h5ad(example_path)
|
| 63 |
+
logger.info(f"Loaded {filename} from local example_data folder.")
|
| 64 |
+
return adata
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.warning(f"Could not load local {filename}: {e}. Retrying HF.")
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
local_path = hf_hub_download(
|
| 70 |
+
repo_id=REPO_ID,
|
| 71 |
+
filename=f"SM/{filename}",
|
| 72 |
+
repo_type="dataset"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
adata = sc.read_h5ad(local_path)
|
| 76 |
+
return adata
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(f"Error loading {filename}: {str(e)}")
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
def download_metabolic_flux_from_hf(filename: str, local_dir: Optional[str] = None):
|
| 82 |
+
"""
|
| 83 |
+
Download spatial metabolic flux file from Hugging Face Hub to local directory.
|
| 84 |
+
"""
|
| 85 |
+
try:
|
| 86 |
+
if local_dir is None:
|
| 87 |
+
local_dir = os.path.expanduser("~/Downloads/spMetaTME-Atlas")
|
| 88 |
+
|
| 89 |
+
os.makedirs(local_dir, exist_ok=True)
|
| 90 |
+
|
| 91 |
+
snapshot_download(
|
| 92 |
+
repo_id=REPO_ID,
|
| 93 |
+
allow_patterns=[f"SM/{filename}"],
|
| 94 |
+
repo_type="dataset",
|
| 95 |
+
local_dir=local_dir
|
| 96 |
+
)
|
| 97 |
+
return local_dir
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Error downloading {filename}: {str(e)}")
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
def process_upload(uploaded_file, data_type: str):
|
| 103 |
+
"""
|
| 104 |
+
Process uploaded file and return AnnData object.
|
| 105 |
+
"""
|
| 106 |
+
try:
|
| 107 |
+
import tempfile
|
| 108 |
+
# Save uploaded file to temp location
|
| 109 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".h5ad") as tmp:
|
| 110 |
+
tmp.write(uploaded_file.getvalue())
|
| 111 |
+
temp_path = tmp.name
|
| 112 |
+
|
| 113 |
+
adata = sc.read_h5ad(temp_path)
|
| 114 |
+
# Clean up temp file
|
| 115 |
+
os.unlink(temp_path)
|
| 116 |
+
return adata
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Error loading {data_type} file: {str(e)}")
|
| 119 |
+
return None
|
src/backend/flux_analysis.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
logger = logging.getLogger(__name__)
|
| 5 |
+
|
| 6 |
+
def run_smt_inference(adata, model_name, K, batch_size, n_clusters, clustering_method, use_pretrained=True, fine_tune=True, n_epochs=10):
|
| 7 |
+
"""
|
| 8 |
+
Backend logic for running SpMetaTME inference.
|
| 9 |
+
"""
|
| 10 |
+
try:
|
| 11 |
+
from spmetatme.train import SpMetaTME
|
| 12 |
+
from spmetatme.data.dataloader import MetabolicDataLoader
|
| 13 |
+
from spmetatme.data.metabolic_model import get_model_path
|
| 14 |
+
except ImportError:
|
| 15 |
+
logger.error("spMetaTME package not found")
|
| 16 |
+
raise ImportError("spMetaTME package not found. Install with: pip install spmetatme")
|
| 17 |
+
|
| 18 |
+
metabolic_path = get_model_path(model_name)
|
| 19 |
+
data_loader = MetabolicDataLoader(
|
| 20 |
+
adata,
|
| 21 |
+
metabolic_model_path=metabolic_path,
|
| 22 |
+
k=K,
|
| 23 |
+
batch_size=batch_size,
|
| 24 |
+
preprocess=False
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
smt = SpMetaTME()
|
| 28 |
+
if use_pretrained:
|
| 29 |
+
smt.load_pretrained_model("Surajv/spMetaTME-human_64D_v1")
|
| 30 |
+
|
| 31 |
+
if fine_tune:
|
| 32 |
+
smt.fine_tune(data_loader, epochs=n_epochs)
|
| 33 |
+
|
| 34 |
+
metabolic_adata = smt.infer_flux(
|
| 35 |
+
data_loader,
|
| 36 |
+
n_clusters=n_clusters,
|
| 37 |
+
method=clustering_method
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
return metabolic_adata
|
src/backend/flux_distribution.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Backend helpers for flux distribution analysis across domains.
|
| 3 |
+
Provides:
|
| 4 |
+
- adata_to_long_df : tidy long-format DataFrame from AnnData
|
| 5 |
+
- compute_domain_stats: Welch t-tests + FDR correction per (reaction, domain)
|
| 6 |
+
- p_to_star : p-value -> significance star string
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from scipy.stats import ttest_ind
|
| 12 |
+
from scipy.sparse import issparse
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
from statsmodels.stats.multitest import multipletests
|
| 16 |
+
_HAS_STATSMODELS = True
|
| 17 |
+
except ImportError:
|
| 18 |
+
_HAS_STATSMODELS = False
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Core helpers
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
def p_to_star(p: float) -> str:
|
| 26 |
+
"""Convert a p-value to a significance annotation string."""
|
| 27 |
+
if p < 1e-4:
|
| 28 |
+
return "****"
|
| 29 |
+
elif p < 1e-3:
|
| 30 |
+
return "***"
|
| 31 |
+
elif p < 1e-2:
|
| 32 |
+
return "**"
|
| 33 |
+
elif p < 0.05:
|
| 34 |
+
return "*"
|
| 35 |
+
return "ns"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def adata_to_long_df(adata, reactions=None) -> pd.DataFrame:
|
| 39 |
+
"""
|
| 40 |
+
Convert an AnnData object to a tidy long-format DataFrame.
|
| 41 |
+
|
| 42 |
+
Parameters
|
| 43 |
+
----------
|
| 44 |
+
adata : AnnData
|
| 45 |
+
Must have obs['domain'] and (optionally) obs['condition'].
|
| 46 |
+
reactions : list[str] | None
|
| 47 |
+
Subset of adata.var_names to include. None = all reactions.
|
| 48 |
+
|
| 49 |
+
Returns
|
| 50 |
+
-------
|
| 51 |
+
pd.DataFrame with columns: spot, domain, condition, reaction, flux
|
| 52 |
+
"""
|
| 53 |
+
if reactions is None:
|
| 54 |
+
reactions = adata.var_names.tolist()
|
| 55 |
+
else:
|
| 56 |
+
reactions = [r for r in reactions if r in adata.var_names]
|
| 57 |
+
|
| 58 |
+
sub = adata[:, reactions]
|
| 59 |
+
X = sub.X.toarray() if issparse(sub.X) else np.array(sub.X)
|
| 60 |
+
|
| 61 |
+
df = pd.DataFrame(X, columns=reactions, index=sub.obs_names)
|
| 62 |
+
df["domain"] = sub.obs["domain"].astype(str).values
|
| 63 |
+
df["condition"] = sub.obs.get("condition", pd.Series("all", index=sub.obs_names)).astype(str).values
|
| 64 |
+
|
| 65 |
+
long = df.melt(
|
| 66 |
+
id_vars=["domain", "condition"],
|
| 67 |
+
var_name="reaction",
|
| 68 |
+
value_name="flux"
|
| 69 |
+
)
|
| 70 |
+
return long
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def compute_domain_stats(df_long: pd.DataFrame) -> pd.DataFrame:
|
| 74 |
+
"""
|
| 75 |
+
Welch t-test for each (reaction, domain) pair between the two conditions.
|
| 76 |
+
Applies FDR-BH correction across all tests.
|
| 77 |
+
|
| 78 |
+
Returns a DataFrame with columns:
|
| 79 |
+
reaction, domain, pvalue, p_adj, signif
|
| 80 |
+
"""
|
| 81 |
+
results = []
|
| 82 |
+
for (rxn, dom), sub in df_long.groupby(["reaction", "domain"]):
|
| 83 |
+
conds = sub["condition"].unique()
|
| 84 |
+
if len(conds) != 2:
|
| 85 |
+
continue
|
| 86 |
+
g1 = sub[sub["condition"] == conds[0]]["flux"].dropna()
|
| 87 |
+
g2 = sub[sub["condition"] == conds[1]]["flux"].dropna()
|
| 88 |
+
if len(g1) < 2 or len(g2) < 2:
|
| 89 |
+
continue
|
| 90 |
+
stat, p = ttest_ind(g1, g2, equal_var=False, nan_policy="omit")
|
| 91 |
+
results.append({"reaction": rxn, "domain": dom, "pvalue": p})
|
| 92 |
+
|
| 93 |
+
if not results:
|
| 94 |
+
return pd.DataFrame(columns=["reaction", "domain", "pvalue", "p_adj", "signif"])
|
| 95 |
+
|
| 96 |
+
ttest_df = pd.DataFrame(results)
|
| 97 |
+
|
| 98 |
+
if _HAS_STATSMODELS:
|
| 99 |
+
ttest_df["p_adj"] = multipletests(ttest_df["pvalue"], method="fdr_bh")[1]
|
| 100 |
+
else:
|
| 101 |
+
ttest_df["p_adj"] = ttest_df["pvalue"] # fallback: no correction
|
| 102 |
+
|
| 103 |
+
ttest_df["signif"] = ttest_df["p_adj"].apply(p_to_star)
|
| 104 |
+
return ttest_df
|
src/backend/flux_utils.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional, Dict, List
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
def aggregate_flux_by_pathway(adata, pathway_col: str = "subsystems", aggregation: str = "mean") -> pd.DataFrame:
|
| 9 |
+
"""Aggregate reaction fluxes by metabolic pathway."""
|
| 10 |
+
if pathway_col not in adata.var.columns:
|
| 11 |
+
return pd.DataFrame()
|
| 12 |
+
|
| 13 |
+
pathways = adata.var[pathway_col].unique()
|
| 14 |
+
pathway_fluxes = []
|
| 15 |
+
|
| 16 |
+
for pathway in pathways:
|
| 17 |
+
if pd.isna(pathway): continue
|
| 18 |
+
mask = adata.var[pathway_col] == pathway
|
| 19 |
+
pathway_flux = adata.X[:, mask]
|
| 20 |
+
|
| 21 |
+
if aggregation == "mean":
|
| 22 |
+
aggregated = np.mean(pathway_flux, axis=1)
|
| 23 |
+
elif aggregation == "sum":
|
| 24 |
+
aggregated = np.sum(pathway_flux, axis=1)
|
| 25 |
+
else:
|
| 26 |
+
aggregated = np.mean(pathway_flux, axis=1)
|
| 27 |
+
|
| 28 |
+
pathway_fluxes.append(aggregated)
|
| 29 |
+
|
| 30 |
+
result = pd.DataFrame(
|
| 31 |
+
np.array(pathway_fluxes).T,
|
| 32 |
+
index=adata.obs_names,
|
| 33 |
+
columns=[p for p in pathways if pd.notna(p)]
|
| 34 |
+
)
|
| 35 |
+
return result
|
| 36 |
+
|
| 37 |
+
def compute_flux_statistics(adata, groupby: Optional[str] = None) -> Dict:
|
| 38 |
+
"""Compute basic flux statistics."""
|
| 39 |
+
flux_data = adata.X
|
| 40 |
+
stats = {
|
| 41 |
+
'mean': np.asarray(flux_data.mean(axis=0)).flatten(),
|
| 42 |
+
'std': np.asarray(flux_data.std(axis=0)).flatten(),
|
| 43 |
+
'variance': np.asarray(flux_data.var(axis=0)).flatten()
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if groupby and groupby in adata.obs.columns:
|
| 47 |
+
groups = adata.obs[groupby].unique()
|
| 48 |
+
group_stats = {}
|
| 49 |
+
for group in groups:
|
| 50 |
+
mask = adata.obs[groupby] == group
|
| 51 |
+
group_stats[group] = {
|
| 52 |
+
'mean': np.asarray(flux_data[mask].mean(axis=0)).flatten(),
|
| 53 |
+
'count': int(mask.sum())
|
| 54 |
+
}
|
| 55 |
+
stats['groups'] = group_stats
|
| 56 |
+
return stats
|
src/backend/infer_metabolic_interactions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from spmetatme.communication import infer_TME_interaction
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _prune_communication_graph(adata, k=5):
|
| 6 |
+
mat = adata.obsp['communication'].copy()
|
| 7 |
+
np.fill_diagonal(mat, 0)
|
| 8 |
+
|
| 9 |
+
rows = np.arange(mat.shape[0])[:, None]
|
| 10 |
+
topk = np.argpartition(mat, -k, axis=1)[:, -k:]
|
| 11 |
+
|
| 12 |
+
pruned = np.zeros_like(mat)
|
| 13 |
+
pruned[rows, topk] = mat[rows, topk]
|
| 14 |
+
adata.obsp['communication'] = pruned
|
| 15 |
+
return adata
|
| 16 |
+
|
| 17 |
+
def TME_interactions(adata, prune=True ):
|
| 18 |
+
if prune:
|
| 19 |
+
adata = _prune_communication_graph(adata, k=5)
|
| 20 |
+
interaction_scores, interaction_type = infer_TME_interaction(adata, file_name = None)
|
| 21 |
+
return interaction_scores, interaction_type
|
src/backend/preprocessing.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import scanpy as sc
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
logger = logging.getLogger(__name__)
|
| 5 |
+
|
| 6 |
+
def run_preprocessing_pipeline(adata,
|
| 7 |
+
filter_cells_qc=False, min_counts=1000, min_genes=500,
|
| 8 |
+
filter_genes_qc=False, min_cells=10,
|
| 9 |
+
mt_filter=False,
|
| 10 |
+
normalize=True, target_sum=1e4,
|
| 11 |
+
log_transform=True,
|
| 12 |
+
hvg_selection=False, n_hvg=2000):
|
| 13 |
+
"""
|
| 14 |
+
Pure backend logic for preprocessing.
|
| 15 |
+
"""
|
| 16 |
+
adata_processed = adata.copy()
|
| 17 |
+
|
| 18 |
+
if filter_cells_qc:
|
| 19 |
+
sc.pp.calculate_qc_metrics(adata_processed, inplace=True)
|
| 20 |
+
adata_processed = adata_processed[
|
| 21 |
+
(adata_processed.obs['total_counts'] >= min_counts) &
|
| 22 |
+
(adata_processed.obs['n_genes_by_counts'] >= min_genes)
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
if filter_genes_qc:
|
| 26 |
+
sc.pp.filter_genes(adata_processed, min_cells=min_cells)
|
| 27 |
+
|
| 28 |
+
if mt_filter:
|
| 29 |
+
adata_processed = adata_processed[
|
| 30 |
+
:, ~adata_processed.var_names.str.startswith(('MT-', 'mt-', 'MTRNR', 'mtrnr'))
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
if normalize:
|
| 34 |
+
sc.pp.normalize_total(adata_processed, target_sum=target_sum, inplace=True)
|
| 35 |
+
|
| 36 |
+
if log_transform:
|
| 37 |
+
sc.pp.log1p(adata_processed)
|
| 38 |
+
|
| 39 |
+
if hvg_selection:
|
| 40 |
+
sc.pp.highly_variable_genes(adata_processed, n_top_genes=n_hvg, inplace=True)
|
| 41 |
+
adata_processed = adata_processed[:, adata_processed.var['highly_variable']]
|
| 42 |
+
|
| 43 |
+
return adata_processed
|
src/ui/components/footer.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
def render_footer():
|
| 4 |
+
"""Render application footer with manuscript reference and credits."""
|
| 5 |
+
st.markdown("---")
|
| 6 |
+
st.markdown("""
|
| 7 |
+
<div class="container-fluid py-4" style="background-color: transparent;">
|
| 8 |
+
<div class="row align-items-center">
|
| 9 |
+
<div class="col-md-6 text-center text-md-start mb-3 mb-md-0">
|
| 10 |
+
<p class="mb-0 text-muted" style="font-size: 0.9rem;">
|
| 11 |
+
<i class="fas fa-quote-left me-2"></i>
|
| 12 |
+
<strong>Manuscript Reference:</strong><br>
|
| 13 |
+
Verma, S., et al. (2026). <em>spMetaTME: A spatial atlas of tumour microenvironment metabolism and metabolic interactions inferred by a pre-trained self-supervised metabolic hypergraph.</em>
|
| 14 |
+
</p>
|
| 15 |
+
<div class="mt-2">
|
| 16 |
+
<a href="https://github.com/SurajRepo/spMetaTME" target="_blank" class="btn btn-outline-secondary btn-sm rounded-pill px-3">
|
| 17 |
+
<i class="fab fa-github me-1"></i> View on GitHub
|
| 18 |
+
</a>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="col-md-6 text-center text-md-end">
|
| 22 |
+
<p class="mb-0 text-muted" style="font-size: 0.85rem;">
|
| 23 |
+
© 2026 <strong>spMetaTME Atlas</strong>
|
| 24 |
+
</p>
|
| 25 |
+
<p class="mb-0 text-muted" style="font-size: 0.8rem; opacity: 0.7;">
|
| 26 |
+
Interactive Platform for Spatial Metabolic Analysis
|
| 27 |
+
</p>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
""", unsafe_allow_html=True)
|
src/ui/components/header.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
import base64
|
| 4 |
+
|
| 5 |
+
def get_base64_of_bin_file(bin_file):
|
| 6 |
+
with open(bin_file, 'rb') as f:
|
| 7 |
+
data = f.read()
|
| 8 |
+
return base64.b64encode(data).decode()
|
| 9 |
+
|
| 10 |
+
def render_header():
|
| 11 |
+
"""Render application header with Logo and Introduction side-by-side in a card."""
|
| 12 |
+
logo_path = "assets/Logo.png"
|
| 13 |
+
|
| 14 |
+
if os.path.exists(logo_path):
|
| 15 |
+
logo_base64 = get_base64_of_bin_file(logo_path)
|
| 16 |
+
logo_html = f"data:image/png;base64,{logo_base64}"
|
| 17 |
+
|
| 18 |
+
st.markdown(f"""
|
| 19 |
+
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 2.5rem; margin-bottom: 2.5rem; border-left: 6px solid #d32f2f; background: #ffffff; border-radius: 12px; border: 1px solid #e0e0e0; border-left: 6px solid #d32f2f;">
|
| 20 |
+
<div style="flex: 1; display: flex; justify-content: center; align-items: center;">
|
| 21 |
+
<img src="{logo_html}" style="max-width: 100%; height: auto; max-height: 300px; border-radius: 8px;">
|
| 22 |
+
</div>
|
| 23 |
+
<div style="flex: 2;">
|
| 24 |
+
<h1 style='color: #d32f2f; margin: 0 0 0.5rem 0; font-size: 3rem; font-weight: 800; line-height: 1; text-align: center;'>spMetaTME-Atlas</h1>
|
| 25 |
+
<p style="font-size: 1.3rem; color: #333; font-weight: 600; margin-bottom: 1.2rem; line-height: 1.3;">
|
| 26 |
+
A spatial atlas of tumour microenvironment metabolism and metabolic interactions inferred by a pretrained self-supervised metabolic hypergraph
|
| 27 |
+
</p>
|
| 28 |
+
<div style="color: #555; font-size: 1.1rem; line-height: 1.6; text-align: justify;">
|
| 29 |
+
Unlike traditional flux estimation approaches, <b>spMetaTME</b> represents the metabolic network as a directed hypergraph, where metabolites are
|
| 30 |
+
represented as nodes and reactions as hyperedges, enabling the modelling of directional reactant-to-product flux propagation. By leveraging
|
| 31 |
+
self-supervised hypergraph learning, <b>spMetaTME</b> captures the intrinsic metabolic dependencies and directional flux propagation across spatially
|
| 32 |
+
adjacent cells or spots. Leveraging pretrained spMetaTME, we introduce spMetaTME-Atlas, a comprehensive atlas of spatial metabolic data to cover metabolic
|
| 33 |
+
reprogramming in the tumour microenvironment and metabolic interactions.
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
""", unsafe_allow_html=True)
|
| 38 |
+
else:
|
| 39 |
+
# Fallback if logo is missing
|
| 40 |
+
st.markdown("""
|
| 41 |
+
<div style="padding: 2.5rem; margin-bottom: 2.5rem; border-radius: 12px; border: 1px solid #e0e0e0; border-left: 6px solid #d32f2f; background: #ffffff;">
|
| 42 |
+
<h1 class='main-header' style='font-size: 3.5rem; margin-bottom: 0.5rem; text-align: center;'>spMetaTME-Atlas</h1>
|
| 43 |
+
<p style="font-size: 1.5rem; color: #333; font-weight: 600; line-height: 1.3;">
|
| 44 |
+
A spatial atlas of tumour microenvironment metabolism and metabolic interactions inferred by a pretrained self-supervised metabolic hypergraph
|
| 45 |
+
</p>
|
| 46 |
+
<div style="color: #444; font-size: 1.15rem; line-height: 1.8; margin-top: 1.5rem; text-align: justify;">
|
| 47 |
+
Unlike traditional flux estimation approaches, <b>spMetaTME</b> represents the metabolic network as a directed
|
| 48 |
+
hypergraph, where metabolites are represented as nodes and reactions as hyperedges, enabling the modelling of
|
| 49 |
+
directional reactant-to-product flux propagation. By leveraging self-supervised hypergraph learning, <b>spMetaTME</b>
|
| 50 |
+
captures the intrinsic metabolic dependencies and directional flux propagation across spatially adjacent cells or spots.
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
""", unsafe_allow_html=True)
|
| 54 |
+
|
| 55 |
+
def load_css():
|
| 56 |
+
"""Load custom CSS."""
|
| 57 |
+
css_path = "assets/style.css"
|
| 58 |
+
if os.path.exists(css_path):
|
| 59 |
+
with open(css_path) as f:
|
| 60 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 61 |
+
|
| 62 |
+
# Also load external assets
|
| 63 |
+
st.markdown("""
|
| 64 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
|
| 65 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 66 |
+
""", unsafe_allow_html=True)
|
src/ui/pages/flux_analysis.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from src.backend.flux_analysis import run_smt_inference
|
| 3 |
+
|
| 4 |
+
def show_flux_analysis():
|
| 5 |
+
"""Render flux analysis UI."""
|
| 6 |
+
st.markdown("## <i class='fas fa-flask-vial' style='color:#d32f2f'></i> Metabolic Flux Analysis", unsafe_allow_html=True)
|
| 7 |
+
|
| 8 |
+
if st.session_state.adata is None:
|
| 9 |
+
st.error("Please preprocess data first.")
|
| 10 |
+
return
|
| 11 |
+
|
| 12 |
+
col1, col2 = st.columns(2)
|
| 13 |
+
with col1:
|
| 14 |
+
model = st.selectbox("<i class='fas fa-microscope'></i> Model:", ["breast_cancer", "pan_cancer"], help="Select the pre-trained spMetaTME model type.")
|
| 15 |
+
K = st.number_input("K neighbors", value=150, help="Number of neighbors for spatial graph construction.")
|
| 16 |
+
with col2:
|
| 17 |
+
n_clusters = st.number_input("Domains", value=5, help="Number of clusters (metabolic domains) to identify.")
|
| 18 |
+
clustering = st.selectbox("Method", ["kmeans", "leiden"], help="Clustering algorithm for domain identification.")
|
| 19 |
+
|
| 20 |
+
if st.button("Run Analysis", key="run_flux", icon=":material/rocket_launch:"):
|
| 21 |
+
with st.spinner("Running spMetaTME (this may take 5-30 mins)..."):
|
| 22 |
+
try:
|
| 23 |
+
metabolic_adata = run_smt_inference(
|
| 24 |
+
st.session_state.adata, model, K, 80, n_clusters, clustering
|
| 25 |
+
)
|
| 26 |
+
st.session_state.metabolic_adata = metabolic_adata
|
| 27 |
+
st.session_state.flux_analysis_done = True
|
| 28 |
+
st.success("Analysis completed!")
|
| 29 |
+
st.rerun()
|
| 30 |
+
except Exception as e:
|
| 31 |
+
st.error(f"Error: {e}")
|
src/ui/pages/overview.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from src.backend.data_loader import get_metadata, get_organ_stats, download_metabolic_flux_from_hf, load_metabolic_flux_from_hf, process_upload
|
| 4 |
+
|
| 5 |
+
def show_overview():
|
| 6 |
+
"""Enhanced Overview with Organ cards and improved Dataset Browser."""
|
| 7 |
+
# Statistics Section at top
|
| 8 |
+
# render_organ_statistics()
|
| 9 |
+
|
| 10 |
+
# tab1, tab2, tab3 = st.tabs([
|
| 11 |
+
# "Browse Atlas",
|
| 12 |
+
# "Upload Pre-computed",
|
| 13 |
+
# "New Analysis"
|
| 14 |
+
# ])
|
| 15 |
+
|
| 16 |
+
# with tab1:
|
| 17 |
+
# render_available_datasets()
|
| 18 |
+
# with tab2:
|
| 19 |
+
# render_upload_fluxes()
|
| 20 |
+
# with tab3:
|
| 21 |
+
# render_upload_spatial_data()
|
| 22 |
+
|
| 23 |
+
render_available_datasets()
|
| 24 |
+
|
| 25 |
+
def render_organ_statistics():
|
| 26 |
+
"""Render attractive cards for organ statistics."""
|
| 27 |
+
meta_df = get_metadata()
|
| 28 |
+
if meta_df.empty:
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
stats = get_organ_stats(meta_df)
|
| 32 |
+
|
| 33 |
+
# Organ to Icon mapping
|
| 34 |
+
icon_map = {
|
| 35 |
+
'brain': 'fa-brain',
|
| 36 |
+
'heart': 'fa-heart',
|
| 37 |
+
'lungs': 'fa-lungs',
|
| 38 |
+
'liver': 'fa-vial',
|
| 39 |
+
'kidney': 'fa-kidneys',
|
| 40 |
+
'bone': 'fa-bone',
|
| 41 |
+
'tooth': 'fa-tooth',
|
| 42 |
+
'eye': 'fa-eye',
|
| 43 |
+
'ear': 'fa-ear-listen',
|
| 44 |
+
'skin': 'fa-person',
|
| 45 |
+
'breast': 'fa-person-half-dress',
|
| 46 |
+
'colon': 'fa-capsules',
|
| 47 |
+
'lymph node': 'fa-dna',
|
| 48 |
+
'pancreas': 'fa-pills',
|
| 49 |
+
'prostate': 'fa-stethoscope',
|
| 50 |
+
'skin': 'fa-hand',
|
| 51 |
+
'muscle': 'fa-hand-back-fist'
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
st.markdown("### <i class='fas fa-chart-line' style='color:#d32f2f'></i> Atlas Overview", unsafe_allow_html=True)
|
| 55 |
+
|
| 56 |
+
# Create rows of cards (4 per row)
|
| 57 |
+
cols = st.columns(4)
|
| 58 |
+
for idx, (index, row) in enumerate(stats.iterrows()):
|
| 59 |
+
col_idx = idx % 4
|
| 60 |
+
organ = row['organ']
|
| 61 |
+
icon = icon_map.get(organ.lower(), 'fa-microscope')
|
| 62 |
+
|
| 63 |
+
with cols[col_idx]:
|
| 64 |
+
st.markdown(f"""
|
| 65 |
+
<div class='material-card' style='text-align: center; border-top: 4px solid #d32f2f;'>
|
| 66 |
+
<i class='fas {icon}' style='font-size: 2.5rem; color: #d32f2f; margin-bottom: 1rem;'></i>
|
| 67 |
+
<div style='font-weight: 700; font-size: 1.2rem; color: #333;'>{organ.title()}</div>
|
| 68 |
+
<div style='color: #666; font-size: 0.9rem;'>{int(row['sample_count'])} Samples</div>
|
| 69 |
+
<div style='color: #d32f2f; font-weight: 600; font-size: 0.8rem; margin-top: 0.5rem;'>
|
| 70 |
+
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
""", unsafe_allow_html=True)
|
| 74 |
+
|
| 75 |
+
def render_available_datasets():
|
| 76 |
+
"""Attractive Dataset Browser with filtering and pagination."""
|
| 77 |
+
st.markdown("#### <i class='fas fa-search' style='color:#d32f2f'></i> Search by filters", unsafe_allow_html=True)
|
| 78 |
+
|
| 79 |
+
meta_df = get_metadata()
|
| 80 |
+
if meta_df.empty: return
|
| 81 |
+
|
| 82 |
+
# Filter sidebar style layout inside page
|
| 83 |
+
# with st.expander("Filter Results", expanded=False, icon=":material/filter_list:"):
|
| 84 |
+
c1, c2, c3 = st.columns(3)
|
| 85 |
+
selected_species = c1.multiselect("Species", options=sorted(meta_df['species'].unique()), help="Filter datasets by species.")
|
| 86 |
+
selected_organ = c2.multiselect("Organ", options=sorted(meta_df['organ'].unique()), help="Filter datasets by organ.")
|
| 87 |
+
datasets_per_page = c3.selectbox("Show", options=[10, 20, 50], index=0, help="Number of datasets to show per page.")
|
| 88 |
+
st.markdown("---")
|
| 89 |
+
filtered_df = meta_df.copy()
|
| 90 |
+
if selected_species: filtered_df = filtered_df[filtered_df['species'].isin(selected_species)]
|
| 91 |
+
if selected_organ: filtered_df = filtered_df[filtered_df['organ'].isin(selected_organ)]
|
| 92 |
+
|
| 93 |
+
total = len(filtered_df)
|
| 94 |
+
pages = max(1, (total + datasets_per_page - 1) // datasets_per_page)
|
| 95 |
+
|
| 96 |
+
if st.session_state.dataset_page > pages:
|
| 97 |
+
st.session_state.dataset_page = 1
|
| 98 |
+
|
| 99 |
+
if total > 0:
|
| 100 |
+
st.markdown(f"<h4>{total} Available Datasets</h4>", unsafe_allow_html=True)
|
| 101 |
+
|
| 102 |
+
start_idx = (st.session_state.dataset_page - 1) * datasets_per_page
|
| 103 |
+
end_idx = start_idx + datasets_per_page
|
| 104 |
+
paginated_df = filtered_df.iloc[start_idx:end_idx]
|
| 105 |
+
|
| 106 |
+
for idx, row in paginated_df.iterrows():
|
| 107 |
+
with st.container():
|
| 108 |
+
col1, col2, col3, col4 = st.columns([2, 1, 1, 1])
|
| 109 |
+
|
| 110 |
+
with col1:
|
| 111 |
+
st.markdown(f"**{row['dataset_title']}**")
|
| 112 |
+
st.markdown(f"**Dataset ID:** `{row['id']}`")
|
| 113 |
+
|
| 114 |
+
# Display metadata
|
| 115 |
+
meta_info = []
|
| 116 |
+
if pd.notna(row.get('species')):
|
| 117 |
+
meta_info.append(f"{row['species']}")
|
| 118 |
+
if pd.notna(row.get('organ')):
|
| 119 |
+
meta_info.append(f"{row['organ']}")
|
| 120 |
+
if pd.notna(row.get('st_technology')):
|
| 121 |
+
meta_info.append(f"{row['st_technology']}")
|
| 122 |
+
|
| 123 |
+
if meta_info:
|
| 124 |
+
st.caption(" | ".join(meta_info))
|
| 125 |
+
|
| 126 |
+
with col2:
|
| 127 |
+
# Dataset metrics — use original CSV column names
|
| 128 |
+
metrics = []
|
| 129 |
+
if pd.notna(row.get('spots_under_tissue')):
|
| 130 |
+
metrics.append(f"**Spots:** {int(row['spots_under_tissue'])}")
|
| 131 |
+
elif pd.notna(row.get('n_obs')):
|
| 132 |
+
metrics.append(f"**Spots:** {int(row['n_obs'])}")
|
| 133 |
+
|
| 134 |
+
if pd.notna(row.get('number_reactions')):
|
| 135 |
+
metrics.append(f"**Reactions:** {int(row['number_reactions'])}")
|
| 136 |
+
elif pd.notna(row.get('n_vars')):
|
| 137 |
+
metrics.append(f"**Reactions:** {int(row['n_vars'])}")
|
| 138 |
+
|
| 139 |
+
if pd.notna(row.get('number_metabolites')):
|
| 140 |
+
metrics.append(f"**Metabolites:** {int(row['number_metabolites'])}")
|
| 141 |
+
elif pd.notna(row.get('n_metabolites')):
|
| 142 |
+
metrics.append(f"**Metabolites:** {int(row['n_metabolites'])}")
|
| 143 |
+
|
| 144 |
+
for metric in metrics:
|
| 145 |
+
st.markdown(metric)
|
| 146 |
+
|
| 147 |
+
with col3:
|
| 148 |
+
hf_filename = row['metabolic_filename']
|
| 149 |
+
if st.button(
|
| 150 |
+
"Download",
|
| 151 |
+
key=f"download_{row['id']}",
|
| 152 |
+
help="Download .h5ad file from Hugging Face to your local machine",
|
| 153 |
+
width='stretch',
|
| 154 |
+
icon=":material/download:"
|
| 155 |
+
):
|
| 156 |
+
download_metabolic_flux_from_hf(hf_filename)
|
| 157 |
+
|
| 158 |
+
with col4:
|
| 159 |
+
hf_filename = row['metabolic_filename']
|
| 160 |
+
if st.button(
|
| 161 |
+
"Analyze",
|
| 162 |
+
key=f"visualize_{row['id']}",
|
| 163 |
+
help="Load and preview spatial metabolic flux data",
|
| 164 |
+
width='stretch',
|
| 165 |
+
icon=":material/open_in_new:"
|
| 166 |
+
):
|
| 167 |
+
with st.spinner(f"Loading {hf_filename}..."):
|
| 168 |
+
adata = load_metabolic_flux_from_hf(hf_filename)
|
| 169 |
+
if adata:
|
| 170 |
+
st.session_state.metabolic_adata = adata
|
| 171 |
+
st.session_state.data_type = "flux"
|
| 172 |
+
# Clear interaction cache for new tissue
|
| 173 |
+
for key in ['interaction_scores', 'interaction_type']:
|
| 174 |
+
if key in st.session_state:
|
| 175 |
+
del st.session_state[key]
|
| 176 |
+
st.rerun()
|
| 177 |
+
|
| 178 |
+
st.markdown("---")
|
| 179 |
+
else:
|
| 180 |
+
st.info("No datasets found matching the selected filters.")
|
| 181 |
+
|
| 182 |
+
# Pagination
|
| 183 |
+
if pages > 1:
|
| 184 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 185 |
+
c1, c2, c3 = st.columns([1,2,1])
|
| 186 |
+
if c1.button("Previous", key="prev_ds", icon=":material/chevron_left:") and st.session_state.dataset_page > 1:
|
| 187 |
+
st.session_state.dataset_page -= 1; st.rerun()
|
| 188 |
+
c2.markdown(f"<div style='text-align: center; font-weight: bold; margin-top: 5px;'>Page {st.session_state.dataset_page} of {pages}</div>", unsafe_allow_html=True)
|
| 189 |
+
if c3.button("Next", key="next_ds", icon=":material/chevron_right:") and st.session_state.dataset_page < pages:
|
| 190 |
+
st.session_state.dataset_page += 1; st.rerun()
|
| 191 |
+
|
| 192 |
+
def render_upload_fluxes():
|
| 193 |
+
st.markdown("### <i class='fas fa-cloud-arrow-up'></i> Upload Flux Data", unsafe_allow_html=True)
|
| 194 |
+
uploaded_file = st.file_uploader("Pre-computed Fluxes (.h5ad)", type="h5ad")
|
| 195 |
+
if uploaded_file:
|
| 196 |
+
adata = process_upload(uploaded_file, "flux")
|
| 197 |
+
if adata:
|
| 198 |
+
st.session_state.metabolic_adata = adata
|
| 199 |
+
st.session_state.data_type = "flux"
|
| 200 |
+
for key in ['interaction_scores', 'interaction_type']:
|
| 201 |
+
if key in st.session_state:
|
| 202 |
+
del st.session_state[key]
|
| 203 |
+
st.rerun()
|
| 204 |
+
|
| 205 |
+
def render_upload_spatial_data():
|
| 206 |
+
st.markdown("### <i class='fas fa-flask'></i> New Spatial Analysis", unsafe_allow_html=True)
|
| 207 |
+
st.info("Upload raw spatial transcriptomics data to run spMetaTME flux inference.")
|
| 208 |
+
uploaded_file = st.file_uploader("Spatial Transcriptomics (.h5ad)", type="h5ad")
|
| 209 |
+
if uploaded_file:
|
| 210 |
+
adata = process_upload(uploaded_file, "spatial")
|
| 211 |
+
if adata:
|
| 212 |
+
st.session_state.adata = adata
|
| 213 |
+
st.session_state.data_type = "spatial"
|
| 214 |
+
for key in ['interaction_scores', 'interaction_type']:
|
| 215 |
+
if key in st.session_state:
|
| 216 |
+
del st.session_state[key]
|
| 217 |
+
st.rerun()
|
src/ui/pages/preprocessing.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from src.backend.preprocessing import run_preprocessing_pipeline
|
| 3 |
+
|
| 4 |
+
def show_preprocessing():
|
| 5 |
+
"""Render preprocessing UI."""
|
| 6 |
+
st.markdown("## <i class='fas fa-screwdriver-wrench' style='color:#d32f2f'></i> Data Preprocessing", unsafe_allow_html=True)
|
| 7 |
+
|
| 8 |
+
if st.session_state.adata is None:
|
| 9 |
+
st.error("Please upload data first.")
|
| 10 |
+
return
|
| 11 |
+
|
| 12 |
+
adata = st.session_state.adata
|
| 13 |
+
|
| 14 |
+
col1, col2 = st.columns(2)
|
| 15 |
+
with col1:
|
| 16 |
+
st.markdown("#### <i class='fas fa-filter'></i> Filtering Options", unsafe_allow_html=True)
|
| 17 |
+
filter_cells = st.checkbox("Filter cells by quality", value=False)
|
| 18 |
+
min_counts = st.number_input("Min counts", value=1000, help="Minimum library size (total counts) per cell.") if filter_cells else 1000
|
| 19 |
+
min_genes = st.number_input("Min genes", value=500, help="Minimum number of genes detected per cell.") if filter_cells else 500
|
| 20 |
+
|
| 21 |
+
with col2:
|
| 22 |
+
st.markdown("#### <i class='fas fa-wand-magic-sparkles'></i> Normalization", unsafe_allow_html=True)
|
| 23 |
+
normalize = st.checkbox("Normalize library size", value=True)
|
| 24 |
+
log_transform = st.checkbox("Log transform", value=True)
|
| 25 |
+
|
| 26 |
+
if st.button("Run Preprocessing", key="run_pre", icon=":material/play_arrow:"):
|
| 27 |
+
with st.spinner("Processing..."):
|
| 28 |
+
processed = run_preprocessing_pipeline(
|
| 29 |
+
adata,
|
| 30 |
+
filter_cells_qc=filter_cells, min_counts=min_counts, min_genes=min_genes,
|
| 31 |
+
normalize=normalize, log_transform=log_transform
|
| 32 |
+
)
|
| 33 |
+
st.session_state.adata = processed
|
| 34 |
+
st.session_state.preprocessing_done = True
|
| 35 |
+
st.success("Preprocessing completed!")
|
| 36 |
+
st.rerun()
|
| 37 |
+
|
| 38 |
+
if st.session_state.preprocessing_done:
|
| 39 |
+
if st.button("Proceed to Analysis", icon=":material/arrow_forward:"):
|
| 40 |
+
# Redirect logic
|
| 41 |
+
st.rerun()
|
src/ui/pages/visualization.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from streamlit_option_menu import option_menu
|
| 3 |
+
from src.ui.plots.domain_statistics import render_domain_statistics
|
| 4 |
+
from src.ui.plots.spatial_flux_map import render_spatial_flux_map
|
| 5 |
+
from src.ui.plots.umap_embedding import render_umap_embedding
|
| 6 |
+
from src.ui.plots.metabolic_interactions import render_metabolic_interactions
|
| 7 |
+
from src.ui.plots.differential_analysis import render_differential_reactions
|
| 8 |
+
from src.ui.plots.metabolite_balance import render_metabolite_balance_analysis
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def show_visualization():
|
| 12 |
+
"""Visualization module coordinator."""
|
| 13 |
+
if st.session_state.metabolic_adata is None:
|
| 14 |
+
st.error("No flux data available. Please load data first.")
|
| 15 |
+
return
|
| 16 |
+
|
| 17 |
+
metabolic_adata = st.session_state.metabolic_adata
|
| 18 |
+
if not metabolic_adata.var_names.is_unique:
|
| 19 |
+
metabolic_adata.var_names_make_unique()
|
| 20 |
+
|
| 21 |
+
viz_options = [
|
| 22 |
+
"Home",
|
| 23 |
+
"Domain Statistics",
|
| 24 |
+
"Spatial Flux Distribution",
|
| 25 |
+
"UMAP Analysis",
|
| 26 |
+
"Differential Analysis",
|
| 27 |
+
"Metabolic Interactions",
|
| 28 |
+
"Metabolite Balance Analysis",
|
| 29 |
+
]
|
| 30 |
+
viz_icons = [
|
| 31 |
+
"house",
|
| 32 |
+
"pie-chart",
|
| 33 |
+
"bi-image-fill",
|
| 34 |
+
"bi-palette2",
|
| 35 |
+
"bi-bar-chart-steps",
|
| 36 |
+
"bi-link",
|
| 37 |
+
"bi-droplet-fill",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
with st.sidebar:
|
| 41 |
+
selected_viz = option_menu(
|
| 42 |
+
"Metabolic Analysis",
|
| 43 |
+
viz_options,
|
| 44 |
+
icons=viz_icons,
|
| 45 |
+
menu_icon="vial",
|
| 46 |
+
default_index=1,
|
| 47 |
+
key="viz_menu"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Handle Home navigation
|
| 51 |
+
if selected_viz == "Home":
|
| 52 |
+
st.session_state.metabolic_adata = None
|
| 53 |
+
st.session_state.data_type = None
|
| 54 |
+
st.rerun()
|
| 55 |
+
|
| 56 |
+
# Main content rendering
|
| 57 |
+
if selected_viz == "Domain Statistics":
|
| 58 |
+
render_domain_statistics(metabolic_adata)
|
| 59 |
+
elif selected_viz == "Spatial Flux Distribution":
|
| 60 |
+
render_spatial_flux_map(metabolic_adata)
|
| 61 |
+
elif selected_viz == "Metabolite Balance Analysis":
|
| 62 |
+
render_metabolite_balance_analysis(metabolic_adata)
|
| 63 |
+
elif selected_viz == "UMAP Analysis":
|
| 64 |
+
render_umap_embedding(metabolic_adata)
|
| 65 |
+
elif selected_viz == "Differential Analysis":
|
| 66 |
+
render_differential_reactions(metabolic_adata)
|
| 67 |
+
elif selected_viz == "Metabolic Interactions":
|
| 68 |
+
render_metabolic_interactions(metabolic_adata)
|
| 69 |
+
|
src/ui/plots/differential_analysis.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
import logging
|
| 6 |
+
from scipy import stats
|
| 7 |
+
from typing import Optional, List
|
| 8 |
+
from streamlit_option_menu import option_menu
|
| 9 |
+
import spmetatme.plotting as pl
|
| 10 |
+
import io
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from .utils import display_plot_with_download, display_formatted_table
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def render_differential_reactions(metabolic_adata):
|
| 19 |
+
"""Replicated original render_differential_reactions."""
|
| 20 |
+
st.markdown("<h2 style='color: #d32f2f;'><i class='fas fa-chart-bar'></i> Differential Metabolic Reactions Analysis</h2>", unsafe_allow_html=True)
|
| 21 |
+
|
| 22 |
+
tab1, tab2, tab3, tab4 = st.tabs([
|
| 23 |
+
"Pathway-Specific Reactions",
|
| 24 |
+
"All Differential Reactions",
|
| 25 |
+
"Pathways by Variance",
|
| 26 |
+
"Differential Pathways"
|
| 27 |
+
])
|
| 28 |
+
|
| 29 |
+
with tab1:
|
| 30 |
+
if 'subsystems' not in metabolic_adata.var.columns:
|
| 31 |
+
st.error("Pathway information (subsystems) not found in data")
|
| 32 |
+
else:
|
| 33 |
+
available_pathways = sorted(metabolic_adata.var['subsystems'].unique().tolist())
|
| 34 |
+
col1, col2, col3 = st.columns(3)
|
| 35 |
+
with col1:
|
| 36 |
+
selected_pathway = st.selectbox("Select pathway:", options=available_pathways, key="tab1_path_sel", help="Select a metabolic pathway for differential reaction analysis.")
|
| 37 |
+
with col2:
|
| 38 |
+
top_n = st.slider("Top N reactions", 5, 20, 10, key="tab1_top_n", help="Filter the number of top significant reactions to display.")
|
| 39 |
+
with col3:
|
| 40 |
+
row_cluster = st.checkbox("Cluster rows", value=True, key="tab1_cluster")
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
with st.spinner(f"Analyzing {selected_pathway}..."):
|
| 44 |
+
df = pl.plot_differential_reactions_by_pathway_heatmap(
|
| 45 |
+
metabolic_adata, selected_pathway, row_cluster=row_cluster, return_marker_df=True, top_n=top_n
|
| 46 |
+
)
|
| 47 |
+
fig = plt.gcf()
|
| 48 |
+
col_p, col_t = st.columns([1.5, 1], gap="small")
|
| 49 |
+
with col_p:
|
| 50 |
+
display_plot_with_download(fig, f"{selected_pathway.replace(' ', '_')}_Heatmap")
|
| 51 |
+
with col_t:
|
| 52 |
+
if df is not None:
|
| 53 |
+
display_formatted_table(df, "Differential Reactions")
|
| 54 |
+
csv = df.to_csv(index=False)
|
| 55 |
+
st.download_button("Download CSV", data=csv, file_name=f"{selected_pathway}.csv", mime="text/csv", icon=":material/download:")
|
| 56 |
+
except Exception as e:
|
| 57 |
+
st.error(f"Error: {e}")
|
| 58 |
+
|
| 59 |
+
with tab2:
|
| 60 |
+
col1, col2 = st.columns(2)
|
| 61 |
+
with col1:
|
| 62 |
+
top_n = st.slider("Top N reactions:", 5, 20, 10, key="tab2_top_n", help="Number of differentially active reactions to display across all pathways.")
|
| 63 |
+
with col2:
|
| 64 |
+
row_cluster = st.checkbox("Cluster rows", value=False, key="tab2_cluster")
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
with st.spinner("Analyzing..."):
|
| 68 |
+
df = pl.plot_differential_reactions_heatmap(metabolic_adata, top_n=top_n, row_cluster=row_cluster, return_marker_df=True)
|
| 69 |
+
fig = plt.gcf()
|
| 70 |
+
col_p, col_t = st.columns([1.5, 1], gap="small")
|
| 71 |
+
with col_p: display_plot_with_download(fig, "Diff_Reactions_Heatmap")
|
| 72 |
+
with col_t:
|
| 73 |
+
if df is not None:
|
| 74 |
+
display_formatted_table(df, "Differential Reactions")
|
| 75 |
+
except Exception as e:
|
| 76 |
+
st.error(f"Error: {e}")
|
| 77 |
+
|
| 78 |
+
with tab3:
|
| 79 |
+
col1, col2 = st.columns(2)
|
| 80 |
+
with col1: top_n = st.slider("Top N pathways", 5, 20, 10, key="tab3_top_n", help="Filter top pathways based on the selected metric.")
|
| 81 |
+
with col2: sort_by = st.selectbox("Sort by", ["variance", "mean"], key="tab3_sort", help="Metric to rank pathways.")
|
| 82 |
+
try:
|
| 83 |
+
with st.spinner("Analyzing..."):
|
| 84 |
+
df = pl.plot_pathways_flux_heatmap(metabolic_adata, group_key="domain", pathway_key="subsystems", top_n=top_n, sort_by=sort_by)
|
| 85 |
+
fig = plt.gcf()
|
| 86 |
+
col_p, col_t = st.columns([1.5, 1], gap="small")
|
| 87 |
+
with col_p: display_plot_with_download(fig, "Pathways_Var")
|
| 88 |
+
with col_t:
|
| 89 |
+
if df is not None:
|
| 90 |
+
display_formatted_table(df, "Pathways Data")
|
| 91 |
+
except Exception as e:
|
| 92 |
+
st.error(f"Error: {e}")
|
| 93 |
+
with tab4:
|
| 94 |
+
col1, col2 = st.columns(2)
|
| 95 |
+
with col1:
|
| 96 |
+
top_n = st.slider("Top N pathways", 5, 20, 10, key="tab4_top_n", help="Filter top pathways.")
|
| 97 |
+
with col2:
|
| 98 |
+
row_cluster = st.checkbox("Cluster rows", value=True, key="tab4_cluster")
|
| 99 |
+
try:
|
| 100 |
+
with st.spinner("Analyzing..."):
|
| 101 |
+
df = pl.plot_differential_pathways_heatmap(metabolic_adata, row_cluster=row_cluster, top_n=top_n, return_marker_df= True)
|
| 102 |
+
fig = plt.gcf()
|
| 103 |
+
col_p, col_t = st.columns([1.5, 1], gap="small")
|
| 104 |
+
with col_p: display_plot_with_download(fig, "Pathways_Var")
|
| 105 |
+
with col_t:
|
| 106 |
+
if df is not None:
|
| 107 |
+
display_formatted_table(df, "Differential Pathways")
|
| 108 |
+
csv = df.to_csv(index=False)
|
| 109 |
+
st.download_button("Download CSV", data=csv, file_name=f"Pathways_Diff.csv", mime="text/csv", icon=":material/download:")
|
| 110 |
+
except Exception as e:
|
| 111 |
+
st.error(f"Error: {e}")
|
| 112 |
+
|
| 113 |
+
|
src/ui/plots/domain_statistics.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import scanpy as sc
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import seaborn as sns
|
| 7 |
+
from scipy.sparse import issparse
|
| 8 |
+
from .utils import display_plot_with_download, display_formatted_table, display_interactive_spatial_plot, add_significance_brackets
|
| 9 |
+
from src.backend.flux_distribution import adata_to_long_df, p_to_star
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def render_domain_statistics(metabolic_adata):
|
| 14 |
+
"""Render domain-level statistics and flux distribution."""
|
| 15 |
+
st.markdown(
|
| 16 |
+
"<h2 style='color: #d32f2f; margin-bottom: 1.5rem;'>"
|
| 17 |
+
"<i class='fas fa-chart-pie'></i> Domain-Level Analysis</h2>",
|
| 18 |
+
unsafe_allow_html=True,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
if "domain" not in metabolic_adata.obs.columns:
|
| 22 |
+
st.warning("Domain information not found in metadata.")
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
_render_metabolic_metadata(metabolic_adata)
|
| 26 |
+
st.markdown("---")
|
| 27 |
+
|
| 28 |
+
# Three-column layout for Domain-level overview
|
| 29 |
+
c1, c2, c3 = st.columns(3, gap="small")
|
| 30 |
+
|
| 31 |
+
with c1:
|
| 32 |
+
st.markdown("<div style='font-size: 1.2rem; font-weight: 600; color: #d32f2f;'><i class='fas fa-map'></i> Spatial Domains</div>", unsafe_allow_html=True)
|
| 33 |
+
try:
|
| 34 |
+
library_id = next(iter(metabolic_adata.uns["spatial"]))
|
| 35 |
+
img_key = "hires" if "hires" in metabolic_adata.uns["spatial"][library_id]["images"] else "downscaled_fullres"
|
| 36 |
+
|
| 37 |
+
fig, ax = plt.subplots(figsize=(5, 5))
|
| 38 |
+
sc.pl.spatial(
|
| 39 |
+
metabolic_adata,
|
| 40 |
+
img_key=img_key,
|
| 41 |
+
color=["domain"],
|
| 42 |
+
size=1.5,
|
| 43 |
+
show=False,
|
| 44 |
+
frameon=False,
|
| 45 |
+
legend_loc="best",
|
| 46 |
+
ax=ax,
|
| 47 |
+
)
|
| 48 |
+
ax.set_title("") # Title is in the card header
|
| 49 |
+
plt.tight_layout()
|
| 50 |
+
display_plot_with_download(
|
| 51 |
+
fig,
|
| 52 |
+
"spatial_domain_map",
|
| 53 |
+
help_text="This plot shows the spatial distribution of metabolic domains across the tissue. Each domain represents a cluster of spots with similar metabolic flux profiles, helping identify functionally distinct regions."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
plt.close(fig)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
st.error(f"Spatial map error: {e}")
|
| 59 |
+
|
| 60 |
+
with c2:
|
| 61 |
+
st.markdown("<div style='font-size: 1.2rem; font-weight: 600; color: #d32f2f;'><i class='fas fa-table-cells'></i> Inter-Domain Correlation</div>", unsafe_allow_html=True)
|
| 62 |
+
try:
|
| 63 |
+
X = metabolic_adata.X.toarray() if issparse(metabolic_adata.X) else metabolic_adata.X
|
| 64 |
+
obs_df = pd.DataFrame(X, columns=metabolic_adata.var_names)
|
| 65 |
+
obs_df['domain'] = metabolic_adata.obs['domain'].values
|
| 66 |
+
domain_profiles = obs_df.groupby('domain').mean()
|
| 67 |
+
corr_matrix = domain_profiles.T.corr()
|
| 68 |
+
|
| 69 |
+
fig, ax = plt.subplots(figsize=(5, 5))
|
| 70 |
+
sns.heatmap(
|
| 71 |
+
corr_matrix,
|
| 72 |
+
annot=True, fmt=".2f", cmap="RdBu_r", center=0,
|
| 73 |
+
vmin=-1, vmax=1, linewidths=1, linecolor='white',
|
| 74 |
+
cbar=False, # Conserve space in the card
|
| 75 |
+
ax=ax,
|
| 76 |
+
annot_kws={"size": 9, "weight": "bold"}
|
| 77 |
+
)
|
| 78 |
+
plt.xticks(rotation=45, ha='right', fontsize=9)
|
| 79 |
+
plt.yticks(rotation=0, fontsize=9)
|
| 80 |
+
plt.tight_layout()
|
| 81 |
+
display_plot_with_download(
|
| 82 |
+
fig,
|
| 83 |
+
"domain_correlation",
|
| 84 |
+
help_text="The correlation heatmap depicts how similar the average metabolic flux profiles are between different domains. High positive correlation (red) suggests metabolic similarity, while negative correlation (blue) indicates contrasting metabolic activities."
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
plt.close(fig)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
st.warning(f"Correlation matrix unavailable: {e}")
|
| 90 |
+
|
| 91 |
+
with c3:
|
| 92 |
+
st.markdown("<div style='font-size: 1.2rem; font-weight: 600; color: #d32f2f;'><i class='fas fa-wave-square'></i> Spatial Autocorrelation</div>", unsafe_allow_html=True)
|
| 93 |
+
try:
|
| 94 |
+
moranI = metabolic_adata.uns.get("moranI")
|
| 95 |
+
if moranI is not None:
|
| 96 |
+
moran_vals = moranI["I"] if "I" in moranI.columns else moranI.iloc[:, 0]
|
| 97 |
+
fig, ax = plt.subplots(figsize=(5, 5))
|
| 98 |
+
sns.kdeplot(moran_vals, fill=True, color="#d32f2f", linewidth=2, ax=ax)
|
| 99 |
+
ax.axvline(0, color="black", linestyle="--", linewidth=0.8, alpha=0.6)
|
| 100 |
+
ax.set_xlabel("Moran's I Index", fontsize=10)
|
| 101 |
+
ax.set_ylabel("Density", fontsize=10)
|
| 102 |
+
sns.despine()
|
| 103 |
+
plt.tight_layout()
|
| 104 |
+
display_plot_with_download(
|
| 105 |
+
fig,
|
| 106 |
+
"moranI_kde",
|
| 107 |
+
help_text="Moran's I measures the degree of spatial clustering in flux values. A positive value indicates that similar flux levels are geographically clustered, while values near zero suggest a random distribution. This helps confirm that metabolic patterns are spatially organized."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
plt.close(fig)
|
| 111 |
+
else:
|
| 112 |
+
st.info("Moran's I not available.")
|
| 113 |
+
except Exception as e:
|
| 114 |
+
st.info(f"Moran's I plot unavailable: {e}")
|
| 115 |
+
|
| 116 |
+
st.markdown("---")
|
| 117 |
+
|
| 118 |
+
st.markdown("<div style='font-size: 1.2rem; font-weight: 600; color: #d32f2f; margin-bottom: 1rem;'><i class='fas fa-box-open'></i> Flux Distribution Across Domains</div>", unsafe_allow_html=True)
|
| 119 |
+
# Horizontal controls for Flux Distribution
|
| 120 |
+
col_ctrl1, col_ctrl2 = st.columns([1, 2])
|
| 121 |
+
|
| 122 |
+
with col_ctrl1:
|
| 123 |
+
view_mode = st.selectbox(
|
| 124 |
+
"Visualize by:",
|
| 125 |
+
options=["Domains", "Reactions", "Pathway"],
|
| 126 |
+
key="ds_view_mode",
|
| 127 |
+
help="Select what to compare on the flux distribution plot.",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
selected_data = None
|
| 131 |
+
with col_ctrl2:
|
| 132 |
+
if view_mode == "Reactions":
|
| 133 |
+
if 'rxn_full_names' in metabolic_adata.var.columns:
|
| 134 |
+
# Map full name to ID for user selection
|
| 135 |
+
unique_names = {}
|
| 136 |
+
for idx, row in metabolic_adata.var.iterrows():
|
| 137 |
+
f_name = str(row['rxn_full_names'])
|
| 138 |
+
if f_name not in unique_names:
|
| 139 |
+
unique_names[f_name] = idx
|
| 140 |
+
rx_options = sorted(list(unique_names.keys()))
|
| 141 |
+
sel_names = st.multiselect(
|
| 142 |
+
"Select reactions:",
|
| 143 |
+
options=rx_options,
|
| 144 |
+
default=rx_options[:1],
|
| 145 |
+
key="ds_rxn_sel"
|
| 146 |
+
)
|
| 147 |
+
selected_data = [unique_names[n] for n in sel_names if n in unique_names]
|
| 148 |
+
else:
|
| 149 |
+
reaction_list = metabolic_adata.var_names.tolist()
|
| 150 |
+
selected_data = st.multiselect(
|
| 151 |
+
"Select reactions:",
|
| 152 |
+
options=reaction_list,
|
| 153 |
+
default=reaction_list[:3],
|
| 154 |
+
key="ds_rxn_sel"
|
| 155 |
+
)
|
| 156 |
+
elif view_mode == "Pathway":
|
| 157 |
+
if "subsystems" in metabolic_adata.var.columns:
|
| 158 |
+
pathways = sorted([p for p in metabolic_adata.var["subsystems"].unique() if pd.notna(p)])
|
| 159 |
+
selected_data = st.multiselect(
|
| 160 |
+
"Select pathway(s):",
|
| 161 |
+
options=pathways,
|
| 162 |
+
default=pathways[:1] if pathways else [],
|
| 163 |
+
key="ds_pathway_sel"
|
| 164 |
+
)
|
| 165 |
+
else:
|
| 166 |
+
st.warning("No subsystem data available.")
|
| 167 |
+
|
| 168 |
+
if view_mode == "Domains":
|
| 169 |
+
_render_domain_overall(metabolic_adata)
|
| 170 |
+
elif view_mode == "Reactions":
|
| 171 |
+
if selected_data:
|
| 172 |
+
_render_reactions_mode(metabolic_adata, selected_data)
|
| 173 |
+
else:
|
| 174 |
+
st.info("Select at least one reaction to visualize.")
|
| 175 |
+
elif view_mode == "Pathway":
|
| 176 |
+
if selected_data:
|
| 177 |
+
_render_pathway_mode(metabolic_adata, selected_data)
|
| 178 |
+
else:
|
| 179 |
+
st.info("Select at least one pathway to visualize.")
|
| 180 |
+
|
| 181 |
+
def _render_metabolic_metadata(adata):
|
| 182 |
+
"""Render summary statistics as Material cards."""
|
| 183 |
+
n_spots = adata.n_obs
|
| 184 |
+
n_rxns = adata.n_vars
|
| 185 |
+
domain_counts = adata.obs['domain'].value_counts()
|
| 186 |
+
domains = sorted(domain_counts.index.tolist())
|
| 187 |
+
|
| 188 |
+
# Row 1: Global Stats
|
| 189 |
+
c1, c2, c3 = st.columns(3)
|
| 190 |
+
with c1:
|
| 191 |
+
st.markdown(f"""
|
| 192 |
+
<div class='material-card' style='border-top: 4px solid #d32f2f; text-align: center; padding: 1.5rem;'>
|
| 193 |
+
<i class='fas fa-microscope' style='font-size: 2rem; color: #d32f2f; margin-bottom: 0.5rem;'></i>
|
| 194 |
+
<div style='font-size: 1rem; color: #666; font-weight: 500;'>Total Spots</div>
|
| 195 |
+
<div style='font-size: 2.2rem; font-weight: 700; color: #333;'>{n_spots:,}</div>
|
| 196 |
+
</div>
|
| 197 |
+
""", unsafe_allow_html=True)
|
| 198 |
+
with c2:
|
| 199 |
+
st.markdown(f"""
|
| 200 |
+
<div class='material-card' style='border-top: 4px solid #d32f2f; text-align: center; padding: 1.5rem;'>
|
| 201 |
+
<i class='fas fa-vial-circle-check' style='font-size: 2rem; color: #d32f2f; margin-bottom: 0.5rem;'></i>
|
| 202 |
+
<div style='font-size: 1rem; color: #666; font-weight: 500;'>Total Reactions</div>
|
| 203 |
+
<div style='font-size: 2.2rem; font-weight: 700; color: #333;'>{n_rxns:,}</div>
|
| 204 |
+
</div>
|
| 205 |
+
""", unsafe_allow_html=True)
|
| 206 |
+
with c3:
|
| 207 |
+
st.markdown(f"""
|
| 208 |
+
<div class='material-card' style='border-top: 4px solid #d32f2f; text-align: center; padding: 1.5rem;'>
|
| 209 |
+
<i class='fas fa-shapes' style='font-size: 2rem; color: #d32f2f; margin-bottom: 0.5rem;'></i>
|
| 210 |
+
<div style='font-size: 1rem; color: #666; font-weight: 500;'>Unique Domains</div>
|
| 211 |
+
<div style='font-size: 2.2rem; font-weight: 700; color: #333;'>{len(domains)}</div>
|
| 212 |
+
</div>
|
| 213 |
+
""", unsafe_allow_html=True)
|
| 214 |
+
|
| 215 |
+
def _render_domain_overall(adata):
|
| 216 |
+
"""Boxen plot: per-spot mean flux across all reactions, by domain."""
|
| 217 |
+
with st.spinner("Building overall flux distribution…"):
|
| 218 |
+
try:
|
| 219 |
+
X = adata.X.toarray() if issparse(adata.X) else np.array(adata.X)
|
| 220 |
+
mean_flux = X.mean(axis=1)
|
| 221 |
+
|
| 222 |
+
df = pd.DataFrame({
|
| 223 |
+
"domain": adata.obs["domain"].astype(str).values,
|
| 224 |
+
"flux": mean_flux,
|
| 225 |
+
})
|
| 226 |
+
domain_order = sorted(df["domain"].unique())
|
| 227 |
+
n_dom = len(domain_order)
|
| 228 |
+
palette = sns.color_palette("tab10", n_dom)
|
| 229 |
+
|
| 230 |
+
fig, ax = plt.subplots(figsize=(max(8, n_dom * 1.5), 5))
|
| 231 |
+
sns.boxenplot(
|
| 232 |
+
data=df, x="domain", y="flux", fill=False,
|
| 233 |
+
order=domain_order, palette=palette, ax=ax,
|
| 234 |
+
)
|
| 235 |
+
add_significance_brackets(ax, df, domain_order, y_col="flux")
|
| 236 |
+
ax.set_xlabel("Metabolic Domain")
|
| 237 |
+
ax.set_ylabel("Mean Flux (all reactions)")
|
| 238 |
+
ax.set_title("Overall Metabolic Flux Distribution Across Domains")
|
| 239 |
+
plt.tight_layout()
|
| 240 |
+
display_plot_with_download(
|
| 241 |
+
fig,
|
| 242 |
+
"domain_overall_flux",
|
| 243 |
+
help_text="This boxen plot shows the distribution of per-spot mean metabolic flux across all reactions for each domain. It highlights the overall metabolic activity levels and identifies which domains are significantly more or less active."
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
plt.close(fig)
|
| 247 |
+
except Exception as e:
|
| 248 |
+
st.error(f"Error: {e}")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _render_reactions_mode(adata, selected):
|
| 252 |
+
"""Faceted boxen plots for selected reactions with significance brackets."""
|
| 253 |
+
with st.spinner("Building reaction flux distribution…"):
|
| 254 |
+
try:
|
| 255 |
+
df_long = adata_to_long_df(adata, reactions=selected)
|
| 256 |
+
domain_order = sorted(df_long["domain"].unique())
|
| 257 |
+
n_dom = len(domain_order)
|
| 258 |
+
n_rxn = len(selected)
|
| 259 |
+
col_wrap = min(3, n_rxn)
|
| 260 |
+
palette = sns.color_palette("tab10", n_dom)
|
| 261 |
+
|
| 262 |
+
fig = plt.figure(figsize=(6 * col_wrap, 5 * ((n_rxn + col_wrap - 1) // col_wrap)))
|
| 263 |
+
for i, rxn in enumerate(selected):
|
| 264 |
+
ax = fig.add_subplot(
|
| 265 |
+
(n_rxn + col_wrap - 1) // col_wrap,
|
| 266 |
+
col_wrap,
|
| 267 |
+
i + 1,
|
| 268 |
+
)
|
| 269 |
+
sub = df_long[df_long["reaction"] == rxn]
|
| 270 |
+
sns.boxenplot(
|
| 271 |
+
data=sub, x="domain", y="flux", fill=False,
|
| 272 |
+
order=domain_order, palette=palette, ax=ax,
|
| 273 |
+
)
|
| 274 |
+
add_significance_brackets(ax, sub, domain_order, y_col="flux")
|
| 275 |
+
|
| 276 |
+
# Use friendly name if available
|
| 277 |
+
title_text = rxn
|
| 278 |
+
if 'rxn_full_names' in adata.var.columns and rxn in adata.var_names:
|
| 279 |
+
title_text = adata.var.loc[rxn, 'rxn_full_names']
|
| 280 |
+
|
| 281 |
+
ax.set_title(title_text, fontsize=9)
|
| 282 |
+
ax.set_xlabel("Domain")
|
| 283 |
+
ax.set_ylabel("Flux")
|
| 284 |
+
|
| 285 |
+
plt.tight_layout()
|
| 286 |
+
# Generate specific reactions help
|
| 287 |
+
rxn_names = []
|
| 288 |
+
for rxn in selected:
|
| 289 |
+
if 'rxn_full_names' in adata.var.columns and rxn in adata.var_names:
|
| 290 |
+
rxn_names.append(adata.var.loc[rxn, 'rxn_full_names'])
|
| 291 |
+
else:
|
| 292 |
+
rxn_names.append(rxn)
|
| 293 |
+
|
| 294 |
+
rxn_list_str = ", ".join(rxn_names[:5]) + ("..." if len(rxn_names) > 5 else "")
|
| 295 |
+
|
| 296 |
+
display_plot_with_download(
|
| 297 |
+
fig,
|
| 298 |
+
"reaction_flux_domains",
|
| 299 |
+
help_text=f"These plots show the distribution of metabolic flux values for selected reactions (**{rxn_list_str}**) across different domains. It allows comparison of specific reaction activities and uses significance brackets to show statistical differences. Significant p-values indicate that the metabolic processing of these compounds differs geographically."
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
plt.close(fig)
|
| 303 |
+
except Exception as e:
|
| 304 |
+
st.error(f"Error: {e}")
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _render_pathway_mode(adata, selected_pathways):
|
| 308 |
+
"""Boxen plots for one or more pathways, each pooling all pathway reactions."""
|
| 309 |
+
with st.spinner("Building pathway flux distribution…"):
|
| 310 |
+
try:
|
| 311 |
+
n_pw = len(selected_pathways)
|
| 312 |
+
col_wrap = min(3, n_pw)
|
| 313 |
+
n_rows = (n_pw + col_wrap - 1) // col_wrap
|
| 314 |
+
|
| 315 |
+
fig = plt.figure(figsize=(7 * col_wrap, 5 * n_rows))
|
| 316 |
+
|
| 317 |
+
for i, pathway in enumerate(selected_pathways):
|
| 318 |
+
ax = fig.add_subplot(n_rows, col_wrap, i + 1)
|
| 319 |
+
pw_reactions = adata.var.index[adata.var["subsystems"] == pathway].tolist()
|
| 320 |
+
|
| 321 |
+
if not pw_reactions:
|
| 322 |
+
ax.set_title(f"{pathway}\n(no reactions)", fontsize=9)
|
| 323 |
+
ax.axis("off")
|
| 324 |
+
continue
|
| 325 |
+
|
| 326 |
+
df_long = adata_to_long_df(adata, reactions=pw_reactions)
|
| 327 |
+
domain_order = sorted(df_long["domain"].unique())
|
| 328 |
+
n_dom = len(domain_order)
|
| 329 |
+
palette = sns.color_palette("tab10", n_dom)
|
| 330 |
+
|
| 331 |
+
sns.boxenplot(
|
| 332 |
+
data=df_long, x="domain", y="flux", fill=False,
|
| 333 |
+
order=domain_order, palette=palette, ax=ax,
|
| 334 |
+
)
|
| 335 |
+
add_significance_brackets(ax, df_long, domain_order, y_col="flux")
|
| 336 |
+
ax.set_title(f"{pathway}\n({len(pw_reactions)} reactions)", fontsize=9)
|
| 337 |
+
ax.set_xlabel("Domain")
|
| 338 |
+
ax.set_ylabel("Flux")
|
| 339 |
+
|
| 340 |
+
plt.tight_layout()
|
| 341 |
+
# Generate specific pathway help
|
| 342 |
+
pathway_str = ", ".join(selected_pathways)
|
| 343 |
+
display_plot_with_download(
|
| 344 |
+
fig,
|
| 345 |
+
"pathway_flux_domains",
|
| 346 |
+
help_text=f"These plots show the distribution of metabolic flux pooled across all reactions within selected pathways (**{pathway_str}**) for each domain. It provides an overview of collective pathway activity and highlights inter-domain differences. High flux in specific domains suggests these regions are metabolic hubs for the selected biological processes."
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
plt.close(fig)
|
| 350 |
+
except Exception as e:
|
| 351 |
+
st.error(f"Error: {e}")
|
src/ui/plots/metabolic_interactions.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
import plotly.graph_objects as go
|
| 5 |
+
import plotly.express as px
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import logging
|
| 9 |
+
import re
|
| 10 |
+
import spmetatme.plotting as pl
|
| 11 |
+
from src.backend.infer_metabolic_interactions import TME_interactions
|
| 12 |
+
# Ensure spmetatme is in path if not already
|
| 13 |
+
|
| 14 |
+
from .utils import create_plotly_tme_plot, create_plotly_comm_plot, INTERACTION_COLORS, display_plotly_with_download
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
def render_metabolic_interactions(metabolic_adata):
|
| 19 |
+
"""
|
| 20 |
+
Investigate metabolic interaction types in the TME using Plotly.
|
| 21 |
+
"""
|
| 22 |
+
st.markdown("<h2 style='color: #d32f2f;'><i class='fas fa-project-diagram'></i> Metabolic Interaction Analysis</h2>", unsafe_allow_html=True)
|
| 23 |
+
|
| 24 |
+
if 'interaction_type' not in st.session_state:
|
| 25 |
+
st.session_state.interaction_type = None
|
| 26 |
+
if 'interaction_scores' not in st.session_state:
|
| 27 |
+
st.session_state.interaction_scores = None
|
| 28 |
+
|
| 29 |
+
if st.session_state.interaction_type is None or st.session_state.interaction_scores is None:
|
| 30 |
+
with st.spinner("Inferring metabolic interactions..."):
|
| 31 |
+
interaction_scores, interaction_type = TME_interactions(metabolic_adata)
|
| 32 |
+
st.session_state.interaction_scores = interaction_scores
|
| 33 |
+
st.session_state.interaction_type = interaction_type
|
| 34 |
+
|
| 35 |
+
interaction_type = st.session_state.interaction_type
|
| 36 |
+
interaction_scores = st.session_state.interaction_scores
|
| 37 |
+
|
| 38 |
+
DENSITY_LABELS = [
|
| 39 |
+
"Level 1 (Top 0.5%)", "Level 2 (Top 1%)", "Level 3 (Top 5%)", "Level 4 (Top 10%)",
|
| 40 |
+
"Level 5 (Top 20%)", "Level 6 (Top 40%)", "Level 7 (Top 60%)", "Level 8 (Top 80%)",
|
| 41 |
+
"Level 9 (Top 90%)", "Level 10 (All Edges)"
|
| 42 |
+
]
|
| 43 |
+
DENSITY_VALS = [99.5, 99, 95, 90, 80, 60, 40, 20, 10, 0]
|
| 44 |
+
DENSITY_MAP = dict(zip(DENSITY_LABELS, DENSITY_VALS))
|
| 45 |
+
|
| 46 |
+
tab1, tab2, tab3 = st.tabs(["Global Distribution", "Interaction Type Investigation", "Communication Score"])
|
| 47 |
+
|
| 48 |
+
with tab1:
|
| 49 |
+
st.markdown("#### Distribution of Interaction Types")
|
| 50 |
+
if interaction_type is not None and 'Interaction type' in interaction_type.columns:
|
| 51 |
+
counts = interaction_type['Interaction type'].value_counts()
|
| 52 |
+
col1, col2 = st.columns([1, 1.5], gap="large")
|
| 53 |
+
|
| 54 |
+
with col1:
|
| 55 |
+
st.markdown("##### Interaction Counts")
|
| 56 |
+
st.dataframe(counts.rename("Count"), use_container_width=True)
|
| 57 |
+
|
| 58 |
+
# Dynamic insight
|
| 59 |
+
if not counts.empty:
|
| 60 |
+
dominant_type = counts.index[0]
|
| 61 |
+
st.info(f"The most frequent interaction detected is **{dominant_type}**, representing { (counts.iloc[0] / counts.sum() * 100):.1f}% of identified metabolic edges.")
|
| 62 |
+
|
| 63 |
+
with col2:
|
| 64 |
+
fig = px.pie(
|
| 65 |
+
values=counts.values,
|
| 66 |
+
names=counts.index,
|
| 67 |
+
title="Global Interaction Frequency",
|
| 68 |
+
hole=0.4,
|
| 69 |
+
color=counts.index,
|
| 70 |
+
color_discrete_map=INTERACTION_COLORS
|
| 71 |
+
)
|
| 72 |
+
fig.update_layout(margin=dict(l=20, r=20, t=40, b=20))
|
| 73 |
+
display_plotly_with_download(
|
| 74 |
+
fig,
|
| 75 |
+
"interaction_distribution",
|
| 76 |
+
help_text="This pie chart summarizes the frequency of different metabolic interaction types across the entire tissue section. Competition often indicates shared metabolic dependencies, while Cooperation/Release suggests metabolic division of labor."
|
| 77 |
+
)
|
| 78 |
+
else:
|
| 79 |
+
st.warning("Interaction type data is not formatted as expected.")
|
| 80 |
+
with tab2:
|
| 81 |
+
st.markdown("#### Spatial Metabolic Interactions within the TME")
|
| 82 |
+
|
| 83 |
+
def clean_rxn_string(s):
|
| 84 |
+
if not isinstance(s, str): return str(s)
|
| 85 |
+
return re.sub(r'_(b|f)(?=\s|\]|$)', '', s)
|
| 86 |
+
|
| 87 |
+
if 'rxn_full_names' in metabolic_adata.var.columns:
|
| 88 |
+
var_subset = metabolic_adata.var[metabolic_adata.var['subsystems'] == 'Exchange/demand reactions']
|
| 89 |
+
|
| 90 |
+
unique_display_to_id = {}
|
| 91 |
+
for idx, row in var_subset.iterrows():
|
| 92 |
+
f_name = clean_rxn_string(row['rxn_full_names'])
|
| 93 |
+
clean_id = clean_rxn_string(idx)
|
| 94 |
+
|
| 95 |
+
if f_name not in unique_display_to_id:
|
| 96 |
+
unique_display_to_id[f_name] = clean_id
|
| 97 |
+
|
| 98 |
+
display_options = sorted(list(unique_display_to_id.keys()))
|
| 99 |
+
else:
|
| 100 |
+
raw_rxns = interaction_type['Reaction'].unique() if 'Reaction' in interaction_type.columns else []
|
| 101 |
+
unique_display_to_id = {clean_rxn_string(r): clean_rxn_string(r) for r in raw_rxns}
|
| 102 |
+
display_options = sorted(list(unique_display_to_id.keys()))
|
| 103 |
+
|
| 104 |
+
if display_options:
|
| 105 |
+
c1, c2 = st.columns([1.5, 1.5])
|
| 106 |
+
with c1:
|
| 107 |
+
selected_display = st.selectbox("Select Exchange Reaction:", options=display_options, key="mi_rxn_select")
|
| 108 |
+
selected_rxn_id = unique_display_to_id.get(selected_display)
|
| 109 |
+
with c2:
|
| 110 |
+
density = st.select_slider(
|
| 111 |
+
"Visual Edge Density:",
|
| 112 |
+
options=DENSITY_LABELS,
|
| 113 |
+
value="Level 7 (Top 60%)",
|
| 114 |
+
help="Adjust density (L1=Sparse to L10=Dense). 'Level 5' is a good starting point.",
|
| 115 |
+
key="mi_visual_density_slider"
|
| 116 |
+
)
|
| 117 |
+
threshold = DENSITY_MAP[density]
|
| 118 |
+
|
| 119 |
+
with st.spinner("Generating interaction map..."):
|
| 120 |
+
fig = create_plotly_tme_plot(
|
| 121 |
+
metabolic_adata,
|
| 122 |
+
interaction_type,
|
| 123 |
+
interaction_scores,
|
| 124 |
+
selected_rxn_id,
|
| 125 |
+
selected_display,
|
| 126 |
+
percentile_threshold=threshold
|
| 127 |
+
)
|
| 128 |
+
if fig:
|
| 129 |
+
display_plotly_with_download(
|
| 130 |
+
fig,
|
| 131 |
+
f"interaction_map_{selected_rxn_id}",
|
| 132 |
+
help_text=f"This network plot visualizes metabolic interactions for **{selected_display}**. It shows how different regions interact through metabolite exchange, helping identify metabolic source (producing) and sink (consuming) relationships for this specific reaction."
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
else:
|
| 136 |
+
st.info(f"No interactions detected for reaction '{selected_display}' at the selected density.")
|
| 137 |
+
|
| 138 |
+
with tab3:
|
| 139 |
+
st.markdown("#### Cell-Cell Metabolic Communication Score")
|
| 140 |
+
|
| 141 |
+
c_spacer, c_dense = st.columns([2, 1])
|
| 142 |
+
with c_dense:
|
| 143 |
+
density_comm = st.select_slider(
|
| 144 |
+
"Communication Edge Density:",
|
| 145 |
+
options=DENSITY_LABELS,
|
| 146 |
+
value="Level 5 (Top 20%)",
|
| 147 |
+
key="comm_density_slider"
|
| 148 |
+
)
|
| 149 |
+
threshold_comm = DENSITY_MAP[density_comm]
|
| 150 |
+
|
| 151 |
+
with st.spinner("Generating communication map..."):
|
| 152 |
+
fig_comm = create_plotly_comm_plot(interaction_scores, metabolic_adata, percentile_threshold=threshold_comm)
|
| 153 |
+
if fig_comm:
|
| 154 |
+
display_plotly_with_download(
|
| 155 |
+
fig_comm,
|
| 156 |
+
"communication_map",
|
| 157 |
+
help_text="The Communication Score represents the overall metabolic exchange strength between cells or spots. This map highlights regional 'hotspots' of metabolic communication within the tumor microenvironment."
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
else:
|
| 161 |
+
st.info("No communication score data available.")
|
| 162 |
+
|
src/ui/plots/metabolite_balance.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import scanpy as sc
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import logging
|
| 7 |
+
import textwrap
|
| 8 |
+
import anndata as ad
|
| 9 |
+
|
| 10 |
+
import spmetatme.plotting as pl
|
| 11 |
+
from spmetatme.utils import get_metabolite_adata
|
| 12 |
+
|
| 13 |
+
from .utils import display_plot_with_download, display_formatted_table
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
def render_metabolite_balance_analysis(metabolic_adata):
|
| 18 |
+
"""Render metabolite balance analysis with tabs and standard project theme."""
|
| 19 |
+
# Align theme color with other pages (#d32f2f)
|
| 20 |
+
st.markdown("<h2 style='color: #d32f2f;'><i class='fas fa-vial'></i> Metabolite Balance Analysis</h2>", unsafe_allow_html=True)
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
if 'met_adata' not in st.session_state or st.session_state.get('met_adata_source_id') != id(metabolic_adata):
|
| 24 |
+
with st.spinner("Extracting metabolite-level data..."):
|
| 25 |
+
met_adata = get_metabolite_adata(metabolic_adata)
|
| 26 |
+
st.session_state.met_adata = met_adata
|
| 27 |
+
st.session_state.met_adata_source_id = id(metabolic_adata)
|
| 28 |
+
else:
|
| 29 |
+
met_adata = st.session_state.met_adata
|
| 30 |
+
|
| 31 |
+
if met_adata is None:
|
| 32 |
+
st.error("The loaded data does not contain metabolite-level information. Please ensure you are using spMetaTME output containing '.obsm['metabolites']'.")
|
| 33 |
+
return
|
| 34 |
+
except Exception as e:
|
| 35 |
+
st.error(f"Error processing metabolite data: {e}")
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
tab1, tab2, tab3 = st.tabs(["Ridge Plot", "Spatial Distribution", "Differential Heatmap"])
|
| 39 |
+
|
| 40 |
+
with tab1:
|
| 41 |
+
st.markdown("#### Metabolite Distribution Ridge Plot")
|
| 42 |
+
if pl and hasattr(pl, 'metabolite_ridges_plot'):
|
| 43 |
+
c1, c2 = st.columns([1, 2])
|
| 44 |
+
with c1:
|
| 45 |
+
n_cols = st.slider("Number of columns", 1, 5, 3, key="ridge_cols")
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
with st.spinner("Generating ridge plot..."):
|
| 49 |
+
pl.metabolite_ridges_plot(met_adata, n_cols=n_cols)
|
| 50 |
+
fig = plt.gcf()
|
| 51 |
+
display_plot_with_download(fig, "metabolite_ridge_plot")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
st.error(f"Error rendering ridge plot: {e}")
|
| 54 |
+
else:
|
| 55 |
+
st.warning("Ridge plot function not found in spmetatme.plotting.")
|
| 56 |
+
|
| 57 |
+
with tab2:
|
| 58 |
+
st.markdown("#### Spatial Metabolite Distribution")
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
library_id = next(iter(met_adata.uns["spatial"]))
|
| 62 |
+
img_key = "hires" if "hires" in met_adata.uns["spatial"][library_id]["images"] else "downscaled_fullres"
|
| 63 |
+
except (KeyError, StopIteration):
|
| 64 |
+
img_key = "hires"
|
| 65 |
+
|
| 66 |
+
if 'metabolite_names' in met_adata.var.columns:
|
| 67 |
+
all_names = met_adata.var['metabolite_names'].dropna().unique().tolist()
|
| 68 |
+
met_options = sorted([str(n) for n in all_names if str(n).strip() != ""])
|
| 69 |
+
else:
|
| 70 |
+
met_options = sorted(met_adata.var_names.tolist())
|
| 71 |
+
|
| 72 |
+
col1, col2 = st.columns([2, 1])
|
| 73 |
+
with col1:
|
| 74 |
+
selected_names = st.multiselect(
|
| 75 |
+
"Select Metabolite Names:",
|
| 76 |
+
options=met_options,
|
| 77 |
+
default=met_options[:1] if met_options else [],
|
| 78 |
+
key="met_spatial_name_select"
|
| 79 |
+
)
|
| 80 |
+
with col2:
|
| 81 |
+
spot_size = st.slider("Spot size", 0.1, 10.0, 1.5, step=0.1, key="met_spot_size")
|
| 82 |
+
|
| 83 |
+
if selected_names and hasattr(pl, 'plot_spatial_metabolites'):
|
| 84 |
+
try:
|
| 85 |
+
with st.spinner("Generating spatial maps..."):
|
| 86 |
+
pl.plot_spatial_metabolites(met_adata, metabolite_names=selected_names, size=spot_size, img_key=img_key)
|
| 87 |
+
fig = plt.gcf()
|
| 88 |
+
display_plot_with_download(fig, "spatial_metabolite_distribution")
|
| 89 |
+
except Exception as e:
|
| 90 |
+
st.error(f"Error rendering spatial maps: {e}")
|
| 91 |
+
logger.exception("Spatial metabolite plot error")
|
| 92 |
+
elif not selected_names:
|
| 93 |
+
st.info("Please select at least one metabolite to visualize.")
|
| 94 |
+
elif not hasattr(pl, 'plot_spatial_metabolites'):
|
| 95 |
+
st.warning("spmetatme.plotting module function 'plot_spatial_metabolites' not available.")
|
| 96 |
+
|
| 97 |
+
with tab3:
|
| 98 |
+
st.markdown("#### Differential Metabolite Analysis Heatmap")
|
| 99 |
+
if hasattr(pl, 'plot_differential_metabolite_heatmap'):
|
| 100 |
+
c1, c2 = st.columns([1, 1])
|
| 101 |
+
with c1:
|
| 102 |
+
top_n_heat = st.slider("Top N metabolites per domain", 2, 20, 5, key="heat_top_n")
|
| 103 |
+
with c2:
|
| 104 |
+
cluster_rows = st.checkbox("Cluster rows", value=True, key="heat_cluster")
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
with st.spinner("Analyzing differential metabolites..."):
|
| 108 |
+
dataset_name = metabolic_adata.uns.get('sample_name', 'Metabolites')
|
| 109 |
+
df = pl.plot_differential_metabolite_heatmap(
|
| 110 |
+
met_adata,
|
| 111 |
+
top_n=top_n_heat,
|
| 112 |
+
row_cluster=cluster_rows,
|
| 113 |
+
return_marker_df=True
|
| 114 |
+
)
|
| 115 |
+
fig = plt.gcf()
|
| 116 |
+
|
| 117 |
+
col_p, col_t = st.columns([1.4, 1.0], gap="medium")
|
| 118 |
+
with col_p:
|
| 119 |
+
display_plot_with_download(
|
| 120 |
+
fig,
|
| 121 |
+
"differential_metabolite_heatmap",
|
| 122 |
+
help_text="This heatmap shows metabolites that are significantly different between spatial domains. Warm colors (red) indicate higher balance (production), and cool colors (blue) indicate lower balance (consumption)."
|
| 123 |
+
)
|
| 124 |
+
with col_t:
|
| 125 |
+
if df is not None and not df.empty:
|
| 126 |
+
display_formatted_table(df, "Differential Analysis Results")
|
| 127 |
+
csv = df.to_csv(index=False).encode('utf-8')
|
| 128 |
+
st.download_button(
|
| 129 |
+
label="Download Results (CSV)",
|
| 130 |
+
data=csv,
|
| 131 |
+
file_name=f"diff_metabolites_{dataset_name}.csv",
|
| 132 |
+
mime="text/csv",
|
| 133 |
+
icon=":material/download:",
|
| 134 |
+
use_container_width=True
|
| 135 |
+
)
|
| 136 |
+
else:
|
| 137 |
+
st.info("No statistically significant metabolites found with current parameters.")
|
| 138 |
+
except Exception as e:
|
| 139 |
+
st.error(f"Error rendering heatmap: {e}")
|
| 140 |
+
logger.exception("Differential metabolite heatmap error")
|
| 141 |
+
else:
|
| 142 |
+
st.warning("Differential heatmap function not found in spmetatme.plotting.")
|
src/ui/plots/spatial_flux_map.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import scanpy as sc
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import logging
|
| 7 |
+
import textwrap
|
| 8 |
+
from .utils import display_plot_with_download, display_interactive_spatial_plot, display_plotly_with_download
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
def render_spatial_flux_map(metabolic_adata):
|
| 13 |
+
"""Render spatial flux maps with Red theme."""
|
| 14 |
+
st.markdown("<h2 style='color: #d32f2f;'><i class='fas fa-map-location-dot'></i> Spatial Metabolic flux</h2>", unsafe_allow_html=True)
|
| 15 |
+
|
| 16 |
+
# 1. Determine layout and render primary filters
|
| 17 |
+
viz_choice = st.session_state.get("sp_viz_choice", "Domains")
|
| 18 |
+
|
| 19 |
+
if viz_choice == "Domains":
|
| 20 |
+
c1, c2, c3 = st.columns([1.5, 1.2, 1.3])
|
| 21 |
+
else:
|
| 22 |
+
c1, c2, c3, c4 = st.columns([1.2, 1.8, 1.0, 1.2])
|
| 23 |
+
|
| 24 |
+
with c1:
|
| 25 |
+
viz_choice = st.selectbox("Analysis Type:", options=["Domains", "Reactions", "Pathways"], key="sp_viz_choice")
|
| 26 |
+
|
| 27 |
+
# Plot mode and spot size are always present, but column varies
|
| 28 |
+
with (c3 if viz_choice == "Domains" else c4):
|
| 29 |
+
plot_mode = st.radio("Plot Mode:", ["Static", "Interactive"], horizontal=True, key="sp_mode")
|
| 30 |
+
|
| 31 |
+
with (c2 if viz_choice == "Domains" else c3):
|
| 32 |
+
spot_size = st.slider("Spot Size:", 0.5, 5.0, 1.2, 0.5) if plot_mode == "Static" else st.slider("Spot Size:", 1, 20, 6)
|
| 33 |
+
|
| 34 |
+
selected_items = []
|
| 35 |
+
|
| 36 |
+
# 2. Render selective filters (only for non-domain modes in col2)
|
| 37 |
+
if viz_choice != "Domains":
|
| 38 |
+
with c2:
|
| 39 |
+
if viz_choice == "Reactions":
|
| 40 |
+
if 'rxn_full_names' in metabolic_adata.var.columns:
|
| 41 |
+
unique_names = {}
|
| 42 |
+
for idx, row in metabolic_adata.var.iterrows():
|
| 43 |
+
f_name = str(row['rxn_full_names'])
|
| 44 |
+
if f_name not in unique_names:
|
| 45 |
+
unique_names[f_name] = idx
|
| 46 |
+
rx_options = sorted(list(unique_names.keys()))
|
| 47 |
+
if plot_mode == "Interactive":
|
| 48 |
+
sel_name = st.selectbox("Select Reaction:", options=rx_options, key="sp_rx_single")
|
| 49 |
+
selected_items = [unique_names[sel_name]] if sel_name else []
|
| 50 |
+
else:
|
| 51 |
+
sel_names = st.multiselect("Select Reactions:", options=rx_options, default=rx_options[:1], key="sp_rx_multi")
|
| 52 |
+
selected_items = [unique_names[n] for n in sel_names if n in unique_names]
|
| 53 |
+
else:
|
| 54 |
+
rx_options = metabolic_adata.var_names.tolist()
|
| 55 |
+
if plot_mode == "Interactive":
|
| 56 |
+
sel = st.selectbox("Select Reaction:", options=rx_options, key="sp_rx_single")
|
| 57 |
+
selected_items = [sel] if sel else []
|
| 58 |
+
else:
|
| 59 |
+
selected_items = st.multiselect("Select Reactions:", options=rx_options, default=rx_options[:1], key="sp_rx_multi")
|
| 60 |
+
|
| 61 |
+
elif viz_choice == "Pathways":
|
| 62 |
+
if 'subsystems' in metabolic_adata.var.columns:
|
| 63 |
+
path_options = sorted([p for p in metabolic_adata.var['subsystems'].unique() if pd.notna(p)])
|
| 64 |
+
if plot_mode == "Interactive":
|
| 65 |
+
sel = st.selectbox("Select Pathway:", options=path_options, key="sp_path_single")
|
| 66 |
+
selected_items = [sel] if sel else []
|
| 67 |
+
else:
|
| 68 |
+
selected_items = st.multiselect("Select Pathways:", options=path_options, default=path_options[:1], key="sp_path_multi")
|
| 69 |
+
else:
|
| 70 |
+
st.warning("No pathway data.")
|
| 71 |
+
|
| 72 |
+
# 3. Visualization logic
|
| 73 |
+
try:
|
| 74 |
+
library_id = next(iter(metabolic_adata.uns["spatial"]))
|
| 75 |
+
img_key = "hires" if "hires" in metabolic_adata.uns["spatial"][library_id]["images"] else "downscaled_fullres"
|
| 76 |
+
|
| 77 |
+
if viz_choice == "Domains":
|
| 78 |
+
if plot_mode == "Interactive":
|
| 79 |
+
display_interactive_spatial_plot(
|
| 80 |
+
metabolic_adata,
|
| 81 |
+
color_key="domain",
|
| 82 |
+
spot_size=spot_size,
|
| 83 |
+
plot_name="spatial_domain_plotly",
|
| 84 |
+
title="Domain Assignment",
|
| 85 |
+
help_text="This map highlights the spatial domains assigned byclustering spots with similar metabolic flux patterns. It shows the geographical organization of the tissue's metabolic environment."
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
else:
|
| 89 |
+
fig, ax = plt.subplots(figsize=(10, 8))
|
| 90 |
+
sc.pl.spatial(metabolic_adata, img_key=img_key, color=['domain'], size=spot_size, show=False, ax=ax)
|
| 91 |
+
display_plot_with_download(
|
| 92 |
+
fig,
|
| 93 |
+
"spatial_domain",
|
| 94 |
+
help_text="This map shows the spatial distribution of metabolic domains across the tissue. Each domain represents a cluster of spots with similar metabolic flux profiles."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
plt.close(fig)
|
| 98 |
+
|
| 99 |
+
elif viz_choice == "Pathways":
|
| 100 |
+
if not selected_items:
|
| 101 |
+
st.info("Please select a pathway.")
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
if plot_mode == "Interactive":
|
| 105 |
+
target = selected_items[0]
|
| 106 |
+
rx_list = metabolic_adata.var[metabolic_adata.var['subsystems'] == target].index.tolist()
|
| 107 |
+
X_sub = metabolic_adata[:, rx_list].X
|
| 108 |
+
pathway_avg = np.array(X_sub.mean(axis=1)).flatten() if not hasattr(X_sub, "toarray") else np.array(X_sub.toarray().mean(axis=1)).flatten()
|
| 109 |
+
metabolic_adata.obs[f'temp_{target}'] = pathway_avg
|
| 110 |
+
|
| 111 |
+
wrapper = textwrap.TextWrapper(width=40)
|
| 112 |
+
display_title = wrapper.fill(text=f"Pathway: {target}")
|
| 113 |
+
display_interactive_spatial_plot(
|
| 114 |
+
metabolic_adata,
|
| 115 |
+
color_key=f'temp_{target}',
|
| 116 |
+
spot_size=spot_size,
|
| 117 |
+
plot_name=f"spatial_{target}_avg_plotly",
|
| 118 |
+
title=display_title,
|
| 119 |
+
help_text=f"This interactive map shows the averaged flux distribution for the **{target}** pathway. High intensity regions highlight where this metabolic process is most active within the tissue."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
del metabolic_adata.obs[f'temp_{target}']
|
| 123 |
+
else:
|
| 124 |
+
# Static grid for pathways
|
| 125 |
+
per_page = 4
|
| 126 |
+
total = len(selected_items)
|
| 127 |
+
pages = (total + per_page - 1) // per_page
|
| 128 |
+
if "sp_path_page" not in st.session_state: st.session_state.sp_path_page = 1
|
| 129 |
+
if st.session_state.sp_path_page > pages: st.session_state.sp_path_page = 1
|
| 130 |
+
|
| 131 |
+
curr_items = selected_items[(st.session_state.sp_path_page-1)*per_page : st.session_state.sp_path_page*per_page]
|
| 132 |
+
n_cols = 2 if len(curr_items) > 1 else 1
|
| 133 |
+
n_rows = (len(curr_items) + n_cols - 1) // n_cols
|
| 134 |
+
fig, axes = plt.subplots(n_rows, n_cols, figsize=(8*n_cols, 7*n_rows))
|
| 135 |
+
|
| 136 |
+
if len(curr_items) == 1: axes = np.array([[axes]])
|
| 137 |
+
elif n_rows == 1: axes = axes.reshape(1, -1)
|
| 138 |
+
elif n_cols == 1: axes = axes.reshape(-1, 1)
|
| 139 |
+
|
| 140 |
+
for i, target in enumerate(curr_items):
|
| 141 |
+
r, c = i // n_cols, i % n_cols
|
| 142 |
+
rx_list = metabolic_adata.var[metabolic_adata.var['subsystems'] == target].index.tolist()
|
| 143 |
+
X_sub = metabolic_adata[:, rx_list].X
|
| 144 |
+
avg = np.array(X_sub.mean(axis=1)).flatten() if not hasattr(X_sub, "toarray") else np.array(X_sub.toarray().mean(axis=1)).flatten()
|
| 145 |
+
metabolic_adata.obs['tmp_avg'] = avg
|
| 146 |
+
sc.pl.spatial(metabolic_adata, img_key=img_key, color=['tmp_avg'], size=spot_size, cmap='jet', show=False, ax=axes[r,c])
|
| 147 |
+
|
| 148 |
+
wrapper = textwrap.TextWrapper(width=40)
|
| 149 |
+
axes[r,c].set_title(wrapper.fill(text=str(target)), fontsize=12)
|
| 150 |
+
|
| 151 |
+
for j in range(len(curr_items), n_rows*n_cols): axes[j//n_cols, j%n_cols].axis('off')
|
| 152 |
+
plt.tight_layout()
|
| 153 |
+
# Generate names for help text
|
| 154 |
+
target_names = ", ".join([str(t) for t in curr_items])
|
| 155 |
+
display_plot_with_download(
|
| 156 |
+
fig,
|
| 157 |
+
f"spatial_pathway_p{st.session_state.sp_path_page}",
|
| 158 |
+
help_text=f"This spatial flux map visualizes the spatial distribution of averaged flux for the pathways: **{target_names}**. It helps localize pathway activities within the tissue."
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
plt.close(fig)
|
| 162 |
+
if 'tmp_avg' in metabolic_adata.obs: del metabolic_adata.obs['tmp_avg']
|
| 163 |
+
|
| 164 |
+
if pages > 1:
|
| 165 |
+
c_p1, c_p2, c_p3 = st.columns([1,2,1])
|
| 166 |
+
if c_p1.button("Prev Pathway", key="pw_prev"): st.session_state.sp_path_page -= 1; st.rerun()
|
| 167 |
+
c_p2.markdown(f"<center>Pathway Page {st.session_state.sp_path_page} / {pages}</center>", unsafe_allow_html=True)
|
| 168 |
+
if c_p3.button("Next Pathway", key="pw_next"): st.session_state.sp_path_page += 1; st.rerun()
|
| 169 |
+
|
| 170 |
+
elif selected_items:
|
| 171 |
+
if plot_mode == "Interactive":
|
| 172 |
+
target = selected_items[0]
|
| 173 |
+
display_title = target
|
| 174 |
+
if 'rxn_full_names' in metabolic_adata.var.columns and target in metabolic_adata.var_names:
|
| 175 |
+
display_title = metabolic_adata.var.loc[target, 'rxn_full_names']
|
| 176 |
+
|
| 177 |
+
wrapper = textwrap.TextWrapper(width=40)
|
| 178 |
+
display_interactive_spatial_plot(
|
| 179 |
+
metabolic_adata,
|
| 180 |
+
color_key=target,
|
| 181 |
+
spot_size=spot_size,
|
| 182 |
+
plot_name=f"spatial_{target}_plotly",
|
| 183 |
+
title=wrapper.fill(text=f"Reaction: {display_title}"),
|
| 184 |
+
help_text=f"This interactive spatial map visualizes the flux distribution for the reaction **{display_title}**. You can explore its metabolic activity across different spatial domains."
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
else:
|
| 188 |
+
per_page = 8
|
| 189 |
+
total = len(selected_items)
|
| 190 |
+
pages = (total + per_page - 1) // per_page
|
| 191 |
+
if "spatial_flux_page" not in st.session_state: st.session_state.spatial_flux_page = 1
|
| 192 |
+
if st.session_state.spatial_flux_page > pages: st.session_state.spatial_flux_page = 1
|
| 193 |
+
|
| 194 |
+
curr_rx = selected_items[(st.session_state.spatial_flux_page-1)*per_page : st.session_state.spatial_flux_page*per_page]
|
| 195 |
+
n_cols = min(2, len(curr_rx))
|
| 196 |
+
n_rows = (len(curr_rx) + n_cols - 1) // n_cols
|
| 197 |
+
fig, axes = plt.subplots(n_rows, n_cols, figsize=(8*n_cols, 7*n_rows))
|
| 198 |
+
|
| 199 |
+
if len(curr_rx) == 1: axes = np.array([[axes]])
|
| 200 |
+
elif n_rows == 1: axes = axes.reshape(1, -1)
|
| 201 |
+
elif n_cols == 1: axes = axes.reshape(-1, 1)
|
| 202 |
+
|
| 203 |
+
for i, rx in enumerate(curr_rx):
|
| 204 |
+
r, c = i // n_cols, i % n_cols
|
| 205 |
+
sc.pl.spatial(metabolic_adata, img_key=img_key, color=[rx], size=spot_size, cmap='jet', show=False, ax=axes[r,c])
|
| 206 |
+
|
| 207 |
+
display_title = rx
|
| 208 |
+
if 'rxn_full_names' in metabolic_adata.var.columns and rx in metabolic_adata.var_names:
|
| 209 |
+
display_title = metabolic_adata.var.loc[rx, 'rxn_full_names']
|
| 210 |
+
|
| 211 |
+
wrapper = textwrap.TextWrapper(width=40)
|
| 212 |
+
axes[r,c].set_title(wrapper.fill(text=display_title), fontsize=10)
|
| 213 |
+
axes[r,c].axis('off')
|
| 214 |
+
|
| 215 |
+
for j in range(len(curr_rx), n_rows*n_cols): axes[j//n_cols, j%n_cols].axis('off')
|
| 216 |
+
plt.tight_layout()
|
| 217 |
+
# Generate names for help text
|
| 218 |
+
rx_names_list = []
|
| 219 |
+
for rx in curr_rx:
|
| 220 |
+
if 'rxn_full_names' in metabolic_adata.var.columns and rx in metabolic_adata.var_names:
|
| 221 |
+
rx_names_list.append(metabolic_adata.var.loc[rx, 'rxn_full_names'])
|
| 222 |
+
else:
|
| 223 |
+
rx_names_list.append(rx)
|
| 224 |
+
|
| 225 |
+
rx_names_str = ", ".join(rx_names_list)
|
| 226 |
+
display_plot_with_download(
|
| 227 |
+
fig,
|
| 228 |
+
f"spatial_flux_p{st.session_state.spatial_flux_page}",
|
| 229 |
+
help_text=f"These maps show the spatial distribution of flux for: **{rx_names_str}**, allowing visualization of where specific metabolic processes are active."
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
plt.close(fig)
|
| 233 |
+
|
| 234 |
+
if pages > 1:
|
| 235 |
+
cx1, cx2, cx3 = st.columns([1,2,1])
|
| 236 |
+
if cx1.button("Previous Page", key="sf_prev"): st.session_state.spatial_flux_page -= 1; st.rerun()
|
| 237 |
+
cx2.markdown(f"<center>Reaction Page {st.session_state.spatial_flux_page} of {pages}</center>", unsafe_allow_html=True)
|
| 238 |
+
if cx3.button("Next Page", key="sf_next"): st.session_state.spatial_flux_page += 1; st.rerun()
|
| 239 |
+
except Exception as e:
|
| 240 |
+
st.error(f"Error: {e}")
|
| 241 |
+
|
src/ui/plots/umap_embedding.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import scanpy as sc
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import numpy as np
|
| 5 |
+
import textwrap
|
| 6 |
+
from .utils import display_plot_with_download, display_interactive_spatial_plot, display_plotly_with_download
|
| 7 |
+
|
| 8 |
+
def render_umap_embedding(metabolic_adata):
|
| 9 |
+
"""Render UMAP embedding with Red theme."""
|
| 10 |
+
st.markdown("<h2 style='color: #d32f2f;'><i class='fas fa-palette'></i> UMAP Analysis</h2>", unsafe_allow_html=True)
|
| 11 |
+
|
| 12 |
+
umap_viz_type = st.session_state.get("u_v_t", "Domain")
|
| 13 |
+
|
| 14 |
+
if umap_viz_type == "Domain":
|
| 15 |
+
c1, c2 = st.columns([1.5, 1.5])
|
| 16 |
+
else:
|
| 17 |
+
c1, c2, c3 = st.columns([1.2, 1.8, 1.2])
|
| 18 |
+
|
| 19 |
+
with c1:
|
| 20 |
+
umap_viz_type = st.selectbox("Color By:", options=["Domain", "Reaction", "Pathway"], key="u_v_t")
|
| 21 |
+
|
| 22 |
+
with (c2 if umap_viz_type == "Domain" else c3):
|
| 23 |
+
plot_mode = st.radio("Plot Mode:", ["Static", "Interactive"], horizontal=True, key="u_mode")
|
| 24 |
+
|
| 25 |
+
selected_items = []
|
| 26 |
+
|
| 27 |
+
if umap_viz_type != "Domain":
|
| 28 |
+
with c2:
|
| 29 |
+
if umap_viz_type == "Reaction":
|
| 30 |
+
if 'rxn_full_names' in metabolic_adata.var.columns:
|
| 31 |
+
# Map full name to ID for user selection
|
| 32 |
+
unique_names = {}
|
| 33 |
+
for idx, row in metabolic_adata.var.iterrows():
|
| 34 |
+
f_name = str(row['rxn_full_names'])
|
| 35 |
+
if f_name not in unique_names:
|
| 36 |
+
unique_names[f_name] = idx
|
| 37 |
+
rx_options = sorted(list(unique_names.keys()))
|
| 38 |
+
|
| 39 |
+
if plot_mode == "Interactive":
|
| 40 |
+
sel_name = st.selectbox("Select Reaction:", options=rx_options, key="u_rx_single")
|
| 41 |
+
selected_items = [unique_names[sel_name]] if sel_name else []
|
| 42 |
+
else:
|
| 43 |
+
sel_names = st.multiselect("Select Reactions:", options=rx_options, default=rx_options[:1], key="u_rx_multi")
|
| 44 |
+
selected_items = [unique_names[n] for n in sel_names if n in unique_names]
|
| 45 |
+
else:
|
| 46 |
+
rx_options = metabolic_adata.var_names.tolist()
|
| 47 |
+
if plot_mode == "Interactive":
|
| 48 |
+
sel = st.selectbox("Select Reaction:", options=rx_options, key="u_rx_single")
|
| 49 |
+
selected_items = [sel] if sel else []
|
| 50 |
+
else:
|
| 51 |
+
selected_items = st.multiselect("Select Reactions:", options=rx_options, default=rx_options[:1], key="u_rx_multi")
|
| 52 |
+
|
| 53 |
+
elif umap_viz_type == "Pathway":
|
| 54 |
+
if 'subsystems' in metabolic_adata.var.columns:
|
| 55 |
+
import pandas as pd
|
| 56 |
+
path_options = sorted([p for p in metabolic_adata.var['subsystems'].unique() if pd.notna(p)])
|
| 57 |
+
if plot_mode == "Interactive":
|
| 58 |
+
sel = st.selectbox("Select Pathway:", options=path_options, key="u_path_single")
|
| 59 |
+
selected_items = [sel] if sel else []
|
| 60 |
+
else:
|
| 61 |
+
selected_items = st.multiselect("Select Pathways:", options=path_options, default=path_options[:1], key="u_path_multi")
|
| 62 |
+
else:
|
| 63 |
+
st.warning("No pathway data.")
|
| 64 |
+
|
| 65 |
+
if 'X_umap' not in metabolic_adata.obsm:
|
| 66 |
+
with st.spinner("Calculating UMAP..."):
|
| 67 |
+
sc.pp.pca(metabolic_adata, n_comps=50)
|
| 68 |
+
sc.pp.neighbors(metabolic_adata, n_neighbors=15, n_pcs=50)
|
| 69 |
+
sc.tl.umap(metabolic_adata)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
if plot_mode == "Interactive" and (umap_viz_type == "Domain" or selected_items):
|
| 73 |
+
import plotly.express as px
|
| 74 |
+
import pandas as pd
|
| 75 |
+
|
| 76 |
+
umap_coords = metabolic_adata.obsm['X_umap']
|
| 77 |
+
target = selected_items[0] if selected_items else "Domain"
|
| 78 |
+
display_title = target
|
| 79 |
+
if umap_viz_type == "Reaction" and 'rxn_full_names' in metabolic_adata.var.columns:
|
| 80 |
+
if target in metabolic_adata.var_names:
|
| 81 |
+
display_title = metabolic_adata.var.loc[target, 'rxn_full_names']
|
| 82 |
+
|
| 83 |
+
if umap_viz_type == "Domain":
|
| 84 |
+
vals = metabolic_adata.obs["domain"].astype(str).values
|
| 85 |
+
color_scale = None # Use default qualitative for domain
|
| 86 |
+
color_label = "Domain"
|
| 87 |
+
elif target in metabolic_adata.var_names:
|
| 88 |
+
idx = metabolic_adata.var_names.get_loc(target)
|
| 89 |
+
raw = metabolic_adata.X[:, idx]
|
| 90 |
+
vals = raw.toarray().flatten() if hasattr(raw, "toarray") else np.asarray(raw).flatten()
|
| 91 |
+
color_scale = "Jet"
|
| 92 |
+
color_label = "Flux"
|
| 93 |
+
else:
|
| 94 |
+
# Pathway
|
| 95 |
+
rx_list = metabolic_adata.var[metabolic_adata.var['subsystems'] == target].index.tolist()
|
| 96 |
+
X_sub = metabolic_adata[:, rx_list].X
|
| 97 |
+
vals = np.array(X_sub.mean(axis=1)).flatten() if not hasattr(X_sub, "toarray") else np.array(X_sub.toarray().mean(axis=1)).flatten()
|
| 98 |
+
color_scale = "Jet"
|
| 99 |
+
color_label = "Flux"
|
| 100 |
+
|
| 101 |
+
df_umap = pd.DataFrame({
|
| 102 |
+
"UMAP1": umap_coords[:, 0],
|
| 103 |
+
"UMAP2": umap_coords[:, 1],
|
| 104 |
+
"color": vals,
|
| 105 |
+
"Domain": metabolic_adata.obs["domain"].values if "domain" in metabolic_adata.obs.columns else "N/A",
|
| 106 |
+
"Spot": metabolic_adata.obs_names
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
fig = px.scatter(df_umap, x="UMAP1", y="UMAP2", color="color",
|
| 110 |
+
hover_data=["Domain", "Spot"],
|
| 111 |
+
color_continuous_scale=color_scale if color_scale else None,
|
| 112 |
+
title=f"UMAP Analysis: {display_title}")
|
| 113 |
+
|
| 114 |
+
fig.update_layout(
|
| 115 |
+
template="simple_white",
|
| 116 |
+
coloraxis_colorbar=dict(title=color_label) if color_scale else None,
|
| 117 |
+
legend_title_text="Domain" if umap_viz_type == "Domain" else None,
|
| 118 |
+
yaxis=dict(scaleanchor="x", scaleratio=1),
|
| 119 |
+
width=700, height=700,
|
| 120 |
+
xaxis=dict(showgrid=False, zeroline=False),
|
| 121 |
+
yaxis_showgrid=False, yaxis_zeroline=False
|
| 122 |
+
)
|
| 123 |
+
# Dynamic help text
|
| 124 |
+
help_msg = f"Uniform Manifold Approximation and Projection (UMAP) is used for dimensionality reduction. "
|
| 125 |
+
if umap_viz_type == "Reaction":
|
| 126 |
+
help_msg += f"This plot shows the flux distribution of **{display_title}** in the reduced feature space."
|
| 127 |
+
elif umap_viz_type == "Pathway":
|
| 128 |
+
help_msg += f"Across the UMAP manifold, we visualize the average flux for the **{target}** pathway."
|
| 129 |
+
else:
|
| 130 |
+
help_msg += "Spots are colored by metabolic domain to visualize global functional clustering."
|
| 131 |
+
|
| 132 |
+
display_plotly_with_download(
|
| 133 |
+
fig,
|
| 134 |
+
f"umap_{umap_viz_type}",
|
| 135 |
+
help_text=help_msg
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
elif umap_viz_type == "Domain":
|
| 140 |
+
# Static Domain
|
| 141 |
+
fig, ax = plt.subplots(figsize=(8, 8))
|
| 142 |
+
sc.pl.umap(metabolic_adata, color=['domain'], show=False, ax=ax, size=100)
|
| 143 |
+
display_plot_with_download(
|
| 144 |
+
fig,
|
| 145 |
+
"umap_domain",
|
| 146 |
+
help_text="This static UMAP shows the distribution of metabolic domains in lower-dimensional space. Spots colored by domain help visualize how well-separated the clustered metabolic regions are."
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
plt.close(fig)
|
| 150 |
+
|
| 151 |
+
elif selected_items:
|
| 152 |
+
per_page = 8
|
| 153 |
+
total = len(selected_items)
|
| 154 |
+
pages = (total + per_page - 1) // per_page
|
| 155 |
+
if "umap_page" not in st.session_state: st.session_state.umap_page = 1
|
| 156 |
+
if st.session_state.umap_page > pages: st.session_state.umap_page = 1
|
| 157 |
+
|
| 158 |
+
curr = selected_items[(st.session_state.umap_page-1)*per_page : st.session_state.umap_page*per_page]
|
| 159 |
+
n_cols = min(2, len(curr))
|
| 160 |
+
n_rows = (len(curr) + n_cols - 1) // n_cols
|
| 161 |
+
fig, axes = plt.subplots(n_rows, n_cols, figsize=(5*n_cols, 4.5*n_rows))
|
| 162 |
+
|
| 163 |
+
if len(curr) == 1: axes = np.array([[axes]])
|
| 164 |
+
elif n_rows == 1: axes = axes.reshape(1, -1)
|
| 165 |
+
elif n_cols == 1: axes = axes.reshape(-1, 1)
|
| 166 |
+
|
| 167 |
+
for i, target in enumerate(curr):
|
| 168 |
+
r, c = i // n_cols, i % n_cols
|
| 169 |
+
if target in metabolic_adata.var_names:
|
| 170 |
+
sc.pl.umap(metabolic_adata, color=[target], cmap='jet', show=False, ax=axes[r,c], size=80)
|
| 171 |
+
if 'rxn_full_names' in metabolic_adata.var.columns:
|
| 172 |
+
full_name = str(metabolic_adata.var.loc[target, 'rxn_full_names'])
|
| 173 |
+
wrapper = textwrap.TextWrapper(width=40)
|
| 174 |
+
axes[r,c].set_title(wrapper.fill(text=full_name), fontsize=10)
|
| 175 |
+
else:
|
| 176 |
+
# Pathway aggregate
|
| 177 |
+
rx_list = metabolic_adata.var[metabolic_adata.var['subsystems'] == target].index.tolist()
|
| 178 |
+
metabolic_adata.obs['tmp_u'] = np.array(metabolic_adata[:, rx_list].X.mean(axis=1)).flatten()
|
| 179 |
+
sc.pl.umap(metabolic_adata, color=['tmp_u'], cmap='jet', show=False, ax=axes[r,c], size=80)
|
| 180 |
+
wrapper = textwrap.TextWrapper(width=40)
|
| 181 |
+
axes[r,c].set_title(wrapper.fill(text=str(target)), fontsize=10)
|
| 182 |
+
if 'tmp_u' in metabolic_adata.obs: del metabolic_adata.obs['tmp_u']
|
| 183 |
+
axes[r,c].axis('off')
|
| 184 |
+
|
| 185 |
+
for j in range(len(curr), n_rows*n_cols): axes[j//n_cols, j%n_cols].axis('off')
|
| 186 |
+
plt.tight_layout()
|
| 187 |
+
# Dynamic help text for static panels
|
| 188 |
+
static_names = []
|
| 189 |
+
for t in curr:
|
| 190 |
+
if t in metabolic_adata.var_names and 'rxn_full_names' in metabolic_adata.var.columns:
|
| 191 |
+
static_names.append(metabolic_adata.var.loc[t, 'rxn_full_names'])
|
| 192 |
+
else:
|
| 193 |
+
static_names.append(str(t))
|
| 194 |
+
static_names_str = ", ".join(static_names)
|
| 195 |
+
|
| 196 |
+
display_plot_with_download(
|
| 197 |
+
fig,
|
| 198 |
+
f"umap_p{st.session_state.umap_page}",
|
| 199 |
+
help_text=f"These static UMAP panels show the flux distribution for: **{static_names_str}**. It helps identify metabolic hotspots for these specific processes within the reduced manifold."
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
plt.close(fig)
|
| 203 |
+
|
| 204 |
+
if pages > 1:
|
| 205 |
+
cx1, cx2, cx3 = st.columns([1,2,1])
|
| 206 |
+
if cx1.button("Prev UMAP Page", key="u_prev"): st.session_state.umap_page -= 1; st.rerun()
|
| 207 |
+
cx2.markdown(f"<center>Page {st.session_state.umap_page} / {pages}</center>", unsafe_allow_html=True)
|
| 208 |
+
if cx3.button("Next UMAP Page", key="u_next"): st.session_state.umap_page += 1; st.rerun()
|
| 209 |
+
|
| 210 |
+
except Exception as e:
|
| 211 |
+
st.error(f"Error during UMAP visualization: {e}")
|
src/ui/plots/utils.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import plotly.graph_objects as go
|
| 3 |
+
import plotly.express as px
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
import io
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
import scanpy as sc
|
| 12 |
+
from itertools import combinations
|
| 13 |
+
from typing import Optional
|
| 14 |
+
from scipy.sparse import issparse
|
| 15 |
+
from scipy.stats import mannwhitneyu
|
| 16 |
+
from src.backend.flux_distribution import adata_to_long_df, p_to_star
|
| 17 |
+
|
| 18 |
+
# Standard color map for metabolic interaction types
|
| 19 |
+
INTERACTION_COLORS = {
|
| 20 |
+
"Competition": "#d32f2f", # Red
|
| 21 |
+
"Release": "#1976d2", # Blue
|
| 22 |
+
"Cooperation": "#388e3c", # Green
|
| 23 |
+
"Amensalism": "#fbc02d", # Amber
|
| 24 |
+
"Neutralism": "#7b1fa2", # Purple
|
| 25 |
+
"Interaction": "#607d8b" # Grey (fallback)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
from statsmodels.stats.multitest import multipletests
|
| 32 |
+
_HAS_STATSMODELS = True
|
| 33 |
+
except ImportError:
|
| 34 |
+
_HAS_STATSMODELS = False
|
| 35 |
+
|
| 36 |
+
def display_help_button(help_text, plot_name):
|
| 37 |
+
"""
|
| 38 |
+
Shows a help popover with insights for the plot.
|
| 39 |
+
"""
|
| 40 |
+
if help_text:
|
| 41 |
+
with st.popover("", icon=":material/help:", help="Click for insights", use_container_width=True):
|
| 42 |
+
st.markdown(f"#### <i class='fas fa-lightbulb'></i> Plot Insights", unsafe_allow_html=True)
|
| 43 |
+
st.markdown(help_text)
|
| 44 |
+
|
| 45 |
+
def display_plot_with_download(fig, plot_name: str = "plot", help_text: str = None):
|
| 46 |
+
"""
|
| 47 |
+
Display a matplotlib figure with aligned help and download buttons on top right.
|
| 48 |
+
"""
|
| 49 |
+
# Use consistent column ratios: Spacer, Help, Download.
|
| 50 |
+
cols = st.columns([0.7, 0.2, 0.1], gap="small")
|
| 51 |
+
|
| 52 |
+
with cols[1]:
|
| 53 |
+
display_help_button(help_text, plot_name)
|
| 54 |
+
|
| 55 |
+
with cols[2]:
|
| 56 |
+
# Generate PDF file
|
| 57 |
+
pdf_buffer = io.BytesIO()
|
| 58 |
+
fig.savefig(pdf_buffer, format='pdf', dpi=300, bbox_inches='tight')
|
| 59 |
+
file_data = pdf_buffer.getvalue()
|
| 60 |
+
|
| 61 |
+
st.download_button(
|
| 62 |
+
label="",
|
| 63 |
+
data=file_data,
|
| 64 |
+
file_name=f"{plot_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf",
|
| 65 |
+
mime="application/pdf",
|
| 66 |
+
key=f"download_{plot_name}_{id(fig)}",
|
| 67 |
+
help="Download as PDF",
|
| 68 |
+
icon=":material/download:",
|
| 69 |
+
use_container_width=True
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Display the plot
|
| 73 |
+
st.pyplot(fig)
|
| 74 |
+
|
| 75 |
+
def display_plotly_with_download(fig, plot_name: str = "plot", help_text: str = None):
|
| 76 |
+
"""
|
| 77 |
+
Display a Plotly figure with aligned help button on top right.
|
| 78 |
+
"""
|
| 79 |
+
cols = st.columns([0.7, 0.2, 0.1], gap="small")
|
| 80 |
+
with cols[1]:
|
| 81 |
+
display_help_button(help_text, plot_name)
|
| 82 |
+
|
| 83 |
+
with cols[2]:
|
| 84 |
+
st.empty()
|
| 85 |
+
|
| 86 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 87 |
+
|
| 88 |
+
def display_interactive_spatial_plot(adata, color_key="domain", spot_size = 6, plot_name="spatial_plot", title: Optional[str] = None, help_text: Optional[str] = None):
|
| 89 |
+
# spot_size = spot_size
|
| 90 |
+
try:
|
| 91 |
+
# Create columns for help/download above the plot if help_text is provided
|
| 92 |
+
if help_text:
|
| 93 |
+
col_space, col_help, col_download = st.columns([5.0, 0.5, 0.5], gap="small")
|
| 94 |
+
with col_help:
|
| 95 |
+
display_help_button(help_text, plot_name)
|
| 96 |
+
|
| 97 |
+
library_id = list(adata.uns["spatial"].keys())[0]
|
| 98 |
+
img_key = "hires" if "hires" in adata.uns["spatial"][library_id]["images"] else "lowres"
|
| 99 |
+
img = adata.uns["spatial"][library_id]["images"][img_key]
|
| 100 |
+
sf_key = f"tissue_{img_key}_scalef"
|
| 101 |
+
sf = adata.uns["spatial"][library_id]["scalefactors"][sf_key]
|
| 102 |
+
coords = adata.obsm["spatial"] * sf
|
| 103 |
+
|
| 104 |
+
if color_key in adata.var_names:
|
| 105 |
+
var_idx = adata.var_names.get_loc(color_key)
|
| 106 |
+
raw = adata.X[:, var_idx]
|
| 107 |
+
color_values = raw.toarray().flatten() if hasattr(raw, "toarray") else np.asarray(raw).flatten()
|
| 108 |
+
is_categorical = False
|
| 109 |
+
elif color_key in adata.obs.columns:
|
| 110 |
+
color_values = adata.obs[color_key].values
|
| 111 |
+
is_categorical = not pd.api.types.is_numeric_dtype(adata.obs[color_key])
|
| 112 |
+
else:
|
| 113 |
+
color_values = np.full(len(coords), "N/A")
|
| 114 |
+
is_categorical = True
|
| 115 |
+
|
| 116 |
+
df = pd.DataFrame({
|
| 117 |
+
"x": coords[:, 0],
|
| 118 |
+
"y": coords[:, 1],
|
| 119 |
+
"color": color_values.astype(str) if is_categorical else color_values,
|
| 120 |
+
"domain": adata.obs["domain"].values if "domain" in adata.obs.columns else "N/A",
|
| 121 |
+
"spot_id": adata.obs_names.tolist()
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
last_key = st.session_state.get(f"{plot_name}_last_key")
|
| 125 |
+
if last_key != color_key:
|
| 126 |
+
st.session_state.pop(f"{plot_name}_relayout", None)
|
| 127 |
+
st.session_state[f"{plot_name}_last_key"] = color_key
|
| 128 |
+
|
| 129 |
+
plot_state = st.session_state.get(plot_name, {})
|
| 130 |
+
relayout = None
|
| 131 |
+
|
| 132 |
+
if isinstance(plot_state, dict):
|
| 133 |
+
relayout = plot_state.get("relayout_data") or plot_state.get("relayout")
|
| 134 |
+
elif hasattr(plot_state, "selection"):
|
| 135 |
+
relayout = getattr(plot_state, "relayout_data", None)
|
| 136 |
+
|
| 137 |
+
zoom_ratio = 1.0
|
| 138 |
+
has_zoom = relayout and isinstance(relayout, dict) and "xaxis.range[0]" in relayout
|
| 139 |
+
|
| 140 |
+
if has_zoom:
|
| 141 |
+
try:
|
| 142 |
+
xr = [relayout["xaxis.range[0]"], relayout["xaxis.range[1]"]]
|
| 143 |
+
zoom_ratio = abs(xr[1] - xr[0]) / img.shape[1]
|
| 144 |
+
except (IndexError, KeyError, ZeroDivisionError):
|
| 145 |
+
zoom_ratio = 1.0
|
| 146 |
+
|
| 147 |
+
fig = go.Figure()
|
| 148 |
+
fig.add_layout_image(
|
| 149 |
+
dict(
|
| 150 |
+
source=Image.fromarray((img * 255).astype(np.uint8)),
|
| 151 |
+
xref="x", yref="y",
|
| 152 |
+
x=0, y=0,
|
| 153 |
+
sizex=img.shape[1], sizey=img.shape[0],
|
| 154 |
+
sizing="stretch", layer="below"
|
| 155 |
+
)
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
if is_categorical:
|
| 159 |
+
palette = px.colors.qualitative.T10
|
| 160 |
+
unique_vals = sorted(df["color"].astype(str).unique())
|
| 161 |
+
|
| 162 |
+
for i, val in enumerate(unique_vals):
|
| 163 |
+
sub = df[df["color"].astype(str) == val]
|
| 164 |
+
fig.add_trace(go.Scattergl(
|
| 165 |
+
x=sub["x"],
|
| 166 |
+
y=sub["y"],
|
| 167 |
+
customdata=np.stack((sub["spot_id"], sub["domain"]), axis=-1),
|
| 168 |
+
mode="markers",
|
| 169 |
+
name=str(val),
|
| 170 |
+
marker=dict(
|
| 171 |
+
size=spot_size,
|
| 172 |
+
color=palette[i % len(palette)],
|
| 173 |
+
line=dict(width=0.5, color='white')
|
| 174 |
+
),
|
| 175 |
+
hovertemplate=(
|
| 176 |
+
"<b>Domain: %{customdata[1]}</b><br>"
|
| 177 |
+
"<span style='font-size:0.8rem;'>ID: %{customdata[0]}</span>"
|
| 178 |
+
"<extra></extra>"
|
| 179 |
+
)
|
| 180 |
+
))
|
| 181 |
+
else:
|
| 182 |
+
fig.add_trace(go.Scattergl(
|
| 183 |
+
x=df["x"], y=df["y"],
|
| 184 |
+
customdata=np.stack((df["spot_id"], df["domain"]), axis=-1),
|
| 185 |
+
mode="markers",
|
| 186 |
+
marker=dict(
|
| 187 |
+
size=spot_size,
|
| 188 |
+
color=df["color"],
|
| 189 |
+
colorscale="Jet",
|
| 190 |
+
showscale=True,
|
| 191 |
+
colorbar=dict(
|
| 192 |
+
thickness=8,
|
| 193 |
+
len=0.75,
|
| 194 |
+
xref="paper",
|
| 195 |
+
yref="paper",
|
| 196 |
+
tickfont=dict(size=10),
|
| 197 |
+
outlinewidth=0,
|
| 198 |
+
),
|
| 199 |
+
line=dict(width=0.3, color='white')
|
| 200 |
+
),
|
| 201 |
+
hovertemplate=(
|
| 202 |
+
"<b>Domain: %{customdata[1]}</b><br>"
|
| 203 |
+
f"<b>Flux:</b> %{{marker.color:.3e}}<br>"
|
| 204 |
+
"<span style='font-size:0.8rem;'>ID: %{customdata[0]}</span>"
|
| 205 |
+
"<extra></extra>"
|
| 206 |
+
)
|
| 207 |
+
))
|
| 208 |
+
|
| 209 |
+
# Enforce square axes aligned to tissue image
|
| 210 |
+
fig.update_xaxes(
|
| 211 |
+
visible=False,
|
| 212 |
+
range=[0, img.shape[1]],
|
| 213 |
+
scaleanchor="y",
|
| 214 |
+
scaleratio=1,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
fig.update_yaxes(
|
| 218 |
+
visible=False,
|
| 219 |
+
range=[img.shape[0], 0],
|
| 220 |
+
scaleanchor="x",
|
| 221 |
+
scaleratio=1,
|
| 222 |
+
constrain="domain",
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
fig.update_layout(
|
| 226 |
+
title=dict(
|
| 227 |
+
text=title if title else "",
|
| 228 |
+
x=0.5,
|
| 229 |
+
y=0.98,
|
| 230 |
+
xanchor="center",
|
| 231 |
+
yanchor="top",
|
| 232 |
+
font=dict(size=16)
|
| 233 |
+
) if title else None,
|
| 234 |
+
margin=dict(l=0, r=0, t=40 if title else 0, b=0),
|
| 235 |
+
legend=dict(
|
| 236 |
+
orientation="v",
|
| 237 |
+
yanchor="top",
|
| 238 |
+
y=0.99,
|
| 239 |
+
xanchor="left",
|
| 240 |
+
x=0.01,
|
| 241 |
+
bgcolor="rgba(255,255,255,0.6)"
|
| 242 |
+
),
|
| 243 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 244 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 245 |
+
dragmode="pan",
|
| 246 |
+
uirevision="constant"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
plot_event = st.plotly_chart(
|
| 250 |
+
fig,
|
| 251 |
+
use_container_width=True,
|
| 252 |
+
config={'scrollZoom': True},
|
| 253 |
+
key=plot_name,
|
| 254 |
+
on_select="rerun"
|
| 255 |
+
)
|
| 256 |
+
if plot_event and hasattr(plot_event, "get"):
|
| 257 |
+
relayout = plot_event.get("relayout_data") or plot_event.get("selection", {}).get("relayout_data")
|
| 258 |
+
if relayout:
|
| 259 |
+
st.session_state[f"{plot_name}_relayout"] = relayout
|
| 260 |
+
|
| 261 |
+
return True
|
| 262 |
+
|
| 263 |
+
except Exception as e:
|
| 264 |
+
st.error(f"Error rendering interactive plot: {e}")
|
| 265 |
+
return False
|
| 266 |
+
|
| 267 |
+
def display_formatted_table(df: pd.DataFrame, title: Optional[str] = None):
|
| 268 |
+
"""Display a dataframe with scientific notation for small float values."""
|
| 269 |
+
if title:
|
| 270 |
+
st.markdown(f"##### <i class='fas fa-table'></i> {title}", unsafe_allow_html=True)
|
| 271 |
+
|
| 272 |
+
config = {}
|
| 273 |
+
if not df.empty:
|
| 274 |
+
for col in df.select_dtypes(include=['float']).columns:
|
| 275 |
+
if 'p_val' in col.lower() or 'pvalue' in col.lower() or df[col].abs().max() < 1e-2:
|
| 276 |
+
config[col] = st.column_config.NumberColumn(format="%.2e")
|
| 277 |
+
else:
|
| 278 |
+
config[col] = st.column_config.NumberColumn(format="%.4f")
|
| 279 |
+
|
| 280 |
+
st.dataframe(df, width='stretch', column_config=config)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def add_significance_brackets(ax, df, domain_order, y_col="flux"):
|
| 285 |
+
"""
|
| 286 |
+
Add pairwise significance brackets above a boxen/box plot.
|
| 287 |
+
Uses Mann-Whitney U test with FDR-BH correction across all pairs.
|
| 288 |
+
Only significant pairs (p_adj < 0.05) are annotated.
|
| 289 |
+
"""
|
| 290 |
+
pairs = list(combinations(domain_order, 2))
|
| 291 |
+
pvalues = []
|
| 292 |
+
valid_pairs = []
|
| 293 |
+
|
| 294 |
+
for d1, d2 in pairs:
|
| 295 |
+
g1 = df.loc[df["domain"] == d1, y_col].dropna()
|
| 296 |
+
g2 = df.loc[df["domain"] == d2, y_col].dropna()
|
| 297 |
+
if len(g1) < 3 or len(g2) < 3:
|
| 298 |
+
continue
|
| 299 |
+
_, p = mannwhitneyu(g1, g2, alternative="two-sided")
|
| 300 |
+
pvalues.append(p)
|
| 301 |
+
valid_pairs.append((d1, d2))
|
| 302 |
+
|
| 303 |
+
if not valid_pairs:
|
| 304 |
+
return
|
| 305 |
+
|
| 306 |
+
if _HAS_STATSMODELS:
|
| 307 |
+
_, p_adj, _, _ = multipletests(pvalues, method="fdr_bh")
|
| 308 |
+
else:
|
| 309 |
+
p_adj = np.array(pvalues)
|
| 310 |
+
|
| 311 |
+
y_max = df[y_col].max()
|
| 312 |
+
y_range = df[y_col].max() - df[y_col].min()
|
| 313 |
+
step = y_range * 0.08
|
| 314 |
+
|
| 315 |
+
bracket_y = y_max + step
|
| 316 |
+
for (d1, d2), p in zip(valid_pairs, p_adj):
|
| 317 |
+
star = p_to_star(p)
|
| 318 |
+
if star == "ns":
|
| 319 |
+
continue
|
| 320 |
+
x1 = domain_order.index(d1)
|
| 321 |
+
x2 = domain_order.index(d2)
|
| 322 |
+
mid = (x1 + x2) / 2
|
| 323 |
+
ax.plot([x1, x1, x2, x2], [bracket_y, bracket_y + step * 0.3, bracket_y + step * 0.3, bracket_y],
|
| 324 |
+
lw=1.2, c="black")
|
| 325 |
+
ax.text(mid, bracket_y + step * 0.35, star, ha="center", va="bottom", fontsize=9)
|
| 326 |
+
bracket_y += step * 0.9 # stack brackets upward
|
| 327 |
+
|
| 328 |
+
def create_plotly_tme_plot(adata, interaction_type_df, interaction_score_df, selected_rxn_id, selected_display_name, percentile_threshold=95):
|
| 329 |
+
|
| 330 |
+
coords_df = pd.DataFrame(adata.obsm["spatial"], index=adata.obs.index, columns=['x', 'y'])
|
| 331 |
+
y_max = coords_df['y'].max()
|
| 332 |
+
coords_df['y_plot'] = y_max - coords_df['y']
|
| 333 |
+
coords_df['domain'] = adata.obs['domain'] if 'domain' in adata.obs.columns else "N/A"
|
| 334 |
+
|
| 335 |
+
if percentile_threshold > 0:
|
| 336 |
+
thresh = interaction_score_df['Interaction score'].quantile(percentile_threshold / 100)
|
| 337 |
+
scores = interaction_score_df[interaction_score_df['Interaction score'] >= thresh]
|
| 338 |
+
else:
|
| 339 |
+
scores = interaction_score_df
|
| 340 |
+
|
| 341 |
+
rxn_mask = interaction_type_df['Reaction'].str.replace(r'_(b|f)$', '', regex=True) == selected_rxn_id
|
| 342 |
+
rxn_data = interaction_type_df[rxn_mask]
|
| 343 |
+
|
| 344 |
+
merged = pd.merge(rxn_data, scores, on=['Source', 'Target'])
|
| 345 |
+
|
| 346 |
+
if merged.empty:
|
| 347 |
+
return None
|
| 348 |
+
|
| 349 |
+
fig = go.Figure()
|
| 350 |
+
|
| 351 |
+
fig.add_trace(go.Scattergl(
|
| 352 |
+
x=coords_df['x'], y=coords_df['y_plot'],
|
| 353 |
+
mode='markers',
|
| 354 |
+
marker=dict(size=4, color='#bdbdbd', opacity=0.5), # All spots in background
|
| 355 |
+
name='Tissue Background',
|
| 356 |
+
customdata=np.stack((coords_df.index, coords_df['domain']), axis=-1),
|
| 357 |
+
hovertemplate="<b>Spot ID: %{customdata[0]}</b><br>Domain: %{customdata[1]}<extra></extra>",
|
| 358 |
+
showlegend=False
|
| 359 |
+
))
|
| 360 |
+
|
| 361 |
+
types = merged['Interaction type'].unique()
|
| 362 |
+
colors = px.colors.qualitative.T10
|
| 363 |
+
|
| 364 |
+
for i, t in enumerate(types):
|
| 365 |
+
sub = merged[merged['Interaction type'] == t]
|
| 366 |
+
|
| 367 |
+
s_coords = coords_df.loc[sub['Source'], ['x', 'y_plot']].values
|
| 368 |
+
t_coords = coords_df.loc[sub['Target'], ['x', 'y_plot']].values
|
| 369 |
+
|
| 370 |
+
n = len(sub)
|
| 371 |
+
edge_x = np.full(n * 3, np.nan)
|
| 372 |
+
edge_y = np.full(n * 3, np.nan)
|
| 373 |
+
edge_x[0::3] = s_coords[:, 0]; edge_x[1::3] = t_coords[:, 0]
|
| 374 |
+
edge_y[0::3] = s_coords[:, 1]; edge_y[1::3] = t_coords[:, 1]
|
| 375 |
+
|
| 376 |
+
fig.add_trace(go.Scattergl(
|
| 377 |
+
x=edge_x, y=edge_y,
|
| 378 |
+
mode='lines',
|
| 379 |
+
line=dict(width=3, color=INTERACTION_COLORS.get(t, "#607d8b")),
|
| 380 |
+
name=str(t),
|
| 381 |
+
hoverinfo='none', # Hover is handled by midpoints
|
| 382 |
+
connectgaps=False
|
| 383 |
+
))
|
| 384 |
+
|
| 385 |
+
# Midpoints for robust hover in the middle of lines
|
| 386 |
+
mid_x = (s_coords[:, 0] + t_coords[:, 0]) / 2
|
| 387 |
+
mid_y = (s_coords[:, 1] + t_coords[:, 1]) / 2
|
| 388 |
+
|
| 389 |
+
fig.add_trace(go.Scattergl(
|
| 390 |
+
x=mid_x, y=mid_y,
|
| 391 |
+
mode='markers',
|
| 392 |
+
marker=dict(size=12, opacity=0), # Large invisible target
|
| 393 |
+
name=str(t),
|
| 394 |
+
hovertemplate=f"<b>Interaction: {t}</b><br>Score: %{{customdata:.4f}}<extra></extra>",
|
| 395 |
+
customdata=sub['Interaction score'].values,
|
| 396 |
+
showlegend=False
|
| 397 |
+
))
|
| 398 |
+
|
| 399 |
+
active_spots = sorted(list(set(merged['Source']).union(set(merged['Target']))))
|
| 400 |
+
active_df = coords_df.loc[active_spots]
|
| 401 |
+
|
| 402 |
+
fig.add_trace(go.Scattergl(
|
| 403 |
+
x=active_df['x'], y=active_df['y_plot'],
|
| 404 |
+
mode='markers',
|
| 405 |
+
marker=dict(size=5, color='#424242', opacity=0.9, line=dict(width=1, color='white')),
|
| 406 |
+
name='Interacting Spots',
|
| 407 |
+
customdata=np.stack((active_df.index, active_df['domain']), axis=-1),
|
| 408 |
+
hovertemplate="<b>Spot ID: %{customdata[0]}</b><br>Domain: %{customdata[1]}<extra></extra>",
|
| 409 |
+
showlegend=True
|
| 410 |
+
))
|
| 411 |
+
|
| 412 |
+
fig.update_layout(
|
| 413 |
+
title=dict(
|
| 414 |
+
text=f"Metabolic Interactions: {selected_display_name}",
|
| 415 |
+
),
|
| 416 |
+
xaxis=dict(visible=False), yaxis=dict(visible=False, scaleanchor="x"),
|
| 417 |
+
plot_bgcolor='#fcfcfc', paper_bgcolor='white',
|
| 418 |
+
width=850, height=850, margin=dict(l=10, r=10, t=60, b=10),
|
| 419 |
+
legend=dict(orientation="h", y=1.02, x=0, xanchor="left", title="Interaction Type:"),
|
| 420 |
+
hovermode='closest',
|
| 421 |
+
hoverdistance=30 # Makes it easier to hover on lines
|
| 422 |
+
)
|
| 423 |
+
return fig
|
| 424 |
+
|
| 425 |
+
def create_plotly_comm_plot(interaction_scores, adata, percentile_threshold=80):
|
| 426 |
+
"""
|
| 427 |
+
Optimized Communication Strength plot using WebGL and vectorized coordinates.
|
| 428 |
+
"""
|
| 429 |
+
coords_df = pd.DataFrame(adata.obsm["spatial"], index=adata.obs.index, columns=['x', 'y'])
|
| 430 |
+
y_max = coords_df['y'].max()
|
| 431 |
+
coords_df['y_plot'] = y_max - coords_df['y']
|
| 432 |
+
coords_df['domain'] = adata.obs['domain'] if 'domain' in adata.obs.columns else "N/A"
|
| 433 |
+
|
| 434 |
+
if percentile_threshold > 0:
|
| 435 |
+
thresh = interaction_scores['Interaction score'].quantile(percentile_threshold / 100)
|
| 436 |
+
interaction_scores = interaction_scores[interaction_scores['Interaction score'] >= thresh]
|
| 437 |
+
|
| 438 |
+
valid = interaction_scores[
|
| 439 |
+
(interaction_scores['Source'].isin(coords_df.index)) &
|
| 440 |
+
(interaction_scores['Target'].isin(coords_df.index))
|
| 441 |
+
]
|
| 442 |
+
|
| 443 |
+
if valid.empty: return None
|
| 444 |
+
|
| 445 |
+
fig = go.Figure()
|
| 446 |
+
# Background
|
| 447 |
+
fig.add_trace(go.Scattergl(
|
| 448 |
+
x=coords_df['x'], y=coords_df['y_plot'],
|
| 449 |
+
mode='markers',
|
| 450 |
+
marker=dict(size=4, color='#bdbdbd', opacity=0.3), # All spots in background
|
| 451 |
+
name='Tissue Background',
|
| 452 |
+
customdata=np.stack((coords_df.index, coords_df['domain']), axis=-1),
|
| 453 |
+
hovertemplate="<b>Spot ID: %{customdata[0]}</b><br>Domain: %{customdata[1]}<extra></extra>",
|
| 454 |
+
showlegend=False
|
| 455 |
+
))
|
| 456 |
+
|
| 457 |
+
# Binned Edges (Vectorized)
|
| 458 |
+
n_bins = 5
|
| 459 |
+
valid = valid.copy()
|
| 460 |
+
valid['bin'] = pd.qcut(valid['Interaction score'], n_bins, labels=False, duplicates='drop')
|
| 461 |
+
|
| 462 |
+
for b in range(n_bins):
|
| 463 |
+
sub = valid[valid['bin'] == b]
|
| 464 |
+
if sub.empty: continue
|
| 465 |
+
|
| 466 |
+
s_coords = coords_df.loc[sub['Source'], ['x', 'y_plot']].values
|
| 467 |
+
t_coords = coords_df.loc[sub['Target'], ['x', 'y_plot']].values
|
| 468 |
+
|
| 469 |
+
n = len(sub)
|
| 470 |
+
edge_x = np.full(n * 3, np.nan)
|
| 471 |
+
edge_y = np.full(n * 3, np.nan)
|
| 472 |
+
edge_x[0::3] = s_coords[:, 0]; edge_x[1::3] = t_coords[:, 0]
|
| 473 |
+
edge_y[0::3] = s_coords[:, 1]; edge_y[1::3] = t_coords[:, 1]
|
| 474 |
+
|
| 475 |
+
fig.add_trace(go.Scattergl(
|
| 476 |
+
x=edge_x, y=edge_y,
|
| 477 |
+
mode='lines',
|
| 478 |
+
line=dict(width=0.5 + b*1.5, color=px.colors.sample_colorscale("Viridis", b/(n_bins-1))[0]),
|
| 479 |
+
name=f"Level {b+1}", hoverinfo='none'
|
| 480 |
+
))
|
| 481 |
+
|
| 482 |
+
fig.update_layout(
|
| 483 |
+
title="Cell-Cell Metabolic Communication Strengths",
|
| 484 |
+
xaxis=dict(visible=False), yaxis=dict(visible=False, scaleanchor="x"),
|
| 485 |
+
plot_bgcolor='#fcfcfc', width=850, height=850,
|
| 486 |
+
legend=dict(title="Score Bin:", orientation="v", x=1.02, y=1)
|
| 487 |
+
)
|
| 488 |
+
return fig
|