WinslowFan Claude Opus 4.8 commited on
Commit
2d33606
·
1 Parent(s): 1ebf5db

Add chain_composition + binding status; refresh DB, docs, Fidelity plot

Browse files

- DB rebuilt from updated annotations: node gains chain_composition and
non_protein_polymer_binding columns (edge unchanged, 27.5M rows).
- Chain metadata: show Chain Composition alongside Binding Status.
- Alternative observations: add Binding status + Chain composition
columns (between pH and ΔRosetta).
- Docs: document chain_composition and non_protein_polymer_binding.
- Refresh Fidelity Distribution plot (cache-bust v=20260625).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

backend/app/protein/api/routes/protein.py CHANGED
@@ -1,263 +1,267 @@
1
- """Protein API routes."""
2
- import logging
3
- import math
4
- from typing import Dict, List
5
-
6
- from fastapi import APIRouter, HTTPException, Query
7
-
8
- from app.protein.chain_resolver import (
9
- batch_resolve_chains_async,
10
- get_cache,
11
- resolve_chain_async,
12
- )
13
- from app.protein.data_loader import DataManager
14
- from app.protein import tsv_loader
15
- from app.protein.models import (
16
- ChainMetadata,
17
- DataRecord,
18
- DataResponse,
19
- FilterOptions,
20
- FilterParams,
21
- SummaryStats,
22
- )
23
-
24
- logger = logging.getLogger(__name__)
25
- router = APIRouter()
26
-
27
- data_manager: DataManager | None = None
28
-
29
-
30
- def set_data_manager(manager: DataManager | None) -> None:
31
- """Set the active data manager for route handlers."""
32
- global data_manager
33
- data_manager = manager
34
-
35
-
36
- def _require_data_manager() -> DataManager:
37
- """Return data manager or raise if not initialized."""
38
- if data_manager is None:
39
- raise HTTPException(
40
- status_code=503,
41
- detail="Protein data is not initialized yet"
42
- )
43
- return data_manager
44
-
45
-
46
- @router.get("/")
47
- async def root():
48
- """Root endpoint."""
49
- return {
50
- "message": "Protein Visualisation API",
51
- "version": "1.0.0",
52
- "endpoints": {
53
- "/filters": "Get available filter options",
54
- "/data": "Get filtered protein data",
55
- "/summary": "Get aggregate statistics"
56
- }
57
- }
58
-
59
-
60
- @router.get("/filters", response_model=FilterOptions)
61
- async def get_filters():
62
- """Get available filter options and ranges."""
63
- try:
64
- manager = _require_data_manager()
65
- chains = sorted(manager.get_chain_sample())
66
- fd = manager.get_filters_data()
67
-
68
- return FilterOptions(
69
- chain_ids=chains[:100],
70
- rmsd_range={"min": float(fd["rmsd_min"]), "max": float(fd["rmsd_max"])},
71
- tm_score_range={"min": float(fd["tm_min"]), "max": float(fd["tm_max"])},
72
- length_range={"min": int(fd["length_min"]), "max": int(fd["length_max"])},
73
- clusters=[
74
- "cluster_ultra_high", "cluster_very_high", "cluster_high",
75
- "cluster_medium", "cluster_low",
76
- ],
77
- total_records=fd["total_records"],
78
- )
79
- except HTTPException:
80
- raise
81
- except Exception as e:
82
- logger.error(f"Error getting filters: {e}")
83
- raise HTTPException(status_code=500, detail=str(e))
84
-
85
-
86
- @router.post("/data", response_model=DataResponse)
87
- async def get_data(filters: FilterParams):
88
- """Get filtered protein data. Searches only by pdb_id_A and auth_asym_id_A."""
89
- try:
90
- manager = _require_data_manager()
91
- limit = filters.limit or 1000
92
- fetch_limit = limit * 10 if filters.cluster_id else limit
93
-
94
- df = manager.query_edge(
95
- pdb_id_a=filters.pdb_id,
96
- auth_asym_id_a=filters.auth_asym_id,
97
- chain_ids=filters.chain_ids,
98
- rmsd_min=filters.rmsd_min,
99
- rmsd_max=filters.rmsd_max,
100
- tm_min=filters.tm_score_min,
101
- tm_max=filters.tm_score_max,
102
- limit=fetch_limit,
103
- )
104
-
105
- if filters.cluster_id:
106
- df = df[df["cluster_id"] == filters.cluster_id]
107
-
108
- filtered_total = len(df)
109
- df = df.head(limit)
110
-
111
- def _safe_float(val):
112
- try:
113
- v = float(val)
114
- return None if math.isnan(v) else round(v, 6)
115
- except (TypeError, ValueError):
116
- return None
117
-
118
- records = []
119
- for row in df.itertuples(index=False):
120
- raw_ph = getattr(row, "pH", None)
121
- raw_temp = getattr(row, "temp_K", None)
122
- records.append(DataRecord(
123
- pdb_id_a=row.pdb_id_A,
124
- auth_asym_id_a=row.auth_asym_id_A,
125
- pdb_id_b=row.pdb_id_B,
126
- auth_asym_id_b=row.auth_asym_id_B,
127
- tm_score=float(row.TM1) if row.TM1 is not None else 0.0,
128
- rmsd=float(row.RMSD) if row.RMSD is not None else 0.0,
129
- structure_sim=_safe_float(row.structure_sim),
130
- length_a=int(row.length_a) if row.length_a is not None else None,
131
- length_b=int(row.length_b) if row.length_b is not None else None,
132
- cluster_id=row.cluster_id,
133
- exptl_method=getattr(row, "experimental_method", None) or None,
134
- pH=_safe_float(raw_ph) if raw_ph else None,
135
- temp=_safe_float(raw_temp) if raw_temp else None,
136
- delta_rosetta=_safe_float(getattr(row, "delta_Rosetta", None)),
137
- delta_foldx=_safe_float(getattr(row, "delta_FoldX", None)),
138
- delta_evoef2=_safe_float(getattr(row, "delta_EvoEF2", None)),
139
- delta_rw=_safe_float(getattr(row, "delta_RW", None)),
140
- delta_rw_plus=_safe_float(getattr(row, "delta_RW_plus", None)),
141
- state_id_b=getattr(row, "state_id_B", None) or None,
142
- state_fidelity=getattr(row, "state_fidelity", None) or None,
143
- avg_sim=getattr(row, "avg_sim", None) or None,
144
- observation_fidelity=getattr(row, "observation_fidelity", None) or None,
145
- ))
146
-
147
- return DataResponse(
148
- data=records,
149
- total=manager.get_total(),
150
- filtered=filtered_total,
151
- )
152
-
153
- except HTTPException:
154
- raise
155
- except Exception as e:
156
- logger.error(f"Error filtering data: {e}")
157
- raise HTTPException(status_code=500, detail=str(e))
158
-
159
-
160
- @router.get("/summary", response_model=SummaryStats)
161
- async def get_summary(
162
- chain_ids: List[str] = Query(default=None),
163
- rmsd_min: float = Query(default=None),
164
- rmsd_max: float = Query(default=None),
165
- tm_score_min: float = Query(default=None),
166
- tm_score_max: float = Query(default=None),
167
- ):
168
- """Get aggregate statistics for the dataset or filtered subset."""
169
- try:
170
- manager = _require_data_manager()
171
- stats = manager.get_summary_stats(
172
- chain_ids=chain_ids,
173
- rmsd_min=rmsd_min,
174
- rmsd_max=rmsd_max,
175
- tm_min=tm_score_min,
176
- tm_max=tm_score_max,
177
- )
178
- return SummaryStats(
179
- total_records=stats["total_records"],
180
- unique_chains=stats["unique_chains"],
181
- avg_rmsd=float(stats["avg_rmsd"]),
182
- avg_tm_score=float(stats["avg_tm_score"]),
183
- avg_sequence_length=float(stats["avg_sequence_length"]),
184
- rmsd_distribution={str(k): int(v) for k, v in stats["rmsd_distribution"].items()},
185
- tm_score_distribution={str(k): int(v) for k, v in stats["tm_score_distribution"].items()},
186
- length_distribution={str(k): int(v) for k, v in stats["length_distribution"].items()},
187
- )
188
- except HTTPException:
189
- raise
190
- except Exception as e:
191
- logger.error(f"Error computing summary: {e}")
192
- raise HTTPException(status_code=500, detail=str(e))
193
-
194
-
195
- @router.get("/chain/resolve", response_model=ChainMetadata)
196
- async def resolve_chain_endpoint(
197
- pdb_id: str = Query(..., description="4-character PDB ID"),
198
- auth_asym_id: str = Query(..., description="Chain identifier (e.g., 'A')"),
199
- use_cache: bool = Query(True, description="Use cached data if available")
200
- ):
201
- """Resolve chain metadata from RCSB web API."""
202
- metadata = await resolve_chain_async(pdb_id, auth_asym_id, use_cache=use_cache)
203
- return metadata.model_copy(update={
204
- "binding_status": tsv_loader.get_binding_status(pdb_id, auth_asym_id),
205
- "cath_id": tsv_loader.get_cath_id(pdb_id, auth_asym_id),
206
- "cath_superfamily": tsv_loader.get_cath_superfamily(pdb_id, auth_asym_id),
207
- "state_id": tsv_loader.get_state_id(pdb_id, auth_asym_id),
208
- })
209
-
210
-
211
- @router.post("/chain/batch-resolve")
212
- async def batch_resolve_chains_endpoint(
213
- chains: List[Dict[str, str]],
214
- use_cache: bool = Query(True, description="Use cached data if available")
215
- ):
216
- """Batch resolve multiple chains at once."""
217
- chain_tuples = []
218
- for chain in chains:
219
- pdb_id = chain.get("pdb_id")
220
- auth_asym_id = chain.get("auth_asym_id")
221
- if pdb_id and auth_asym_id:
222
- chain_tuples.append((pdb_id, auth_asym_id))
223
-
224
- result = await batch_resolve_chains_async(chain_tuples, use_cache=use_cache)
225
- return {
226
- key: metadata.model_copy(update={
227
- "binding_status": tsv_loader.get_binding_status(*key.split("|")),
228
- "cath_id": tsv_loader.get_cath_id(*key.split("|")),
229
- "cath_superfamily": tsv_loader.get_cath_superfamily(*key.split("|")),
230
- "state_id": tsv_loader.get_state_id(*key.split("|")),
231
- })
232
- for key, metadata in result.items()
233
- }
234
-
235
-
236
- @router.get("/chain/cache/stats")
237
- async def cache_stats():
238
- """Get cache statistics."""
239
- cache = get_cache()
240
- return {
241
- "cached_chains": len(cache.cache),
242
- "ttl_hours": cache.ttl.total_seconds() / 3600
243
- }
244
-
245
-
246
- @router.get("/chain/functions")
247
- async def get_chain_functions(
248
- pdb_id: str = Query(..., description="4-character PDB ID"),
249
- auth_asym_id: str = Query(..., description="Chain identifier (e.g., 'A')"),
250
- ):
251
- """Return ranked functional annotations for a chain from node table."""
252
- functions = tsv_loader.get_functions(pdb_id, auth_asym_id)
253
- if functions is None:
254
- raise HTTPException(status_code=404, detail=f"No annotation found for {pdb_id}:{auth_asym_id}")
255
- return {"pdb_id": pdb_id.lower(), "chain_id": auth_asym_id.upper(), "functions": functions}
256
-
257
-
258
- @router.post("/chain/cache/clear")
259
- async def clear_cache():
260
- """Clear the chain metadata cache."""
261
- cache = get_cache()
262
- cache.clear()
263
- return {"message": "Cache cleared successfully"}
 
 
 
 
 
1
+ """Protein API routes."""
2
+ import logging
3
+ import math
4
+ from typing import Dict, List
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+
8
+ from app.protein.chain_resolver import (
9
+ batch_resolve_chains_async,
10
+ get_cache,
11
+ resolve_chain_async,
12
+ )
13
+ from app.protein.data_loader import DataManager
14
+ from app.protein import tsv_loader
15
+ from app.protein.models import (
16
+ ChainMetadata,
17
+ DataRecord,
18
+ DataResponse,
19
+ FilterOptions,
20
+ FilterParams,
21
+ SummaryStats,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+ router = APIRouter()
26
+
27
+ data_manager: DataManager | None = None
28
+
29
+
30
+ def set_data_manager(manager: DataManager | None) -> None:
31
+ """Set the active data manager for route handlers."""
32
+ global data_manager
33
+ data_manager = manager
34
+
35
+
36
+ def _require_data_manager() -> DataManager:
37
+ """Return data manager or raise if not initialized."""
38
+ if data_manager is None:
39
+ raise HTTPException(
40
+ status_code=503,
41
+ detail="Protein data is not initialized yet"
42
+ )
43
+ return data_manager
44
+
45
+
46
+ @router.get("/")
47
+ async def root():
48
+ """Root endpoint."""
49
+ return {
50
+ "message": "Protein Visualisation API",
51
+ "version": "1.0.0",
52
+ "endpoints": {
53
+ "/filters": "Get available filter options",
54
+ "/data": "Get filtered protein data",
55
+ "/summary": "Get aggregate statistics"
56
+ }
57
+ }
58
+
59
+
60
+ @router.get("/filters", response_model=FilterOptions)
61
+ async def get_filters():
62
+ """Get available filter options and ranges."""
63
+ try:
64
+ manager = _require_data_manager()
65
+ chains = sorted(manager.get_chain_sample())
66
+ fd = manager.get_filters_data()
67
+
68
+ return FilterOptions(
69
+ chain_ids=chains[:100],
70
+ rmsd_range={"min": float(fd["rmsd_min"]), "max": float(fd["rmsd_max"])},
71
+ tm_score_range={"min": float(fd["tm_min"]), "max": float(fd["tm_max"])},
72
+ length_range={"min": int(fd["length_min"]), "max": int(fd["length_max"])},
73
+ clusters=[
74
+ "cluster_ultra_high", "cluster_very_high", "cluster_high",
75
+ "cluster_medium", "cluster_low",
76
+ ],
77
+ total_records=fd["total_records"],
78
+ )
79
+ except HTTPException:
80
+ raise
81
+ except Exception as e:
82
+ logger.error(f"Error getting filters: {e}")
83
+ raise HTTPException(status_code=500, detail=str(e))
84
+
85
+
86
+ @router.post("/data", response_model=DataResponse)
87
+ async def get_data(filters: FilterParams):
88
+ """Get filtered protein data. Searches only by pdb_id_A and auth_asym_id_A."""
89
+ try:
90
+ manager = _require_data_manager()
91
+ limit = filters.limit or 1000
92
+ fetch_limit = limit * 10 if filters.cluster_id else limit
93
+
94
+ df = manager.query_edge(
95
+ pdb_id_a=filters.pdb_id,
96
+ auth_asym_id_a=filters.auth_asym_id,
97
+ chain_ids=filters.chain_ids,
98
+ rmsd_min=filters.rmsd_min,
99
+ rmsd_max=filters.rmsd_max,
100
+ tm_min=filters.tm_score_min,
101
+ tm_max=filters.tm_score_max,
102
+ limit=fetch_limit,
103
+ )
104
+
105
+ if filters.cluster_id:
106
+ df = df[df["cluster_id"] == filters.cluster_id]
107
+
108
+ filtered_total = len(df)
109
+ df = df.head(limit)
110
+
111
+ def _safe_float(val):
112
+ try:
113
+ v = float(val)
114
+ return None if math.isnan(v) else round(v, 6)
115
+ except (TypeError, ValueError):
116
+ return None
117
+
118
+ records = []
119
+ for row in df.itertuples(index=False):
120
+ raw_ph = getattr(row, "pH", None)
121
+ raw_temp = getattr(row, "temp_K", None)
122
+ records.append(DataRecord(
123
+ pdb_id_a=row.pdb_id_A,
124
+ auth_asym_id_a=row.auth_asym_id_A,
125
+ pdb_id_b=row.pdb_id_B,
126
+ auth_asym_id_b=row.auth_asym_id_B,
127
+ tm_score=float(row.TM1) if row.TM1 is not None else 0.0,
128
+ rmsd=float(row.RMSD) if row.RMSD is not None else 0.0,
129
+ structure_sim=_safe_float(row.structure_sim),
130
+ length_a=int(row.length_a) if row.length_a is not None else None,
131
+ length_b=int(row.length_b) if row.length_b is not None else None,
132
+ cluster_id=row.cluster_id,
133
+ exptl_method=getattr(row, "experimental_method", None) or None,
134
+ pH=_safe_float(raw_ph) if raw_ph else None,
135
+ temp=_safe_float(raw_temp) if raw_temp else None,
136
+ binding_status=getattr(row, "base_label", None) or None,
137
+ chain_composition=getattr(row, "chain_composition", None) or None,
138
+ delta_rosetta=_safe_float(getattr(row, "delta_Rosetta", None)),
139
+ delta_foldx=_safe_float(getattr(row, "delta_FoldX", None)),
140
+ delta_evoef2=_safe_float(getattr(row, "delta_EvoEF2", None)),
141
+ delta_rw=_safe_float(getattr(row, "delta_RW", None)),
142
+ delta_rw_plus=_safe_float(getattr(row, "delta_RW_plus", None)),
143
+ state_id_b=getattr(row, "state_id_B", None) or None,
144
+ state_fidelity=getattr(row, "state_fidelity", None) or None,
145
+ avg_sim=getattr(row, "avg_sim", None) or None,
146
+ observation_fidelity=getattr(row, "observation_fidelity", None) or None,
147
+ ))
148
+
149
+ return DataResponse(
150
+ data=records,
151
+ total=manager.get_total(),
152
+ filtered=filtered_total,
153
+ )
154
+
155
+ except HTTPException:
156
+ raise
157
+ except Exception as e:
158
+ logger.error(f"Error filtering data: {e}")
159
+ raise HTTPException(status_code=500, detail=str(e))
160
+
161
+
162
+ @router.get("/summary", response_model=SummaryStats)
163
+ async def get_summary(
164
+ chain_ids: List[str] = Query(default=None),
165
+ rmsd_min: float = Query(default=None),
166
+ rmsd_max: float = Query(default=None),
167
+ tm_score_min: float = Query(default=None),
168
+ tm_score_max: float = Query(default=None),
169
+ ):
170
+ """Get aggregate statistics for the dataset or filtered subset."""
171
+ try:
172
+ manager = _require_data_manager()
173
+ stats = manager.get_summary_stats(
174
+ chain_ids=chain_ids,
175
+ rmsd_min=rmsd_min,
176
+ rmsd_max=rmsd_max,
177
+ tm_min=tm_score_min,
178
+ tm_max=tm_score_max,
179
+ )
180
+ return SummaryStats(
181
+ total_records=stats["total_records"],
182
+ unique_chains=stats["unique_chains"],
183
+ avg_rmsd=float(stats["avg_rmsd"]),
184
+ avg_tm_score=float(stats["avg_tm_score"]),
185
+ avg_sequence_length=float(stats["avg_sequence_length"]),
186
+ rmsd_distribution={str(k): int(v) for k, v in stats["rmsd_distribution"].items()},
187
+ tm_score_distribution={str(k): int(v) for k, v in stats["tm_score_distribution"].items()},
188
+ length_distribution={str(k): int(v) for k, v in stats["length_distribution"].items()},
189
+ )
190
+ except HTTPException:
191
+ raise
192
+ except Exception as e:
193
+ logger.error(f"Error computing summary: {e}")
194
+ raise HTTPException(status_code=500, detail=str(e))
195
+
196
+
197
+ @router.get("/chain/resolve", response_model=ChainMetadata)
198
+ async def resolve_chain_endpoint(
199
+ pdb_id: str = Query(..., description="4-character PDB ID"),
200
+ auth_asym_id: str = Query(..., description="Chain identifier (e.g., 'A')"),
201
+ use_cache: bool = Query(True, description="Use cached data if available")
202
+ ):
203
+ """Resolve chain metadata from RCSB web API."""
204
+ metadata = await resolve_chain_async(pdb_id, auth_asym_id, use_cache=use_cache)
205
+ return metadata.model_copy(update={
206
+ "binding_status": tsv_loader.get_binding_status(pdb_id, auth_asym_id),
207
+ "chain_composition": tsv_loader.get_chain_composition(pdb_id, auth_asym_id),
208
+ "cath_id": tsv_loader.get_cath_id(pdb_id, auth_asym_id),
209
+ "cath_superfamily": tsv_loader.get_cath_superfamily(pdb_id, auth_asym_id),
210
+ "state_id": tsv_loader.get_state_id(pdb_id, auth_asym_id),
211
+ })
212
+
213
+
214
+ @router.post("/chain/batch-resolve")
215
+ async def batch_resolve_chains_endpoint(
216
+ chains: List[Dict[str, str]],
217
+ use_cache: bool = Query(True, description="Use cached data if available")
218
+ ):
219
+ """Batch resolve multiple chains at once."""
220
+ chain_tuples = []
221
+ for chain in chains:
222
+ pdb_id = chain.get("pdb_id")
223
+ auth_asym_id = chain.get("auth_asym_id")
224
+ if pdb_id and auth_asym_id:
225
+ chain_tuples.append((pdb_id, auth_asym_id))
226
+
227
+ result = await batch_resolve_chains_async(chain_tuples, use_cache=use_cache)
228
+ return {
229
+ key: metadata.model_copy(update={
230
+ "binding_status": tsv_loader.get_binding_status(*key.split("|")),
231
+ "chain_composition": tsv_loader.get_chain_composition(*key.split("|")),
232
+ "cath_id": tsv_loader.get_cath_id(*key.split("|")),
233
+ "cath_superfamily": tsv_loader.get_cath_superfamily(*key.split("|")),
234
+ "state_id": tsv_loader.get_state_id(*key.split("|")),
235
+ })
236
+ for key, metadata in result.items()
237
+ }
238
+
239
+
240
+ @router.get("/chain/cache/stats")
241
+ async def cache_stats():
242
+ """Get cache statistics."""
243
+ cache = get_cache()
244
+ return {
245
+ "cached_chains": len(cache.cache),
246
+ "ttl_hours": cache.ttl.total_seconds() / 3600
247
+ }
248
+
249
+
250
+ @router.get("/chain/functions")
251
+ async def get_chain_functions(
252
+ pdb_id: str = Query(..., description="4-character PDB ID"),
253
+ auth_asym_id: str = Query(..., description="Chain identifier (e.g., 'A')"),
254
+ ):
255
+ """Return ranked functional annotations for a chain from node table."""
256
+ functions = tsv_loader.get_functions(pdb_id, auth_asym_id)
257
+ if functions is None:
258
+ raise HTTPException(status_code=404, detail=f"No annotation found for {pdb_id}:{auth_asym_id}")
259
+ return {"pdb_id": pdb_id.lower(), "chain_id": auth_asym_id.upper(), "functions": functions}
260
+
261
+
262
+ @router.post("/chain/cache/clear")
263
+ async def clear_cache():
264
+ """Clear the chain metadata cache."""
265
+ cache = get_cache()
266
+ cache.clear()
267
+ return {"message": "Cache cleared successfully"}
backend/app/protein/data_loader.py CHANGED
@@ -1,335 +1,337 @@
1
- """
2
- SQLite-based data loader for protein structure data.
3
- Reads from MuSProt.db: edge table (pairwise comparisons from CSV)
4
- and node table (chain annotations from TSV).
5
- """
6
- import json
7
- import pandas as pd
8
- from pathlib import Path
9
- from typing import Any, Dict, List, Optional, Set
10
- import logging
11
-
12
- from app.protein.config import get_database_path, get_summary_path
13
- from app.protein.database import connect_readonly
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- def _assign_cluster(tm: float) -> str:
19
- if tm >= 0.99:
20
- return "cluster_ultra_high"
21
- if tm >= 0.95:
22
- return "cluster_very_high"
23
- if tm >= 0.90:
24
- return "cluster_high"
25
- if tm >= 0.80:
26
- return "cluster_medium"
27
- return "cluster_low"
28
-
29
-
30
- class DataManager:
31
- """Manages SQL queries against MuSProt.db edge and node tables."""
32
-
33
- def __init__(self, db_path: Optional[Path] = None, **_kwargs):
34
- self.db_path = db_path or get_database_path()
35
- self._loaded = False
36
- self._total_records = 0
37
- self._summary: Dict[str, Any] = {}
38
-
39
- def load_data(self) -> None:
40
- """Verify DB is accessible and load optional precomputed metadata."""
41
- if self._loaded:
42
- return
43
- summary_path = get_summary_path()
44
- if summary_path:
45
- self._summary = json.loads(summary_path.read_text(encoding="utf-8"))
46
- self._total_records = int(self._summary.get("total_records", 0))
47
-
48
- conn = connect_readonly(self.db_path)
49
- try:
50
- cur = conn.cursor()
51
- cur.execute("SELECT 1 FROM edge LIMIT 1")
52
- if not self._total_records:
53
- logger.warning(
54
- "No musprot_summary.json found; counting edge rows during startup. "
55
- "Publish the summary sidecar to avoid this scan."
56
- )
57
- cur.execute("SELECT COUNT(*) FROM edge")
58
- self._total_records = cur.fetchone()[0]
59
- finally:
60
- conn.close()
61
- self._loaded = True
62
- logger.info(f"DataManager ready: {self._total_records:,} edge records in {self.db_path}")
63
-
64
- def _connect(self):
65
- if not self._loaded:
66
- raise RuntimeError("Call load_data() first.")
67
- return connect_readonly(self.db_path)
68
-
69
- def get_chain_sample(self, limit: int = 500) -> Set[str]:
70
- """Return a small chain sample without loading the full node index."""
71
- conn = self._connect()
72
- try:
73
- cur = conn.cursor()
74
- cur.execute(
75
- "SELECT pdb_id, auth_asym_id FROM node "
76
- "WHERE pdb_id IS NOT NULL AND auth_asym_id IS NOT NULL LIMIT ?",
77
- (limit,),
78
- )
79
- return {
80
- f"{str(pdb).upper()}_{str(chain).upper()}"
81
- for pdb, chain in cur.fetchall()
82
- }
83
- finally:
84
- conn.close()
85
-
86
- def get_filters_data(self) -> Dict[str, Any]:
87
- """Return aggregated ranges for filter UI."""
88
- if self._summary:
89
- return {
90
- "tm_min": self._summary["tm_score_range"]["min"],
91
- "tm_max": self._summary["tm_score_range"]["max"],
92
- "rmsd_min": self._summary["rmsd_range"]["min"],
93
- "rmsd_max": self._summary["rmsd_range"]["max"],
94
- "length_min": self._summary["length_range"]["min"],
95
- "length_max": self._summary["length_range"]["max"],
96
- "total_records": self._total_records,
97
- }
98
-
99
- conn = self._connect()
100
- try:
101
- cur = conn.cursor()
102
- cur.execute(
103
- "SELECT MIN(CAST(TM1 AS REAL)), MAX(CAST(TM1 AS REAL)),"
104
- " MIN(CAST(RMSD AS REAL)), MAX(CAST(RMSD AS REAL))"
105
- " FROM edge"
106
- )
107
- tm_min, tm_max, rmsd_min, rmsd_max = cur.fetchone()
108
- finally:
109
- conn.close()
110
-
111
- from app.protein import tsv_loader
112
- lengths = []
113
- for row in tsv_loader._get_index().values():
114
- try:
115
- lengths.append(int(row["sequence_length"]))
116
- except (ValueError, TypeError):
117
- pass
118
-
119
- return {
120
- "tm_min": tm_min or 0.0,
121
- "tm_max": tm_max or 1.0,
122
- "rmsd_min": rmsd_min or 0.0,
123
- "rmsd_max": rmsd_max or 10.0,
124
- "length_min": min(lengths) if lengths else 0,
125
- "length_max": max(lengths) if lengths else 0,
126
- "total_records": self._total_records,
127
- }
128
-
129
- def query_edge(
130
- self,
131
- pdb_id_a: Optional[str] = None,
132
- auth_asym_id_a: Optional[str] = None,
133
- chain_ids: Optional[List[str]] = None,
134
- rmsd_min: Optional[float] = None,
135
- rmsd_max: Optional[float] = None,
136
- tm_min: Optional[float] = None,
137
- tm_max: Optional[float] = None,
138
- limit: int = 1000,
139
- ) -> pd.DataFrame:
140
- """Query edge table with filters. Returns DataFrame with cluster_id and lengths."""
141
- where_clauses: List[str] = []
142
- params: List[Any] = []
143
-
144
- if pdb_id_a and auth_asym_id_a:
145
- where_clauses.append("LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?)")
146
- params.extend([pdb_id_a, auth_asym_id_a])
147
- elif chain_ids:
148
- sub: List[str] = []
149
- for cid in chain_ids:
150
- parts = cid.split("_", 1)
151
- if len(parts) == 2:
152
- sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
153
- params.extend(parts)
154
- if sub:
155
- where_clauses.append("(" + " OR ".join(sub) + ")")
156
-
157
- if rmsd_min is not None:
158
- where_clauses.append("CAST(RMSD AS REAL) >= ?")
159
- params.append(rmsd_min)
160
- if rmsd_max is not None:
161
- where_clauses.append("CAST(RMSD AS REAL) <= ?")
162
- params.append(rmsd_max)
163
- if tm_min is not None:
164
- where_clauses.append("CAST(TM1 AS REAL) >= ?")
165
- params.append(tm_min)
166
- if tm_max is not None:
167
- where_clauses.append("CAST(TM1 AS REAL) <= ?")
168
- params.append(tm_max)
169
-
170
- where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
171
- sql = f"""
172
- SELECT e.pdb_id_A, e.auth_asym_id_A, e.pdb_id_B, e.auth_asym_id_B,
173
- CAST(e.TM1 AS REAL) AS TM1,
174
- CAST(e.RMSD AS REAL) AS RMSD,
175
- CAST(e.structure_sim AS REAL) AS structure_sim,
176
- e."delta_Rosetta", e."delta_FoldX", e."delta_EvoEF2",
177
- e."delta_RW", e."delta_RW+",
178
- e.state_id_B, e.state_fidelity, e.avg_sim, e.observation_fidelity
179
- FROM edge e
180
- {where_sql}
181
- LIMIT ?
182
- """
183
- params.append(limit)
184
-
185
- conn = self._connect()
186
- try:
187
- df = pd.read_sql_query(sql, conn, params=params)
188
- finally:
189
- conn.close()
190
-
191
- # Rename columns with special characters so itertuples works cleanly
192
- df = df.rename(columns={"delta_RW+": "delta_RW_plus"})
193
-
194
- df["cluster_id"] = df["TM1"].apply(
195
- lambda t: _assign_cluster(t) if pd.notna(t) else "cluster_low"
196
- )
197
-
198
- from app.protein import tsv_loader
199
- df["length_a"] = [
200
- tsv_loader.get_sequence(r.pdb_id_A, r.auth_asym_id_A)[1]
201
- for r in df.itertuples(index=False)
202
- ]
203
- df["length_b"] = [
204
- tsv_loader.get_sequence(r.pdb_id_B, r.auth_asym_id_B)[1]
205
- for r in df.itertuples(index=False)
206
- ]
207
- node_rows = [
208
- tsv_loader.get_node_row(r.pdb_id_B, r.auth_asym_id_B)
209
- for r in df.itertuples(index=False)
210
- ]
211
- df["experimental_method"] = [row.get("experimental_method") if row else None for row in node_rows]
212
- df["pH"] = [row.get("pH") if row else None for row in node_rows]
213
- df["temp_K"] = [row.get("temp_K") if row else None for row in node_rows]
214
-
215
- return df
216
-
217
- def get_summary_stats(
218
- self,
219
- chain_ids: Optional[List[str]] = None,
220
- rmsd_min: Optional[float] = None,
221
- rmsd_max: Optional[float] = None,
222
- tm_min: Optional[float] = None,
223
- tm_max: Optional[float] = None,
224
- ) -> Dict[str, Any]:
225
- """Compute aggregate statistics via SQL, with optional filters."""
226
- if not any([chain_ids, rmsd_min, rmsd_max, tm_min, tm_max]) and self._summary:
227
- return self._summary
228
-
229
- where_clauses: List[str] = []
230
- params: List[Any] = []
231
-
232
- if chain_ids:
233
- sub: List[str] = []
234
- for cid in chain_ids:
235
- parts = cid.split("_", 1)
236
- if len(parts) == 2:
237
- sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
238
- params.extend(parts)
239
- if sub:
240
- where_clauses.append("(" + " OR ".join(sub) + ")")
241
-
242
- if rmsd_min is not None:
243
- where_clauses.append("CAST(RMSD AS REAL) >= ?")
244
- params.append(rmsd_min)
245
- if rmsd_max is not None:
246
- where_clauses.append("CAST(RMSD AS REAL) <= ?")
247
- params.append(rmsd_max)
248
- if tm_min is not None:
249
- where_clauses.append("CAST(TM1 AS REAL) >= ?")
250
- params.append(tm_min)
251
- if tm_max is not None:
252
- where_clauses.append("CAST(TM1 AS REAL) <= ?")
253
- params.append(tm_max)
254
-
255
- where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
256
-
257
- conn = self._connect()
258
- try:
259
- cur = conn.cursor()
260
- cur.execute(
261
- f"SELECT COUNT(*), AVG(CAST(RMSD AS REAL)), AVG(CAST(TM1 AS REAL))"
262
- f" FROM edge {where_sql}",
263
- params,
264
- )
265
- total, avg_rmsd, avg_tm = cur.fetchone()
266
-
267
- cur.execute(
268
- f"SELECT COUNT(DISTINCT LOWER(pdb_id_A) || '_' || LOWER(auth_asym_id_A))"
269
- f" FROM edge {where_sql}",
270
- params,
271
- )
272
- unique_chains = cur.fetchone()[0]
273
-
274
- def _dist(col, bins, labels):
275
- cases = " ".join(
276
- f"WHEN CAST({col} AS REAL) >= {lo} AND CAST({col} AS REAL) < {hi} THEN '{lbl}'"
277
- for (lo, hi), lbl in zip(zip(bins, bins[1:]), labels)
278
- )
279
- sql = (
280
- f"SELECT CASE {cases} ELSE '{labels[-1]}' END AS bucket, COUNT(*)"
281
- f" FROM edge {where_sql} GROUP BY bucket"
282
- )
283
- cur.execute(sql, params)
284
- return {r[0]: r[1] for r in cur.fetchall()}
285
-
286
- rmsd_dist = _dist(
287
- "RMSD",
288
- [0, 0.5, 1.0, 1.5, 2.0, 5.0, 1e9],
289
- ["0-0.5", "0.5-1.0", "1.0-1.5", "1.5-2.0", "2.0-5.0", ">5.0"],
290
- )
291
- tm_dist = _dist(
292
- "TM1",
293
- [0, 0.5, 0.7, 0.85, 0.95, 1.01],
294
- ["0-0.5", "0.5-0.7", "0.7-0.85", "0.85-0.95", "0.95-1.0"],
295
- )
296
- finally:
297
- conn.close()
298
-
299
- from app.protein import tsv_loader
300
- lengths = []
301
- for row in tsv_loader._get_index().values():
302
- try:
303
- lengths.append(int(row["sequence_length"]))
304
- except (ValueError, TypeError):
305
- pass
306
- avg_len = sum(lengths) / len(lengths) if lengths else 0.0
307
- len_dist = {}
308
- for length in lengths:
309
- if length <= 100:
310
- bucket = "0-100"
311
- elif length <= 200:
312
- bucket = "100-200"
313
- elif length <= 300:
314
- bucket = "200-300"
315
- elif length <= 500:
316
- bucket = "300-500"
317
- elif length <= 1000:
318
- bucket = "500-1000"
319
- else:
320
- bucket = ">1000"
321
- len_dist[bucket] = len_dist.get(bucket, 0) + 1
322
-
323
- return {
324
- "total_records": total or 0,
325
- "unique_chains": unique_chains or 0,
326
- "avg_rmsd": avg_rmsd or 0.0,
327
- "avg_tm_score": avg_tm or 0.0,
328
- "avg_sequence_length": avg_len,
329
- "rmsd_distribution": rmsd_dist,
330
- "tm_score_distribution": tm_dist,
331
- "length_distribution": len_dist,
332
- }
333
-
334
- def get_total(self) -> int:
335
- return self._total_records
 
 
 
1
+ """
2
+ SQLite-based data loader for protein structure data.
3
+ Reads from MuSProt.db: edge table (pairwise comparisons from CSV)
4
+ and node table (chain annotations from TSV).
5
+ """
6
+ import json
7
+ import pandas as pd
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Set
10
+ import logging
11
+
12
+ from app.protein.config import get_database_path, get_summary_path
13
+ from app.protein.database import connect_readonly
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _assign_cluster(tm: float) -> str:
19
+ if tm >= 0.99:
20
+ return "cluster_ultra_high"
21
+ if tm >= 0.95:
22
+ return "cluster_very_high"
23
+ if tm >= 0.90:
24
+ return "cluster_high"
25
+ if tm >= 0.80:
26
+ return "cluster_medium"
27
+ return "cluster_low"
28
+
29
+
30
+ class DataManager:
31
+ """Manages SQL queries against MuSProt.db edge and node tables."""
32
+
33
+ def __init__(self, db_path: Optional[Path] = None, **_kwargs):
34
+ self.db_path = db_path or get_database_path()
35
+ self._loaded = False
36
+ self._total_records = 0
37
+ self._summary: Dict[str, Any] = {}
38
+
39
+ def load_data(self) -> None:
40
+ """Verify DB is accessible and load optional precomputed metadata."""
41
+ if self._loaded:
42
+ return
43
+ summary_path = get_summary_path()
44
+ if summary_path:
45
+ self._summary = json.loads(summary_path.read_text(encoding="utf-8"))
46
+ self._total_records = int(self._summary.get("total_records", 0))
47
+
48
+ conn = connect_readonly(self.db_path)
49
+ try:
50
+ cur = conn.cursor()
51
+ cur.execute("SELECT 1 FROM edge LIMIT 1")
52
+ if not self._total_records:
53
+ logger.warning(
54
+ "No musprot_summary.json found; counting edge rows during startup. "
55
+ "Publish the summary sidecar to avoid this scan."
56
+ )
57
+ cur.execute("SELECT COUNT(*) FROM edge")
58
+ self._total_records = cur.fetchone()[0]
59
+ finally:
60
+ conn.close()
61
+ self._loaded = True
62
+ logger.info(f"DataManager ready: {self._total_records:,} edge records in {self.db_path}")
63
+
64
+ def _connect(self):
65
+ if not self._loaded:
66
+ raise RuntimeError("Call load_data() first.")
67
+ return connect_readonly(self.db_path)
68
+
69
+ def get_chain_sample(self, limit: int = 500) -> Set[str]:
70
+ """Return a small chain sample without loading the full node index."""
71
+ conn = self._connect()
72
+ try:
73
+ cur = conn.cursor()
74
+ cur.execute(
75
+ "SELECT pdb_id, auth_asym_id FROM node "
76
+ "WHERE pdb_id IS NOT NULL AND auth_asym_id IS NOT NULL LIMIT ?",
77
+ (limit,),
78
+ )
79
+ return {
80
+ f"{str(pdb).upper()}_{str(chain).upper()}"
81
+ for pdb, chain in cur.fetchall()
82
+ }
83
+ finally:
84
+ conn.close()
85
+
86
+ def get_filters_data(self) -> Dict[str, Any]:
87
+ """Return aggregated ranges for filter UI."""
88
+ if self._summary:
89
+ return {
90
+ "tm_min": self._summary["tm_score_range"]["min"],
91
+ "tm_max": self._summary["tm_score_range"]["max"],
92
+ "rmsd_min": self._summary["rmsd_range"]["min"],
93
+ "rmsd_max": self._summary["rmsd_range"]["max"],
94
+ "length_min": self._summary["length_range"]["min"],
95
+ "length_max": self._summary["length_range"]["max"],
96
+ "total_records": self._total_records,
97
+ }
98
+
99
+ conn = self._connect()
100
+ try:
101
+ cur = conn.cursor()
102
+ cur.execute(
103
+ "SELECT MIN(CAST(TM1 AS REAL)), MAX(CAST(TM1 AS REAL)),"
104
+ " MIN(CAST(RMSD AS REAL)), MAX(CAST(RMSD AS REAL))"
105
+ " FROM edge"
106
+ )
107
+ tm_min, tm_max, rmsd_min, rmsd_max = cur.fetchone()
108
+ finally:
109
+ conn.close()
110
+
111
+ from app.protein import tsv_loader
112
+ lengths = []
113
+ for row in tsv_loader._get_index().values():
114
+ try:
115
+ lengths.append(int(row["sequence_length"]))
116
+ except (ValueError, TypeError):
117
+ pass
118
+
119
+ return {
120
+ "tm_min": tm_min or 0.0,
121
+ "tm_max": tm_max or 1.0,
122
+ "rmsd_min": rmsd_min or 0.0,
123
+ "rmsd_max": rmsd_max or 10.0,
124
+ "length_min": min(lengths) if lengths else 0,
125
+ "length_max": max(lengths) if lengths else 0,
126
+ "total_records": self._total_records,
127
+ }
128
+
129
+ def query_edge(
130
+ self,
131
+ pdb_id_a: Optional[str] = None,
132
+ auth_asym_id_a: Optional[str] = None,
133
+ chain_ids: Optional[List[str]] = None,
134
+ rmsd_min: Optional[float] = None,
135
+ rmsd_max: Optional[float] = None,
136
+ tm_min: Optional[float] = None,
137
+ tm_max: Optional[float] = None,
138
+ limit: int = 1000,
139
+ ) -> pd.DataFrame:
140
+ """Query edge table with filters. Returns DataFrame with cluster_id and lengths."""
141
+ where_clauses: List[str] = []
142
+ params: List[Any] = []
143
+
144
+ if pdb_id_a and auth_asym_id_a:
145
+ where_clauses.append("LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?)")
146
+ params.extend([pdb_id_a, auth_asym_id_a])
147
+ elif chain_ids:
148
+ sub: List[str] = []
149
+ for cid in chain_ids:
150
+ parts = cid.split("_", 1)
151
+ if len(parts) == 2:
152
+ sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
153
+ params.extend(parts)
154
+ if sub:
155
+ where_clauses.append("(" + " OR ".join(sub) + ")")
156
+
157
+ if rmsd_min is not None:
158
+ where_clauses.append("CAST(RMSD AS REAL) >= ?")
159
+ params.append(rmsd_min)
160
+ if rmsd_max is not None:
161
+ where_clauses.append("CAST(RMSD AS REAL) <= ?")
162
+ params.append(rmsd_max)
163
+ if tm_min is not None:
164
+ where_clauses.append("CAST(TM1 AS REAL) >= ?")
165
+ params.append(tm_min)
166
+ if tm_max is not None:
167
+ where_clauses.append("CAST(TM1 AS REAL) <= ?")
168
+ params.append(tm_max)
169
+
170
+ where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
171
+ sql = f"""
172
+ SELECT e.pdb_id_A, e.auth_asym_id_A, e.pdb_id_B, e.auth_asym_id_B,
173
+ CAST(e.TM1 AS REAL) AS TM1,
174
+ CAST(e.RMSD AS REAL) AS RMSD,
175
+ CAST(e.structure_sim AS REAL) AS structure_sim,
176
+ e."delta_Rosetta", e."delta_FoldX", e."delta_EvoEF2",
177
+ e."delta_RW", e."delta_RW+",
178
+ e.state_id_B, e.state_fidelity, e.avg_sim, e.observation_fidelity
179
+ FROM edge e
180
+ {where_sql}
181
+ LIMIT ?
182
+ """
183
+ params.append(limit)
184
+
185
+ conn = self._connect()
186
+ try:
187
+ df = pd.read_sql_query(sql, conn, params=params)
188
+ finally:
189
+ conn.close()
190
+
191
+ # Rename columns with special characters so itertuples works cleanly
192
+ df = df.rename(columns={"delta_RW+": "delta_RW_plus"})
193
+
194
+ df["cluster_id"] = df["TM1"].apply(
195
+ lambda t: _assign_cluster(t) if pd.notna(t) else "cluster_low"
196
+ )
197
+
198
+ from app.protein import tsv_loader
199
+ df["length_a"] = [
200
+ tsv_loader.get_sequence(r.pdb_id_A, r.auth_asym_id_A)[1]
201
+ for r in df.itertuples(index=False)
202
+ ]
203
+ df["length_b"] = [
204
+ tsv_loader.get_sequence(r.pdb_id_B, r.auth_asym_id_B)[1]
205
+ for r in df.itertuples(index=False)
206
+ ]
207
+ node_rows = [
208
+ tsv_loader.get_node_row(r.pdb_id_B, r.auth_asym_id_B)
209
+ for r in df.itertuples(index=False)
210
+ ]
211
+ df["experimental_method"] = [row.get("experimental_method") if row else None for row in node_rows]
212
+ df["pH"] = [row.get("pH") if row else None for row in node_rows]
213
+ df["temp_K"] = [row.get("temp_K") if row else None for row in node_rows]
214
+ df["base_label"] = [row.get("base_label") if row else None for row in node_rows]
215
+ df["chain_composition"] = [row.get("chain_composition") if row else None for row in node_rows]
216
+
217
+ return df
218
+
219
+ def get_summary_stats(
220
+ self,
221
+ chain_ids: Optional[List[str]] = None,
222
+ rmsd_min: Optional[float] = None,
223
+ rmsd_max: Optional[float] = None,
224
+ tm_min: Optional[float] = None,
225
+ tm_max: Optional[float] = None,
226
+ ) -> Dict[str, Any]:
227
+ """Compute aggregate statistics via SQL, with optional filters."""
228
+ if not any([chain_ids, rmsd_min, rmsd_max, tm_min, tm_max]) and self._summary:
229
+ return self._summary
230
+
231
+ where_clauses: List[str] = []
232
+ params: List[Any] = []
233
+
234
+ if chain_ids:
235
+ sub: List[str] = []
236
+ for cid in chain_ids:
237
+ parts = cid.split("_", 1)
238
+ if len(parts) == 2:
239
+ sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
240
+ params.extend(parts)
241
+ if sub:
242
+ where_clauses.append("(" + " OR ".join(sub) + ")")
243
+
244
+ if rmsd_min is not None:
245
+ where_clauses.append("CAST(RMSD AS REAL) >= ?")
246
+ params.append(rmsd_min)
247
+ if rmsd_max is not None:
248
+ where_clauses.append("CAST(RMSD AS REAL) <= ?")
249
+ params.append(rmsd_max)
250
+ if tm_min is not None:
251
+ where_clauses.append("CAST(TM1 AS REAL) >= ?")
252
+ params.append(tm_min)
253
+ if tm_max is not None:
254
+ where_clauses.append("CAST(TM1 AS REAL) <= ?")
255
+ params.append(tm_max)
256
+
257
+ where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
258
+
259
+ conn = self._connect()
260
+ try:
261
+ cur = conn.cursor()
262
+ cur.execute(
263
+ f"SELECT COUNT(*), AVG(CAST(RMSD AS REAL)), AVG(CAST(TM1 AS REAL))"
264
+ f" FROM edge {where_sql}",
265
+ params,
266
+ )
267
+ total, avg_rmsd, avg_tm = cur.fetchone()
268
+
269
+ cur.execute(
270
+ f"SELECT COUNT(DISTINCT LOWER(pdb_id_A) || '_' || LOWER(auth_asym_id_A))"
271
+ f" FROM edge {where_sql}",
272
+ params,
273
+ )
274
+ unique_chains = cur.fetchone()[0]
275
+
276
+ def _dist(col, bins, labels):
277
+ cases = " ".join(
278
+ f"WHEN CAST({col} AS REAL) >= {lo} AND CAST({col} AS REAL) < {hi} THEN '{lbl}'"
279
+ for (lo, hi), lbl in zip(zip(bins, bins[1:]), labels)
280
+ )
281
+ sql = (
282
+ f"SELECT CASE {cases} ELSE '{labels[-1]}' END AS bucket, COUNT(*)"
283
+ f" FROM edge {where_sql} GROUP BY bucket"
284
+ )
285
+ cur.execute(sql, params)
286
+ return {r[0]: r[1] for r in cur.fetchall()}
287
+
288
+ rmsd_dist = _dist(
289
+ "RMSD",
290
+ [0, 0.5, 1.0, 1.5, 2.0, 5.0, 1e9],
291
+ ["0-0.5", "0.5-1.0", "1.0-1.5", "1.5-2.0", "2.0-5.0", ">5.0"],
292
+ )
293
+ tm_dist = _dist(
294
+ "TM1",
295
+ [0, 0.5, 0.7, 0.85, 0.95, 1.01],
296
+ ["0-0.5", "0.5-0.7", "0.7-0.85", "0.85-0.95", "0.95-1.0"],
297
+ )
298
+ finally:
299
+ conn.close()
300
+
301
+ from app.protein import tsv_loader
302
+ lengths = []
303
+ for row in tsv_loader._get_index().values():
304
+ try:
305
+ lengths.append(int(row["sequence_length"]))
306
+ except (ValueError, TypeError):
307
+ pass
308
+ avg_len = sum(lengths) / len(lengths) if lengths else 0.0
309
+ len_dist = {}
310
+ for length in lengths:
311
+ if length <= 100:
312
+ bucket = "0-100"
313
+ elif length <= 200:
314
+ bucket = "100-200"
315
+ elif length <= 300:
316
+ bucket = "200-300"
317
+ elif length <= 500:
318
+ bucket = "300-500"
319
+ elif length <= 1000:
320
+ bucket = "500-1000"
321
+ else:
322
+ bucket = ">1000"
323
+ len_dist[bucket] = len_dist.get(bucket, 0) + 1
324
+
325
+ return {
326
+ "total_records": total or 0,
327
+ "unique_chains": unique_chains or 0,
328
+ "avg_rmsd": avg_rmsd or 0.0,
329
+ "avg_tm_score": avg_tm or 0.0,
330
+ "avg_sequence_length": avg_len,
331
+ "rmsd_distribution": rmsd_dist,
332
+ "tm_score_distribution": tm_dist,
333
+ "length_distribution": len_dist,
334
+ }
335
+
336
+ def get_total(self) -> int:
337
+ return self._total_records
backend/app/protein/models.py CHANGED
@@ -59,6 +59,7 @@ class ChainMetadata(BaseModel):
59
  polymer_entity_instance: Optional[PolymerEntityInstance] = None
60
  nonpolymer_entities: Optional[List[NonpolymerEntity]] = None
61
  binding_status: Optional[str] = None
 
62
  cath_id: Optional[str] = None
63
  cath_superfamily: Optional[str] = None
64
  state_id: Optional[str] = None
@@ -108,6 +109,9 @@ class DataRecord(BaseModel):
108
  exptl_method: Optional[str] = None
109
  temp: Optional[float] = None
110
  pH: Optional[float] = None
 
 
 
111
  # Energy score deltas fetched directly from the edge table
112
  delta_rosetta: Optional[float] = None
113
  delta_foldx: Optional[float] = None
 
59
  polymer_entity_instance: Optional[PolymerEntityInstance] = None
60
  nonpolymer_entities: Optional[List[NonpolymerEntity]] = None
61
  binding_status: Optional[str] = None
62
+ chain_composition: Optional[str] = None
63
  cath_id: Optional[str] = None
64
  cath_superfamily: Optional[str] = None
65
  state_id: Optional[str] = None
 
109
  exptl_method: Optional[str] = None
110
  temp: Optional[float] = None
111
  pH: Optional[float] = None
112
+ # Matched-chain (B) annotations from the node table
113
+ binding_status: Optional[str] = None
114
+ chain_composition: Optional[str] = None
115
  # Energy score deltas fetched directly from the edge table
116
  delta_rosetta: Optional[float] = None
117
  delta_foldx: Optional[float] = None
backend/app/protein/tsv_loader.py CHANGED
@@ -1,201 +1,210 @@
1
- """
2
- Node table lookup for chain annotations.
3
-
4
- Loads the node table from MuSProt.db once on first use and indexes rows
5
- by (pdb_id_lower, auth_asym_id_upper). When multiple rows share the same
6
- key the first occurrence is kept.
7
- """
8
- import ast
9
- import sqlite3
10
- import logging
11
- import zlib
12
- from functools import lru_cache
13
- from typing import Dict, List, Optional, Tuple, Any
14
-
15
- from app.protein.config import get_database_path, get_node_database_path
16
- from app.protein.database import connect_readonly
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
- _Row = Dict[str, str]
21
-
22
- # (pdb_id_lower, auth_asym_id_upper) -> relevant fields
23
- _index: Optional[Dict[Tuple[str, str], _Row]] = None
24
-
25
-
26
- def _load() -> Dict[Tuple[str, str], _Row]:
27
- index: Dict[Tuple[str, str], _Row] = {}
28
- db_path = get_database_path()
29
- conn = connect_readonly(db_path)
30
- try:
31
- conn.row_factory = sqlite3.Row
32
- cur = conn.cursor()
33
- cur.execute(
34
- 'SELECT pdb_id, auth_asym_id, base_label, sequence, sequence_length,'
35
- ' CATH_ID, cath_superfamily, Rosetta, FoldX, EvoEF2, RW, "RW+", ranked_functions,'
36
- ' state_id, experimental_method, pH, temp_K'
37
- ' FROM node'
38
- )
39
- for row in cur:
40
- pdb_id = row["pdb_id"] or ""
41
- auth_asym_id = row["auth_asym_id"] or ""
42
- key = (pdb_id.lower(), auth_asym_id.upper())
43
- if key not in index:
44
- index[key] = {
45
- "base_label": row["base_label"] or "",
46
- "sequence": row["sequence"] or "",
47
- "sequence_length": row["sequence_length"] or "",
48
- "CATH_ID": row["CATH_ID"] or "",
49
- "cath_superfamily": row["cath_superfamily"] or "",
50
- "Rosetta": row["Rosetta"] or "",
51
- "FoldX": row["FoldX"] or "",
52
- "EvoEF2": row["EvoEF2"] or "",
53
- "RW": row["RW"] or "",
54
- "RW+": row["RW+"] or "",
55
- "ranked_functions": row["ranked_functions"] or "",
56
- "state_id": row["state_id"] or "",
57
- "experimental_method": row["experimental_method"] or "",
58
- "pH": row["pH"] or "",
59
- "temp_K": row["temp_K"] or "",
60
- }
61
- finally:
62
- conn.close()
63
-
64
- logger.info(f"Node index loaded: {len(index):,} entries from {db_path}")
65
- return index
66
-
67
-
68
- def _get_index() -> Dict[Tuple[str, str], _Row]:
69
- global _index
70
- if _index is None:
71
- _index = _load()
72
- return _index
73
-
74
-
75
- def reset_index() -> None:
76
- """Force reload of node index."""
77
- global _index
78
- _index = None
79
- _lookup_node_row.cache_clear()
80
-
81
-
82
- @lru_cache(maxsize=4096)
83
- def _lookup_node_row(pdb_id: str, auth_asym_id: str) -> Optional[_Row]:
84
- """Read one node row without blocking on a full-table in-memory index."""
85
- conn = connect_readonly(get_node_database_path())
86
- try:
87
- conn.row_factory = sqlite3.Row
88
- row = conn.execute(
89
- 'SELECT base_label, sequence, sequence_length, CATH_ID, cath_superfamily,'
90
- ' Rosetta, FoldX, EvoEF2, RW, "RW+", ranked_functions,'
91
- ' state_id, experimental_method, pH, temp_K'
92
- ' FROM node WHERE LOWER(pdb_id) = ? AND UPPER(auth_asym_id) = ? LIMIT 1',
93
- (pdb_id.lower(), auth_asym_id.upper()),
94
- ).fetchone()
95
- if row is None:
96
- return None
97
- return {
98
- key: zlib.decompress(value).decode("utf-8") if isinstance(value, bytes) else value or ""
99
- for key, value in ((key, row[key]) for key in row.keys())
100
- }
101
- finally:
102
- conn.close()
103
-
104
-
105
- def _parse_functions(raw: str) -> List[str]:
106
- raw = raw.strip()
107
- if not raw:
108
- return []
109
- try:
110
- parsed = ast.literal_eval(raw)
111
- if isinstance(parsed, list):
112
- return [str(f).strip() for f in parsed if str(f).strip()]
113
- except (ValueError, SyntaxError):
114
- pass
115
- return [f.strip() for f in raw.split(";") if f.strip()]
116
-
117
-
118
- def _parse_float(val: str) -> Optional[float]:
119
- val = str(val).strip()
120
- if not val:
121
- return None
122
- try:
123
- return float(val)
124
- except ValueError:
125
- return None
126
-
127
-
128
- def get_energy_scores(pdb_id: str, auth_asym_id: str) -> Optional[Dict[str, Any]]:
129
- """Return energy scores (Rosetta, FoldX, EvoEF2, RW, RW+) or None if not found."""
130
- row = get_node_row(pdb_id, auth_asym_id)
131
- if row is None:
132
- return None
133
- return {
134
- "Rosetta": _parse_float(row.get("Rosetta", "")),
135
- "FoldX": _parse_float(row.get("FoldX", "")),
136
- "EvoEF2": _parse_float(row.get("EvoEF2", "")),
137
- "RW": _parse_float(row.get("RW", "")),
138
- "RW+": _parse_float(row.get("RW+", "")),
139
- }
140
-
141
-
142
- def get_node_row(pdb_id: str, auth_asym_id: str) -> Optional[_Row]:
143
- """Return the cached node row for a chain."""
144
- key = (pdb_id.lower(), auth_asym_id.upper())
145
- return _index.get(key) if _index is not None else _lookup_node_row(*key)
146
-
147
-
148
- def get_binding_status(pdb_id: str, auth_asym_id: str) -> Optional[str]:
149
- """Return binding status (base_label: apo/holo) or None if not found."""
150
- row = get_node_row(pdb_id, auth_asym_id)
151
- if row is None:
152
- return None
153
- return row.get("base_label") or None
154
-
155
-
156
- def get_cath_id(pdb_id: str, auth_asym_id: str) -> Optional[str]:
157
- """Return CATH_ID or 'uncategorized' if not found."""
158
- row = get_node_row(pdb_id, auth_asym_id)
159
- if row is None:
160
- return "uncategorized"
161
- val = row.get("CATH_ID", "").strip()
162
- return val if val else "uncategorized"
163
-
164
-
165
- def get_cath_superfamily(pdb_id: str, auth_asym_id: str) -> Optional[str]:
166
- """Return CATH superfamily code (e.g. '3.40.190.10') or None if not found."""
167
- row = get_node_row(pdb_id, auth_asym_id)
168
- if row is None:
169
- return None
170
- val = row.get("cath_superfamily", "").strip()
171
- return val if val else None
172
-
173
-
174
- def get_sequence(pdb_id: str, auth_asym_id: str) -> Tuple[Optional[str], Optional[int]]:
175
- """Return (seq_can, sequence_length) from node table, or (None, None) if not found."""
176
- row = get_node_row(pdb_id, auth_asym_id)
177
- if row is None:
178
- return None, None
179
- seq = row["sequence"] or None
180
- try:
181
- length = int(row["sequence_length"]) if row["sequence_length"].strip() else None
182
- except ValueError:
183
- length = None
184
- return seq, length
185
-
186
-
187
- def get_functions(pdb_id: str, auth_asym_id: str) -> Optional[List[str]]:
188
- """Return ranked function list for a chain, or None if not in node table."""
189
- row = get_node_row(pdb_id, auth_asym_id)
190
- if row is None:
191
- return None
192
- return _parse_functions(row["ranked_functions"])
193
-
194
-
195
- def get_state_id(pdb_id: str, auth_asym_id: str) -> Optional[str]:
196
- """Return state_id for a chain, or None if not found."""
197
- row = get_node_row(pdb_id, auth_asym_id)
198
- if row is None:
199
- return None
200
- val = row.get("state_id", "").strip()
201
- return val if val else None
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Node table lookup for chain annotations.
3
+
4
+ Loads the node table from MuSProt.db once on first use and indexes rows
5
+ by (pdb_id_lower, auth_asym_id_upper). When multiple rows share the same
6
+ key the first occurrence is kept.
7
+ """
8
+ import ast
9
+ import sqlite3
10
+ import logging
11
+ import zlib
12
+ from functools import lru_cache
13
+ from typing import Dict, List, Optional, Tuple, Any
14
+
15
+ from app.protein.config import get_database_path, get_node_database_path
16
+ from app.protein.database import connect_readonly
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _Row = Dict[str, str]
21
+
22
+ # (pdb_id_lower, auth_asym_id_upper) -> relevant fields
23
+ _index: Optional[Dict[Tuple[str, str], _Row]] = None
24
+
25
+
26
+ def _load() -> Dict[Tuple[str, str], _Row]:
27
+ index: Dict[Tuple[str, str], _Row] = {}
28
+ db_path = get_database_path()
29
+ conn = connect_readonly(db_path)
30
+ try:
31
+ conn.row_factory = sqlite3.Row
32
+ cur = conn.cursor()
33
+ cur.execute(
34
+ 'SELECT pdb_id, auth_asym_id, base_label, sequence, sequence_length,'
35
+ ' CATH_ID, cath_superfamily, Rosetta, FoldX, EvoEF2, RW, "RW+", ranked_functions,'
36
+ ' state_id, experimental_method, pH, temp_K, chain_composition'
37
+ ' FROM node'
38
+ )
39
+ for row in cur:
40
+ pdb_id = row["pdb_id"] or ""
41
+ auth_asym_id = row["auth_asym_id"] or ""
42
+ key = (pdb_id.lower(), auth_asym_id.upper())
43
+ if key not in index:
44
+ index[key] = {
45
+ "base_label": row["base_label"] or "",
46
+ "sequence": row["sequence"] or "",
47
+ "sequence_length": row["sequence_length"] or "",
48
+ "CATH_ID": row["CATH_ID"] or "",
49
+ "cath_superfamily": row["cath_superfamily"] or "",
50
+ "Rosetta": row["Rosetta"] or "",
51
+ "FoldX": row["FoldX"] or "",
52
+ "EvoEF2": row["EvoEF2"] or "",
53
+ "RW": row["RW"] or "",
54
+ "RW+": row["RW+"] or "",
55
+ "ranked_functions": row["ranked_functions"] or "",
56
+ "state_id": row["state_id"] or "",
57
+ "experimental_method": row["experimental_method"] or "",
58
+ "pH": row["pH"] or "",
59
+ "temp_K": row["temp_K"] or "",
60
+ "chain_composition": row["chain_composition"] or "",
61
+ }
62
+ finally:
63
+ conn.close()
64
+
65
+ logger.info(f"Node index loaded: {len(index):,} entries from {db_path}")
66
+ return index
67
+
68
+
69
+ def _get_index() -> Dict[Tuple[str, str], _Row]:
70
+ global _index
71
+ if _index is None:
72
+ _index = _load()
73
+ return _index
74
+
75
+
76
+ def reset_index() -> None:
77
+ """Force reload of node index."""
78
+ global _index
79
+ _index = None
80
+ _lookup_node_row.cache_clear()
81
+
82
+
83
+ @lru_cache(maxsize=4096)
84
+ def _lookup_node_row(pdb_id: str, auth_asym_id: str) -> Optional[_Row]:
85
+ """Read one node row without blocking on a full-table in-memory index."""
86
+ conn = connect_readonly(get_node_database_path())
87
+ try:
88
+ conn.row_factory = sqlite3.Row
89
+ row = conn.execute(
90
+ 'SELECT base_label, sequence, sequence_length, CATH_ID, cath_superfamily,'
91
+ ' Rosetta, FoldX, EvoEF2, RW, "RW+", ranked_functions,'
92
+ ' state_id, experimental_method, pH, temp_K, chain_composition'
93
+ ' FROM node WHERE LOWER(pdb_id) = ? AND UPPER(auth_asym_id) = ? LIMIT 1',
94
+ (pdb_id.lower(), auth_asym_id.upper()),
95
+ ).fetchone()
96
+ if row is None:
97
+ return None
98
+ return {
99
+ key: zlib.decompress(value).decode("utf-8") if isinstance(value, bytes) else value or ""
100
+ for key, value in ((key, row[key]) for key in row.keys())
101
+ }
102
+ finally:
103
+ conn.close()
104
+
105
+
106
+ def _parse_functions(raw: str) -> List[str]:
107
+ raw = raw.strip()
108
+ if not raw:
109
+ return []
110
+ try:
111
+ parsed = ast.literal_eval(raw)
112
+ if isinstance(parsed, list):
113
+ return [str(f).strip() for f in parsed if str(f).strip()]
114
+ except (ValueError, SyntaxError):
115
+ pass
116
+ return [f.strip() for f in raw.split(";") if f.strip()]
117
+
118
+
119
+ def _parse_float(val: str) -> Optional[float]:
120
+ val = str(val).strip()
121
+ if not val:
122
+ return None
123
+ try:
124
+ return float(val)
125
+ except ValueError:
126
+ return None
127
+
128
+
129
+ def get_energy_scores(pdb_id: str, auth_asym_id: str) -> Optional[Dict[str, Any]]:
130
+ """Return energy scores (Rosetta, FoldX, EvoEF2, RW, RW+) or None if not found."""
131
+ row = get_node_row(pdb_id, auth_asym_id)
132
+ if row is None:
133
+ return None
134
+ return {
135
+ "Rosetta": _parse_float(row.get("Rosetta", "")),
136
+ "FoldX": _parse_float(row.get("FoldX", "")),
137
+ "EvoEF2": _parse_float(row.get("EvoEF2", "")),
138
+ "RW": _parse_float(row.get("RW", "")),
139
+ "RW+": _parse_float(row.get("RW+", "")),
140
+ }
141
+
142
+
143
+ def get_node_row(pdb_id: str, auth_asym_id: str) -> Optional[_Row]:
144
+ """Return the cached node row for a chain."""
145
+ key = (pdb_id.lower(), auth_asym_id.upper())
146
+ return _index.get(key) if _index is not None else _lookup_node_row(*key)
147
+
148
+
149
+ def get_binding_status(pdb_id: str, auth_asym_id: str) -> Optional[str]:
150
+ """Return binding status (base_label: apo/holo) or None if not found."""
151
+ row = get_node_row(pdb_id, auth_asym_id)
152
+ if row is None:
153
+ return None
154
+ return row.get("base_label") or None
155
+
156
+
157
+ def get_chain_composition(pdb_id: str, auth_asym_id: str) -> Optional[str]:
158
+ """Return chain composition (monomeric/homomeric/heteromeric) or None if not found."""
159
+ row = get_node_row(pdb_id, auth_asym_id)
160
+ if row is None:
161
+ return None
162
+ return row.get("chain_composition") or None
163
+
164
+
165
+ def get_cath_id(pdb_id: str, auth_asym_id: str) -> Optional[str]:
166
+ """Return CATH_ID or 'uncategorized' if not found."""
167
+ row = get_node_row(pdb_id, auth_asym_id)
168
+ if row is None:
169
+ return "uncategorized"
170
+ val = row.get("CATH_ID", "").strip()
171
+ return val if val else "uncategorized"
172
+
173
+
174
+ def get_cath_superfamily(pdb_id: str, auth_asym_id: str) -> Optional[str]:
175
+ """Return CATH superfamily code (e.g. '3.40.190.10') or None if not found."""
176
+ row = get_node_row(pdb_id, auth_asym_id)
177
+ if row is None:
178
+ return None
179
+ val = row.get("cath_superfamily", "").strip()
180
+ return val if val else None
181
+
182
+
183
+ def get_sequence(pdb_id: str, auth_asym_id: str) -> Tuple[Optional[str], Optional[int]]:
184
+ """Return (seq_can, sequence_length) from node table, or (None, None) if not found."""
185
+ row = get_node_row(pdb_id, auth_asym_id)
186
+ if row is None:
187
+ return None, None
188
+ seq = row["sequence"] or None
189
+ try:
190
+ length = int(row["sequence_length"]) if row["sequence_length"].strip() else None
191
+ except ValueError:
192
+ length = None
193
+ return seq, length
194
+
195
+
196
+ def get_functions(pdb_id: str, auth_asym_id: str) -> Optional[List[str]]:
197
+ """Return ranked function list for a chain, or None if not in node table."""
198
+ row = get_node_row(pdb_id, auth_asym_id)
199
+ if row is None:
200
+ return None
201
+ return _parse_functions(row["ranked_functions"])
202
+
203
+
204
+ def get_state_id(pdb_id: str, auth_asym_id: str) -> Optional[str]:
205
+ """Return state_id for a chain, or None if not found."""
206
+ row = get_node_row(pdb_id, auth_asym_id)
207
+ if row is None:
208
+ return None
209
+ val = row.get("state_id", "").strip()
210
+ return val if val else None
backend/assets/MuSProt_documentation.md CHANGED
@@ -1,150 +1,152 @@
1
- # MuSProt Dataset Documentation
2
-
3
- **MuSProt** (Multistate Protein Database) is a million-scale multimodal database for multistate proteins, designed to support programmable protein design and AI model development. It links experimentally observed conformational states of identical protein sequences from the PDB, organizes them into state clusters and transition relationships, and enriches each record with structural similarity, experimental context, state-specific function rankings and transition fidelity labels. Users can search, browse and download MuSProt records to study conformational diversity, state-dependent functions and feasible protein state transitions.
4
-
5
- ---
6
-
7
- ## Download
8
-
9
- The full dataset is distributed as a single SQLite database file (`MuSProt.db`, ~6.3 GB). Download it from the dataset page using the **Download DB** button.
10
-
11
- For detailed protein structures and atom coordinates, users are expected to download [Protein Data Bank (PDB)](https://rcsb.org) and fetch from `mmCIF` or `.pdb` files based on the PDB entry and chain ID.
12
-
13
- ---
14
-
15
- ## Database Schema
16
-
17
- The database contains two tables: **`node`** and **`edge`**.
18
-
19
- ---
20
-
21
- ### Table: `node`
22
-
23
- Each row represents a single protein chain instance (one PDB entry + chain).
24
-
25
- | Column | Type | Description |
26
- |---|---|---|
27
- | `uniprot_id` | TEXT | UniProt accession number |
28
- | `pdb_id` | TEXT | 4-character PDB entry ID (lowercase) |
29
- | `auth_asym_id` | TEXT | Author chain identifier (e.g. `A`) |
30
- | `base_label` | TEXT | Canonical label combining UniProt ID and chain state |
31
- | `sequence` | TEXT | SEQRES amino acid sequence |
32
- | `sequence_length` | INT | Number of residues in the chain |
33
- | `original_metals` | TEXT | Metal ions present in the structure |
34
- | `original_ligands` | TEXT | Small-molecule ligands present in the structure |
35
- | `sequence_id` | TEXT | Internal sequence cluster identifier |
36
- | `state_id` | TEXT | Conformational state cluster this chain is assigned to (`0`, `1`, `2`, …) |
37
- | `CATH_ID` | TEXT | CATH domain assignment |
38
- | `cath_class` | TEXT | CATH class (e.g. `1` = Mainly Alpha) |
39
- | `cath_arch` | TEXT | CATH architecture |
40
- | `cath_topo` | TEXT | CATH topology |
41
- | `cath_homology` | TEXT | CATH homology superfamily |
42
- | `cath_superfamily` | TEXT | Full CATH superfamily code (e.g. `1.10.10.10`) |
43
- | `domain_length` | INT | Length of the matched CATH domain |
44
- | `experimental_method` | TEXT | Structure determination method (e.g. `X-RAY DIFFRACTION`, `ELECTRON MICROSCOPY`, `SOLUTION NMR`) |
45
- | `pH` | FLOAT | pH of the experimental / crystallization condition |
46
- | `temp_K` | FLOAT | Temperature of the experiment, in Kelvin |
47
- | `experimental_details` | TEXT | Free-text crystallization / sample-preparation details |
48
- | `resolution` | FLOAT | Experimental resolution in Å (lower is sharper; empty for methods without a resolution) |
49
- | `Rosetta` | FLOAT | Rosetta total energy score |
50
- | `FoldX` | FLOAT | FoldX total energy score |
51
- | `EvoEF2` | FLOAT | EvoEF2 total energy score |
52
- | `RW` | FLOAT | Random-Walk (RW) energy score |
53
- | `RW+` | FLOAT | Random-Walk+ (RWplus) energy score |
54
- | `ranked_functions` | TEXT | JSON-encoded list of ranked GO/functional annotations |
55
-
56
-
57
- ---
58
-
59
- ### Table: `edge`
60
-
61
- Each row represents a pairwise structural comparison between two chain instances (A and B) that share the same UniProt identity.
62
-
63
- | Column | Type | Description |
64
- |---|---|---|
65
- | `pdb_id_A` | TEXT | PDB ID of chain A |
66
- | `auth_asym_id_A` | TEXT | Chain identifier of chain A |
67
- | `pdb_id_B` | TEXT | PDB ID of chain B |
68
- | `auth_asym_id_B` | TEXT | Chain identifier of chain B |
69
- | `TM1` | FLOAT | TM-score of the alignment (chain A as reference) |
70
- | `RMSD` | FLOAT | Root-mean-square deviation of atoms (Å) |
71
- | `structure_sim` | FLOAT | Composite structural similarity score |
72
- | `delta_Rosetta` | FLOAT | Rosetta energy difference (B A) |
73
- | `delta_FoldX` | FLOAT | FoldX energy difference (B − A) |
74
- | `delta_EvoEF2` | FLOAT | EvoEF2 energy difference (B − A) |
75
- | `delta_RW` | FLOAT | Random-Walk (RW) energy difference (B − A) |
76
- | `delta_RW+` | FLOAT | Random-Walk+ (RWplus) energy difference (B − A) |
77
- | `state_id_A` | TEXT | Conformational state cluster of chain A |
78
- | `state_id_B` | TEXT | Conformational state cluster of chain B |
79
- | `avg_sim` | TEXT | Average structural similarity within the state cluster (`>0.95` or a numeric value) |
80
- | `state_fidelity` | TEXT | State-level transition fidelity label: `identical`, `high`, `medium`, or `low` |
81
- | `observation_fidelity` | TEXT | Observation-level transition fidelity label: `identical`, `high`, `medium`, or `low` |
82
-
83
- ---
84
-
85
- ## Usage Examples
86
-
87
- ### Python (sqlite3)
88
-
89
- ```python
90
- import sqlite3
91
- import pandas as pd
92
-
93
- conn = sqlite3.connect("MuSProt.db")
94
-
95
- # Load all chains for a UniProt entry
96
- df_nodes = pd.read_sql(
97
- "SELECT * FROM node WHERE uniprot_id = 'P00533'",
98
- conn
99
- )
100
-
101
- # Find all structural neighbours of a given chain
102
- df_edges = pd.read_sql(
103
- "SELECT * FROM edge WHERE pdb_id_A = '1ivo' AND auth_asym_id_A = 'A'",
104
- conn
105
- )
106
-
107
- conn.close()
108
- ```
109
-
110
- ### Filter by structural similarity
111
-
112
- ```python
113
- # Retrieve pairs with high TM-score and low RMSD
114
- df = pd.read_sql("""
115
- SELECT *
116
- FROM edge
117
- WHERE CAST(TM1 AS REAL) > 0.8
118
- AND CAST(RMSD AS REAL) < 2.0
119
- LIMIT 1000
120
- """, conn)
121
- ```
122
-
123
- ### Join nodes and edges
124
-
125
- ```python
126
- # Get full info for both chains in each pair
127
- df = pd.read_sql("""
128
- SELECT
129
- e.pdb_id_A, e.auth_asym_id_A,
130
- e.pdb_id_B, e.auth_asym_id_B,
131
- e.TM1, e.RMSD,
132
- nA.sequence_length AS len_A,
133
- nB.sequence_length AS len_B,
134
- nA.cath_superfamily
135
- FROM edge e
136
- JOIN node nA ON e.pdb_id_A = nA.pdb_id AND e.auth_asym_id_A = nA.auth_asym_id
137
- JOIN node nB ON e.pdb_id_B = nB.pdb_id AND e.auth_asym_id_B = nB.auth_asym_id
138
- WHERE nA.uniprot_id = 'P00533'
139
- LIMIT 500
140
- """, conn)
141
- ```
142
-
143
- ---
144
-
145
- ## Notes
146
-
147
- - All numeric fields (energies, TM-score, RMSD, lengths) are stored as `TEXT`; cast them with `CAST(col AS REAL)` or `CAST(col AS INTEGER)` as needed.
148
- - `ranked_functions` in the `node` table is a JSON string. Parse it with `json.loads()`.
149
- - The `edge` table is directional: (A→B) and (B→A) are separate rows and may differ slightly in TM-score.
150
- - Energy delta values represent B A; a negative delta means chain B is lower energy than chain A.
 
 
 
1
+ # MuSProt Dataset Documentation
2
+
3
+ **MuSProt** (Multistate Protein Database) is a million-scale multimodal database for multistate proteins, designed to support programmable protein design and AI model development. It links experimentally observed conformational states of identical protein sequences from the PDB, organizes them into state clusters and transition relationships, and enriches each record with structural similarity, experimental context, state-specific function rankings and transition fidelity labels. Users can search, browse and download MuSProt records to study conformational diversity, state-dependent functions and feasible protein state transitions.
4
+
5
+ ---
6
+
7
+ ## Download
8
+
9
+ The full dataset is distributed as a single SQLite database file (`MuSProt.db`, ~6.3 GB). Download it from the dataset page using the **Download DB** button.
10
+
11
+ For detailed protein structures and atom coordinates, users are expected to download [Protein Data Bank (PDB)](https://rcsb.org) and fetch from `mmCIF` or `.pdb` files based on the PDB entry and chain ID.
12
+
13
+ ---
14
+
15
+ ## Database Schema
16
+
17
+ The database contains two tables: **`node`** and **`edge`**.
18
+
19
+ ---
20
+
21
+ ### Table: `node`
22
+
23
+ Each row represents a single protein chain instance (one PDB entry + chain).
24
+
25
+ | Column | Type | Description |
26
+ |---|---|---|
27
+ | `uniprot_id` | TEXT | UniProt accession number |
28
+ | `pdb_id` | TEXT | 4-character PDB entry ID (lowercase) |
29
+ | `auth_asym_id` | TEXT | Author chain identifier (e.g. `A`) |
30
+ | `base_label` | TEXT | Canonical label combining UniProt ID and chain state |
31
+ | `sequence` | TEXT | SEQRES amino acid sequence |
32
+ | `sequence_length` | INT | Number of residues in the chain |
33
+ | `original_metals` | TEXT | Metal ions present in the structure |
34
+ | `original_ligands` | TEXT | Small-molecule ligands present in the structure |
35
+ | `sequence_id` | TEXT | Internal sequence cluster identifier |
36
+ | `state_id` | TEXT | Conformational state cluster this chain is assigned to (`0`, `1`, `2`, …) |
37
+ | `CATH_ID` | TEXT | CATH domain assignment |
38
+ | `cath_class` | TEXT | CATH class (e.g. `1` = Mainly Alpha) |
39
+ | `cath_arch` | TEXT | CATH architecture |
40
+ | `cath_topo` | TEXT | CATH topology |
41
+ | `cath_homology` | TEXT | CATH homology superfamily |
42
+ | `cath_superfamily` | TEXT | Full CATH superfamily code (e.g. `1.10.10.10`) |
43
+ | `domain_length` | INT | Length of the matched CATH domain |
44
+ | `experimental_method` | TEXT | Structure determination method (e.g. `X-RAY DIFFRACTION`, `ELECTRON MICROSCOPY`, `SOLUTION NMR`) |
45
+ | `pH` | FLOAT | pH of the experimental / crystallization condition |
46
+ | `temp_K` | FLOAT | Temperature of the experiment, in Kelvin |
47
+ | `experimental_details` | TEXT | Free-text crystallization / sample-preparation details |
48
+ | `resolution` | FLOAT | Experimental resolution in Å (lower is sharper; empty for methods without a resolution) |
49
+ | `Rosetta` | FLOAT | Rosetta total energy score |
50
+ | `FoldX` | FLOAT | FoldX total energy score |
51
+ | `EvoEF2` | FLOAT | EvoEF2 total energy score |
52
+ | `RW` | FLOAT | Random-Walk (RW) energy score |
53
+ | `RW+` | FLOAT | Random-Walk+ (RWplus) energy score |
54
+ | `ranked_functions` | TEXT | JSON-encoded list of ranked GO/functional annotations |
55
+ | `chain_composition` | TEXT | Quaternary composition of the deposited assembly: `monomeric`, `homomeric`, or `heteromeric` |
56
+ | `non_protein_polymer_binding` | TEXT | Non-protein polymer bound in the structure (`DNA`, `RNA`, or `DNA/RNA`); empty when none is present |
57
+
58
+
59
+ ---
60
+
61
+ ### Table: `edge`
62
+
63
+ Each row represents a pairwise structural comparison between two chain instances (A and B) that share the same UniProt identity.
64
+
65
+ | Column | Type | Description |
66
+ |---|---|---|
67
+ | `pdb_id_A` | TEXT | PDB ID of chain A |
68
+ | `auth_asym_id_A` | TEXT | Chain identifier of chain A |
69
+ | `pdb_id_B` | TEXT | PDB ID of chain B |
70
+ | `auth_asym_id_B` | TEXT | Chain identifier of chain B |
71
+ | `TM1` | FLOAT | TM-score of the alignment (chain A as reference) |
72
+ | `RMSD` | FLOAT | Root-mean-square deviation of atoms ) |
73
+ | `structure_sim` | FLOAT | Composite structural similarity score |
74
+ | `delta_Rosetta` | FLOAT | Rosetta energy difference (B − A) |
75
+ | `delta_FoldX` | FLOAT | FoldX energy difference (B − A) |
76
+ | `delta_EvoEF2` | FLOAT | EvoEF2 energy difference (B − A) |
77
+ | `delta_RW` | FLOAT | Random-Walk (RW) energy difference (B A) |
78
+ | `delta_RW+` | FLOAT | Random-Walk+ (RWplus) energy difference (B − A) |
79
+ | `state_id_A` | TEXT | Conformational state cluster of chain A |
80
+ | `state_id_B` | TEXT | Conformational state cluster of chain B |
81
+ | `avg_sim` | TEXT | Average structural similarity within the state cluster (`>0.95` or a numeric value) |
82
+ | `state_fidelity` | TEXT | State-level transition fidelity label: `identical`, `high`, `medium`, or `low` |
83
+ | `observation_fidelity` | TEXT | Observation-level transition fidelity label: `identical`, `high`, `medium`, or `low` |
84
+
85
+ ---
86
+
87
+ ## Usage Examples
88
+
89
+ ### Python (sqlite3)
90
+
91
+ ```python
92
+ import sqlite3
93
+ import pandas as pd
94
+
95
+ conn = sqlite3.connect("MuSProt.db")
96
+
97
+ # Load all chains for a UniProt entry
98
+ df_nodes = pd.read_sql(
99
+ "SELECT * FROM node WHERE uniprot_id = 'P00533'",
100
+ conn
101
+ )
102
+
103
+ # Find all structural neighbours of a given chain
104
+ df_edges = pd.read_sql(
105
+ "SELECT * FROM edge WHERE pdb_id_A = '1ivo' AND auth_asym_id_A = 'A'",
106
+ conn
107
+ )
108
+
109
+ conn.close()
110
+ ```
111
+
112
+ ### Filter by structural similarity
113
+
114
+ ```python
115
+ # Retrieve pairs with high TM-score and low RMSD
116
+ df = pd.read_sql("""
117
+ SELECT *
118
+ FROM edge
119
+ WHERE CAST(TM1 AS REAL) > 0.8
120
+ AND CAST(RMSD AS REAL) < 2.0
121
+ LIMIT 1000
122
+ """, conn)
123
+ ```
124
+
125
+ ### Join nodes and edges
126
+
127
+ ```python
128
+ # Get full info for both chains in each pair
129
+ df = pd.read_sql("""
130
+ SELECT
131
+ e.pdb_id_A, e.auth_asym_id_A,
132
+ e.pdb_id_B, e.auth_asym_id_B,
133
+ e.TM1, e.RMSD,
134
+ nA.sequence_length AS len_A,
135
+ nB.sequence_length AS len_B,
136
+ nA.cath_superfamily
137
+ FROM edge e
138
+ JOIN node nA ON e.pdb_id_A = nA.pdb_id AND e.auth_asym_id_A = nA.auth_asym_id
139
+ JOIN node nB ON e.pdb_id_B = nB.pdb_id AND e.auth_asym_id_B = nB.auth_asym_id
140
+ WHERE nA.uniprot_id = 'P00533'
141
+ LIMIT 500
142
+ """, conn)
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Notes
148
+
149
+ - All numeric fields (energies, TM-score, RMSD, lengths) are stored as `TEXT`; cast them with `CAST(col AS REAL)` or `CAST(col AS INTEGER)` as needed.
150
+ - `ranked_functions` in the `node` table is a JSON string. Parse it with `json.loads()`.
151
+ - The `edge` table is directional: (A→B) and (B→A) are separate rows and may differ slightly in TM-score.
152
+ - Energy delta values represent B − A; a negative delta means chain B is lower energy than chain A.
backend/scripts/generate_node_lookup.py CHANGED
@@ -1,66 +1,68 @@
1
- """Generate an indexed read-only node lookup sidecar from MuSProt.db."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import sqlite3
6
- import zlib
7
- from pathlib import Path
8
-
9
-
10
- NODE_COLUMNS = (
11
- "pdb_id", "auth_asym_id", "base_label", "sequence", "sequence_length",
12
- "CATH_ID", "cath_superfamily", "Rosetta", "FoldX", "EvoEF2", "RW", "RW+",
13
- "ranked_functions", "state_id", "experimental_method", "pH", "temp_K",
14
- )
15
-
16
-
17
- def main() -> None:
18
- parser = argparse.ArgumentParser()
19
- parser.add_argument("source", type=Path)
20
- parser.add_argument("-o", "--output", type=Path, default=Path("MuSProt-node-lookup.db"))
21
- args = parser.parse_args()
22
-
23
- output = args.output.resolve()
24
- output.unlink(missing_ok=True)
25
-
26
- conn = sqlite3.connect(output)
27
- try:
28
- conn.execute("PRAGMA journal_mode = OFF")
29
- conn.execute("PRAGMA synchronous = OFF")
30
- conn.execute(
31
- 'CREATE TABLE node (pdb_id, auth_asym_id, base_label, sequence BLOB,'
32
- ' sequence_length, CATH_ID, cath_superfamily, Rosetta, FoldX, EvoEF2,'
33
- ' RW, "RW+", ranked_functions BLOB, state_id, experimental_method, pH, temp_K)'
34
- )
35
- source = sqlite3.connect(f"file:{args.source.resolve()}?mode=ro", uri=True)
36
- try:
37
- select_columns = ", ".join(f'"{column}"' for column in NODE_COLUMNS)
38
- rows = source.execute(f"SELECT {select_columns} FROM node")
39
- placeholders = ", ".join("?" for _ in NODE_COLUMNS)
40
- batch = []
41
- for row in rows:
42
- row = list(row)
43
- for index in (3, 12):
44
- row[index] = zlib.compress((row[index] or "").encode("utf-8"), 1)
45
- batch.append(row)
46
- if len(batch) == 1000:
47
- conn.executemany(f"INSERT INTO node VALUES ({placeholders})", batch)
48
- batch.clear()
49
- if batch:
50
- conn.executemany(f"INSERT INTO node VALUES ({placeholders})", batch)
51
- finally:
52
- source.close()
53
- conn.execute(
54
- "CREATE INDEX idx_node_chain "
55
- "ON node(LOWER(pdb_id), UPPER(auth_asym_id))"
56
- )
57
- conn.execute("ANALYZE")
58
- conn.commit()
59
- finally:
60
- conn.close()
61
-
62
- print(f"Created {output} ({output.stat().st_size:,} bytes)")
63
-
64
-
65
- if __name__ == "__main__":
66
- main()
 
 
 
1
+ """Generate an indexed read-only node lookup sidecar from MuSProt.db."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sqlite3
6
+ import zlib
7
+ from pathlib import Path
8
+
9
+
10
+ NODE_COLUMNS = (
11
+ "pdb_id", "auth_asym_id", "base_label", "sequence", "sequence_length",
12
+ "CATH_ID", "cath_superfamily", "Rosetta", "FoldX", "EvoEF2", "RW", "RW+",
13
+ "ranked_functions", "state_id", "experimental_method", "pH", "temp_K",
14
+ "chain_composition",
15
+ )
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser()
20
+ parser.add_argument("source", type=Path)
21
+ parser.add_argument("-o", "--output", type=Path, default=Path("MuSProt-node-lookup.db"))
22
+ args = parser.parse_args()
23
+
24
+ output = args.output.resolve()
25
+ output.unlink(missing_ok=True)
26
+
27
+ conn = sqlite3.connect(output)
28
+ try:
29
+ conn.execute("PRAGMA journal_mode = OFF")
30
+ conn.execute("PRAGMA synchronous = OFF")
31
+ conn.execute(
32
+ 'CREATE TABLE node (pdb_id, auth_asym_id, base_label, sequence BLOB,'
33
+ ' sequence_length, CATH_ID, cath_superfamily, Rosetta, FoldX, EvoEF2,'
34
+ ' RW, "RW+", ranked_functions BLOB, state_id, experimental_method, pH, temp_K,'
35
+ ' chain_composition)'
36
+ )
37
+ source = sqlite3.connect(f"file:{args.source.resolve()}?mode=ro", uri=True)
38
+ try:
39
+ select_columns = ", ".join(f'"{column}"' for column in NODE_COLUMNS)
40
+ rows = source.execute(f"SELECT {select_columns} FROM node")
41
+ placeholders = ", ".join("?" for _ in NODE_COLUMNS)
42
+ batch = []
43
+ for row in rows:
44
+ row = list(row)
45
+ for index in (3, 12):
46
+ row[index] = zlib.compress((row[index] or "").encode("utf-8"), 1)
47
+ batch.append(row)
48
+ if len(batch) == 1000:
49
+ conn.executemany(f"INSERT INTO node VALUES ({placeholders})", batch)
50
+ batch.clear()
51
+ if batch:
52
+ conn.executemany(f"INSERT INTO node VALUES ({placeholders})", batch)
53
+ finally:
54
+ source.close()
55
+ conn.execute(
56
+ "CREATE INDEX idx_node_chain "
57
+ "ON node(LOWER(pdb_id), UPPER(auth_asym_id))"
58
+ )
59
+ conn.execute("ANALYZE")
60
+ conn.commit()
61
+ finally:
62
+ conn.close()
63
+
64
+ print(f"Created {output} ({output.stat().st_size:,} bytes)")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
frontend/src/components/datasets/protein/ChainBasicInfoCards.tsx CHANGED
@@ -51,7 +51,7 @@ const ChainBasicInfoCards: React.FC<ChainBasicInfoCardsProps> = ({ metadata, isL
51
  const chainId = metadata?.chain_id || metadata?.polymer_entity_instance?.auth_asym_id;
52
 
53
  return (
54
- <div className="h-full w-full border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
55
  <div className="flex items-start justify-between mb-4">
56
  <h3 className="text-xl font-semibold text-slate-900">Chain Metadata</h3>
57
  <div className="flex flex-wrap gap-2">
@@ -68,19 +68,19 @@ const ChainBasicInfoCards: React.FC<ChainBasicInfoCardsProps> = ({ metadata, isL
68
 
69
  {/* Chips Row */}
70
 
71
- {/* Compact three-column metadata flow */}
72
- <div className="gap-4" style={{ columnCount: 3, columnGap: '1rem' }}>
73
- <div className="break-inside-avoid mb-3">
74
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Protein Name</div>
75
- <div className="text-sm font-semibold text-slate-900 font-mono" title={metadata?.entry?.title || ''}>
76
- {metadata?.entry?.title || '—'}
77
- </div>
78
- </div>
79
-
80
- <div className="break-inside-avoid mb-3">
81
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">PDB ID</div>
82
- <div className="text-sm font-semibold text-slate-900">
83
- <a
84
  href={`https://www.rcsb.org/structure/${pdbId}`}
85
  target="_blank"
86
  rel="noopener noreferrer"
@@ -89,33 +89,38 @@ const ChainBasicInfoCards: React.FC<ChainBasicInfoCardsProps> = ({ metadata, isL
89
  {pdbId || '—'}
90
  <span className="text-xs">↗</span>
91
  </a>
92
- </div>
93
- </div>
94
-
95
- <div className="break-inside-avoid mb-3">
96
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Chain ID</div>
97
- <div className="text-sm font-semibold text-slate-900 font-mono">{chainId || '—'}</div>
98
- </div>
99
-
100
- <div className="break-inside-avoid mb-3">
101
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">UniProt ID</div>
102
- <div className="text-sm font-semibold text-slate-900 font-mono">{getUniProtId()}</div>
103
- </div>
104
-
105
- <div className="break-inside-avoid mb-3">
106
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Binding Status</div>
107
- <div className="text-sm font-semibold text-slate-900 capitalize">{metadata?.binding_status || '—'}</div>
108
- </div>
109
-
110
- <div className="break-inside-avoid mb-3">
111
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH ID</div>
112
- <div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.cath_id || 'uncategorized'}</div>
113
- </div>
114
-
115
- <div className="break-inside-avoid mb-3">
116
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH Domain</div>
117
- <div className="text-sm font-semibold text-slate-900 font-mono">
118
- {metadata?.cath_superfamily ? (
 
 
 
 
 
119
  <a
120
  href={`https://www.cathdb.info/version/latest/superfamily/${metadata.cath_superfamily}`}
121
  target="_blank"
@@ -126,13 +131,13 @@ const ChainBasicInfoCards: React.FC<ChainBasicInfoCardsProps> = ({ metadata, isL
126
  <span className="text-xs">↗</span>
127
  </a>
128
  ) : '—'}
129
- </div>
130
- </div>
131
-
132
- <div className="break-inside-avoid mb-3">
133
- <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">State ID</div>
134
- <div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.state_id || '—'}</div>
135
- </div>
136
  </div>
137
  </div>
138
  );
 
51
  const chainId = metadata?.chain_id || metadata?.polymer_entity_instance?.auth_asym_id;
52
 
53
  return (
54
+ <div className="h-full w-full border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
55
  <div className="flex items-start justify-between mb-4">
56
  <h3 className="text-xl font-semibold text-slate-900">Chain Metadata</h3>
57
  <div className="flex flex-wrap gap-2">
 
68
 
69
  {/* Chips Row */}
70
 
71
+ {/* Compact three-column metadata flow */}
72
+ <div className="gap-4" style={{ columnCount: 3, columnGap: '1rem' }}>
73
+ <div className="break-inside-avoid mb-3">
74
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Protein Name</div>
75
+ <div className="text-sm font-semibold text-slate-900 font-mono" title={metadata?.entry?.title || ''}>
76
+ {metadata?.entry?.title || '—'}
77
+ </div>
78
+ </div>
79
+
80
+ <div className="break-inside-avoid mb-3">
81
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">PDB ID</div>
82
+ <div className="text-sm font-semibold text-slate-900">
83
+ <a
84
  href={`https://www.rcsb.org/structure/${pdbId}`}
85
  target="_blank"
86
  rel="noopener noreferrer"
 
89
  {pdbId || '—'}
90
  <span className="text-xs">↗</span>
91
  </a>
92
+ </div>
93
+ </div>
94
+
95
+ <div className="break-inside-avoid mb-3">
96
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Chain ID</div>
97
+ <div className="text-sm font-semibold text-slate-900 font-mono">{chainId || '—'}</div>
98
+ </div>
99
+
100
+ <div className="break-inside-avoid mb-3">
101
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">UniProt ID</div>
102
+ <div className="text-sm font-semibold text-slate-900 font-mono">{getUniProtId()}</div>
103
+ </div>
104
+
105
+ <div className="break-inside-avoid mb-3">
106
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Binding Status</div>
107
+ <div className="text-sm font-semibold text-slate-900 capitalize">{metadata?.binding_status || '—'}</div>
108
+ </div>
109
+
110
+ <div className="break-inside-avoid mb-3">
111
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Chain Composition</div>
112
+ <div className="text-sm font-semibold text-slate-900 capitalize">{metadata?.chain_composition || ''}</div>
113
+ </div>
114
+
115
+ <div className="break-inside-avoid mb-3">
116
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH ID</div>
117
+ <div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.cath_id || 'uncategorized'}</div>
118
+ </div>
119
+
120
+ <div className="break-inside-avoid mb-3">
121
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH Domain</div>
122
+ <div className="text-sm font-semibold text-slate-900 font-mono">
123
+ {metadata?.cath_superfamily ? (
124
  <a
125
  href={`https://www.cathdb.info/version/latest/superfamily/${metadata.cath_superfamily}`}
126
  target="_blank"
 
131
  <span className="text-xs">↗</span>
132
  </a>
133
  ) : '—'}
134
+ </div>
135
+ </div>
136
+
137
+ <div className="break-inside-avoid mb-3">
138
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">State ID</div>
139
+ <div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.state_id || '—'}</div>
140
+ </div>
141
  </div>
142
  </div>
143
  );
frontend/src/components/datasets/protein/DataTable.tsx CHANGED
@@ -102,6 +102,12 @@ export const DataTable: React.FC<DataTableProps> = ({ data, loading, total, filt
102
  <th>
103
  pH
104
  </th>
 
 
 
 
 
 
105
  <th onClick={() => handleSort('delta_rosetta')}>
106
  ΔRosetta {getSortIcon('delta_rosetta')}
107
  </th>
@@ -154,6 +160,12 @@ export const DataTable: React.FC<DataTableProps> = ({ data, loading, total, filt
154
  <td className="numeric">
155
  {formatPH(record.pH)}
156
  </td>
 
 
 
 
 
 
157
  {(['delta_rosetta', 'delta_foldx', 'delta_evoef2', 'delta_rw', 'delta_rw_plus'] as const).map(field => {
158
  const val = record[field];
159
  return (
 
102
  <th>
103
  pH
104
  </th>
105
+ <th onClick={() => handleSort('binding_status')}>
106
+ Binding status {getSortIcon('binding_status')}
107
+ </th>
108
+ <th onClick={() => handleSort('chain_composition')}>
109
+ Chain composition {getSortIcon('chain_composition')}
110
+ </th>
111
  <th onClick={() => handleSort('delta_rosetta')}>
112
  ΔRosetta {getSortIcon('delta_rosetta')}
113
  </th>
 
160
  <td className="numeric">
161
  {formatPH(record.pH)}
162
  </td>
163
+ <td className="capitalize">
164
+ {record.binding_status || <span className="placeholder">—</span>}
165
+ </td>
166
+ <td className="capitalize">
167
+ {record.chain_composition || <span className="placeholder">—</span>}
168
+ </td>
169
  {(['delta_rosetta', 'delta_foldx', 'delta_evoef2', 'delta_rw', 'delta_rw_plus'] as const).map(field => {
170
  const val = record[field];
171
  return (
frontend/src/components/datasets/protein/StaticDistributionPlots.tsx CHANGED
@@ -1,34 +1,34 @@
1
- import React from 'react';
2
-
3
- const IMG_BASE = '/api/protein/plots';
4
- const CACHE_BUST = '?v=20260622c';
5
-
6
- const plots = [
7
- { name: 'Structure Similarity', file: 'Structure-similarity.png' },
8
- { name: 'Function Rank Correlation vs. Structure Similarity', file: 'Function-rank-correlation.png' },
9
- { name: 'Fidelity Distribution', file: 'Fidelity-distribution.png' },
10
- { name: 'Unique State Cluster Distribution', file: 'Unique-state-cluster-distribution.png' },
11
- ];
12
-
13
- export const StaticDistributionPlots: React.FC = () => {
14
- return (
15
- <div style={{ columns: '400px', columnGap: '1.5rem' }}>
16
- {plots.map(({ name, file }) => (
17
- <div
18
- key={file}
19
- style={{ breakInside: 'avoid', marginBottom: '1.5rem' }}
20
- className="border border-gray-200 rounded-2xl bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300 overflow-hidden"
21
- >
22
- <div className="px-4 pt-5 pb-2 text-xl font-semibold text-slate-900">{name}</div>
23
- <div className="px-4 pb-4">
24
- <img
25
- src={`${IMG_BASE}/${file}${CACHE_BUST}`}
26
- alt={name}
27
- style={{ width: '100%', height: 'auto', display: 'block' }}
28
- />
29
- </div>
30
- </div>
31
- ))}
32
- </div>
33
- );
34
- };
 
1
+ import React from 'react';
2
+
3
+ const IMG_BASE = '/api/protein/plots';
4
+ const CACHE_BUST = '?v=20260625';
5
+
6
+ const plots = [
7
+ { name: 'Structure Similarity', file: 'Structure-similarity.png' },
8
+ { name: 'Function Rank Correlation vs. Structure Similarity', file: 'Function-rank-correlation.png' },
9
+ { name: 'Fidelity Distribution', file: 'Fidelity-distribution.png' },
10
+ { name: 'Unique State Cluster Distribution', file: 'Unique-state-cluster-distribution.png' },
11
+ ];
12
+
13
+ export const StaticDistributionPlots: React.FC = () => {
14
+ return (
15
+ <div style={{ columns: '400px', columnGap: '1.5rem' }}>
16
+ {plots.map(({ name, file }) => (
17
+ <div
18
+ key={file}
19
+ style={{ breakInside: 'avoid', marginBottom: '1.5rem' }}
20
+ className="border border-gray-200 rounded-2xl bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300 overflow-hidden"
21
+ >
22
+ <div className="px-4 pt-5 pb-2 text-xl font-semibold text-slate-900">{name}</div>
23
+ <div className="px-4 pb-4">
24
+ <img
25
+ src={`${IMG_BASE}/${file}${CACHE_BUST}`}
26
+ alt={name}
27
+ style={{ width: '100%', height: 'auto', display: 'block' }}
28
+ />
29
+ </div>
30
+ </div>
31
+ ))}
32
+ </div>
33
+ );
34
+ };
frontend/src/types/protein.ts CHANGED
@@ -63,6 +63,7 @@ export interface ChainMetadata {
63
  polymer_entity_instance?: PolymerEntityInstance | null;
64
  nonpolymer_entities?: NonpolymerEntity[] | null;
65
  binding_status?: string | null;
 
66
  cath_id?: string | null;
67
  cath_superfamily?: string | null;
68
  state_id?: string | null;
@@ -85,6 +86,9 @@ export interface DataRecord {
85
  exptl_method?: string | null;
86
  temp?: number | string | null;
87
  pH?: number | string | null;
 
 
 
88
  // Energy score deltas (searched chain - matched chain)
89
  delta_rosetta?: number | null;
90
  delta_foldx?: number | null;
 
63
  polymer_entity_instance?: PolymerEntityInstance | null;
64
  nonpolymer_entities?: NonpolymerEntity[] | null;
65
  binding_status?: string | null;
66
+ chain_composition?: string | null;
67
  cath_id?: string | null;
68
  cath_superfamily?: string | null;
69
  state_id?: string | null;
 
86
  exptl_method?: string | null;
87
  temp?: number | string | null;
88
  pH?: number | string | null;
89
+ // Matched-chain (B) node annotations
90
+ binding_status?: string | null;
91
+ chain_composition?: string | null;
92
  // Energy score deltas (searched chain - matched chain)
93
  delta_rosetta?: number | null;
94
  delta_foldx?: number | null;