MuSProt / backend /app /protein /data_loader.py
WinslowFan's picture
Add chain_composition + binding status; refresh DB, docs, Fidelity plot
2d33606
Raw
History Blame Contribute Delete
12.9 kB
"""
SQLite-based data loader for protein structure data.
Reads from MuSProt.db: edge table (pairwise comparisons from CSV)
and node table (chain annotations from TSV).
"""
import json
import pandas as pd
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
import logging
from app.protein.config import get_database_path, get_summary_path
from app.protein.database import connect_readonly
logger = logging.getLogger(__name__)
def _assign_cluster(tm: float) -> str:
if tm >= 0.99:
return "cluster_ultra_high"
if tm >= 0.95:
return "cluster_very_high"
if tm >= 0.90:
return "cluster_high"
if tm >= 0.80:
return "cluster_medium"
return "cluster_low"
class DataManager:
"""Manages SQL queries against MuSProt.db edge and node tables."""
def __init__(self, db_path: Optional[Path] = None, **_kwargs):
self.db_path = db_path or get_database_path()
self._loaded = False
self._total_records = 0
self._summary: Dict[str, Any] = {}
def load_data(self) -> None:
"""Verify DB is accessible and load optional precomputed metadata."""
if self._loaded:
return
summary_path = get_summary_path()
if summary_path:
self._summary = json.loads(summary_path.read_text(encoding="utf-8"))
self._total_records = int(self._summary.get("total_records", 0))
conn = connect_readonly(self.db_path)
try:
cur = conn.cursor()
cur.execute("SELECT 1 FROM edge LIMIT 1")
if not self._total_records:
logger.warning(
"No musprot_summary.json found; counting edge rows during startup. "
"Publish the summary sidecar to avoid this scan."
)
cur.execute("SELECT COUNT(*) FROM edge")
self._total_records = cur.fetchone()[0]
finally:
conn.close()
self._loaded = True
logger.info(f"DataManager ready: {self._total_records:,} edge records in {self.db_path}")
def _connect(self):
if not self._loaded:
raise RuntimeError("Call load_data() first.")
return connect_readonly(self.db_path)
def get_chain_sample(self, limit: int = 500) -> Set[str]:
"""Return a small chain sample without loading the full node index."""
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
"SELECT pdb_id, auth_asym_id FROM node "
"WHERE pdb_id IS NOT NULL AND auth_asym_id IS NOT NULL LIMIT ?",
(limit,),
)
return {
f"{str(pdb).upper()}_{str(chain).upper()}"
for pdb, chain in cur.fetchall()
}
finally:
conn.close()
def get_filters_data(self) -> Dict[str, Any]:
"""Return aggregated ranges for filter UI."""
if self._summary:
return {
"tm_min": self._summary["tm_score_range"]["min"],
"tm_max": self._summary["tm_score_range"]["max"],
"rmsd_min": self._summary["rmsd_range"]["min"],
"rmsd_max": self._summary["rmsd_range"]["max"],
"length_min": self._summary["length_range"]["min"],
"length_max": self._summary["length_range"]["max"],
"total_records": self._total_records,
}
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
"SELECT MIN(CAST(TM1 AS REAL)), MAX(CAST(TM1 AS REAL)),"
" MIN(CAST(RMSD AS REAL)), MAX(CAST(RMSD AS REAL))"
" FROM edge"
)
tm_min, tm_max, rmsd_min, rmsd_max = cur.fetchone()
finally:
conn.close()
from app.protein import tsv_loader
lengths = []
for row in tsv_loader._get_index().values():
try:
lengths.append(int(row["sequence_length"]))
except (ValueError, TypeError):
pass
return {
"tm_min": tm_min or 0.0,
"tm_max": tm_max or 1.0,
"rmsd_min": rmsd_min or 0.0,
"rmsd_max": rmsd_max or 10.0,
"length_min": min(lengths) if lengths else 0,
"length_max": max(lengths) if lengths else 0,
"total_records": self._total_records,
}
def query_edge(
self,
pdb_id_a: Optional[str] = None,
auth_asym_id_a: Optional[str] = None,
chain_ids: Optional[List[str]] = None,
rmsd_min: Optional[float] = None,
rmsd_max: Optional[float] = None,
tm_min: Optional[float] = None,
tm_max: Optional[float] = None,
limit: int = 1000,
) -> pd.DataFrame:
"""Query edge table with filters. Returns DataFrame with cluster_id and lengths."""
where_clauses: List[str] = []
params: List[Any] = []
if pdb_id_a and auth_asym_id_a:
where_clauses.append("LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?)")
params.extend([pdb_id_a, auth_asym_id_a])
elif chain_ids:
sub: List[str] = []
for cid in chain_ids:
parts = cid.split("_", 1)
if len(parts) == 2:
sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
params.extend(parts)
if sub:
where_clauses.append("(" + " OR ".join(sub) + ")")
if rmsd_min is not None:
where_clauses.append("CAST(RMSD AS REAL) >= ?")
params.append(rmsd_min)
if rmsd_max is not None:
where_clauses.append("CAST(RMSD AS REAL) <= ?")
params.append(rmsd_max)
if tm_min is not None:
where_clauses.append("CAST(TM1 AS REAL) >= ?")
params.append(tm_min)
if tm_max is not None:
where_clauses.append("CAST(TM1 AS REAL) <= ?")
params.append(tm_max)
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
sql = f"""
SELECT e.pdb_id_A, e.auth_asym_id_A, e.pdb_id_B, e.auth_asym_id_B,
CAST(e.TM1 AS REAL) AS TM1,
CAST(e.RMSD AS REAL) AS RMSD,
CAST(e.structure_sim AS REAL) AS structure_sim,
e."delta_Rosetta", e."delta_FoldX", e."delta_EvoEF2",
e."delta_RW", e."delta_RW+",
e.state_id_B, e.state_fidelity, e.avg_sim, e.observation_fidelity
FROM edge e
{where_sql}
LIMIT ?
"""
params.append(limit)
conn = self._connect()
try:
df = pd.read_sql_query(sql, conn, params=params)
finally:
conn.close()
# Rename columns with special characters so itertuples works cleanly
df = df.rename(columns={"delta_RW+": "delta_RW_plus"})
df["cluster_id"] = df["TM1"].apply(
lambda t: _assign_cluster(t) if pd.notna(t) else "cluster_low"
)
from app.protein import tsv_loader
df["length_a"] = [
tsv_loader.get_sequence(r.pdb_id_A, r.auth_asym_id_A)[1]
for r in df.itertuples(index=False)
]
df["length_b"] = [
tsv_loader.get_sequence(r.pdb_id_B, r.auth_asym_id_B)[1]
for r in df.itertuples(index=False)
]
node_rows = [
tsv_loader.get_node_row(r.pdb_id_B, r.auth_asym_id_B)
for r in df.itertuples(index=False)
]
df["experimental_method"] = [row.get("experimental_method") if row else None for row in node_rows]
df["pH"] = [row.get("pH") if row else None for row in node_rows]
df["temp_K"] = [row.get("temp_K") if row else None for row in node_rows]
df["base_label"] = [row.get("base_label") if row else None for row in node_rows]
df["chain_composition"] = [row.get("chain_composition") if row else None for row in node_rows]
return df
def get_summary_stats(
self,
chain_ids: Optional[List[str]] = None,
rmsd_min: Optional[float] = None,
rmsd_max: Optional[float] = None,
tm_min: Optional[float] = None,
tm_max: Optional[float] = None,
) -> Dict[str, Any]:
"""Compute aggregate statistics via SQL, with optional filters."""
if not any([chain_ids, rmsd_min, rmsd_max, tm_min, tm_max]) and self._summary:
return self._summary
where_clauses: List[str] = []
params: List[Any] = []
if chain_ids:
sub: List[str] = []
for cid in chain_ids:
parts = cid.split("_", 1)
if len(parts) == 2:
sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
params.extend(parts)
if sub:
where_clauses.append("(" + " OR ".join(sub) + ")")
if rmsd_min is not None:
where_clauses.append("CAST(RMSD AS REAL) >= ?")
params.append(rmsd_min)
if rmsd_max is not None:
where_clauses.append("CAST(RMSD AS REAL) <= ?")
params.append(rmsd_max)
if tm_min is not None:
where_clauses.append("CAST(TM1 AS REAL) >= ?")
params.append(tm_min)
if tm_max is not None:
where_clauses.append("CAST(TM1 AS REAL) <= ?")
params.append(tm_max)
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
conn = self._connect()
try:
cur = conn.cursor()
cur.execute(
f"SELECT COUNT(*), AVG(CAST(RMSD AS REAL)), AVG(CAST(TM1 AS REAL))"
f" FROM edge {where_sql}",
params,
)
total, avg_rmsd, avg_tm = cur.fetchone()
cur.execute(
f"SELECT COUNT(DISTINCT LOWER(pdb_id_A) || '_' || LOWER(auth_asym_id_A))"
f" FROM edge {where_sql}",
params,
)
unique_chains = cur.fetchone()[0]
def _dist(col, bins, labels):
cases = " ".join(
f"WHEN CAST({col} AS REAL) >= {lo} AND CAST({col} AS REAL) < {hi} THEN '{lbl}'"
for (lo, hi), lbl in zip(zip(bins, bins[1:]), labels)
)
sql = (
f"SELECT CASE {cases} ELSE '{labels[-1]}' END AS bucket, COUNT(*)"
f" FROM edge {where_sql} GROUP BY bucket"
)
cur.execute(sql, params)
return {r[0]: r[1] for r in cur.fetchall()}
rmsd_dist = _dist(
"RMSD",
[0, 0.5, 1.0, 1.5, 2.0, 5.0, 1e9],
["0-0.5", "0.5-1.0", "1.0-1.5", "1.5-2.0", "2.0-5.0", ">5.0"],
)
tm_dist = _dist(
"TM1",
[0, 0.5, 0.7, 0.85, 0.95, 1.01],
["0-0.5", "0.5-0.7", "0.7-0.85", "0.85-0.95", "0.95-1.0"],
)
finally:
conn.close()
from app.protein import tsv_loader
lengths = []
for row in tsv_loader._get_index().values():
try:
lengths.append(int(row["sequence_length"]))
except (ValueError, TypeError):
pass
avg_len = sum(lengths) / len(lengths) if lengths else 0.0
len_dist = {}
for length in lengths:
if length <= 100:
bucket = "0-100"
elif length <= 200:
bucket = "100-200"
elif length <= 300:
bucket = "200-300"
elif length <= 500:
bucket = "300-500"
elif length <= 1000:
bucket = "500-1000"
else:
bucket = ">1000"
len_dist[bucket] = len_dist.get(bucket, 0) + 1
return {
"total_records": total or 0,
"unique_chains": unique_chains or 0,
"avg_rmsd": avg_rmsd or 0.0,
"avg_tm_score": avg_tm or 0.0,
"avg_sequence_length": avg_len,
"rmsd_distribution": rmsd_dist,
"tm_score_distribution": tm_dist,
"length_distribution": len_dist,
}
def get_total(self) -> int:
return self._total_records