Files
Olamide Isreal 9e75f44f1a Digital Trials pipeline configured for WES
Source-only snapshot of the cluster branch for WES execution. Large
reference files (HPA/MANE/ensemble FASTA, model weights, ~597 MB) are
omitted: they are baked into the container images at build time and
mounted from the dreamdock-data PVC at runtime, and exceed the Gitea
request size limit.

Pipeline entry point is main.nf, which orchestrates the biotransformer,
conplex and tissue modules as a single workflow. Ligand inputs are read
from the eureka workspace; protein_zarr and chembl_db come from the
dreamdock-data PVC.
2026-07-27 21:59:52 +01:00

226 lines
9.8 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import argparse
import pandas as pd
import numpy as np
from collections import defaultdict
from itertools import chain
from pathlib import Path
import zarr
from zarr.storage import LocalStore
# Load reference data
seq_exist = pd.read_csv('/app/MANE_all_transcipts.csv')
def get_round_2(threshold, workdir, round, drug_csv: Path):
workdir = Path(workdir)
fasta_name = "patient_0"
drug_name = drug_csv.stem
name_out = f"{drug_name}_{fasta_name}"
# Load all zarr score stores produced by screen.py.
results_zarr = list(workdir.glob('*_results.zarr'))
if not results_zarr:
raise FileNotFoundError("No results Zarr stores found")
print("Loading Zarr results...")
# Lazy zarr handles per file; we read the (proteins, compounds) score matrix
# in protein chunks rather than loading it fully — keeps peak memory bounded
# regardless of compound count.
zarr_groups = []
compound_ids_per = []
protein_ids_per = []
for z_path in results_zarr:
store = LocalStore(z_path, read_only=True)
group = zarr.open(store, mode="r")
zarr_groups.append(group)
# numpy 2.4+ returns StringDType for these arrays; convert via tolist()
# because direct .astype(str) raises "cannot cast StringDType to StrDType".
compound_ids_per.append(np.array(group["compound_ids"][:].tolist(), dtype=str))
protein_ids_per.append(np.array(group["protein_ids"][:].tolist(), dtype=str))
# All zarrs share the same compound axis (drug + same metabolites).
all_compound_ids = compound_ids_per[0]
for cids in compound_ids_per[1:]:
if cids.shape != all_compound_ids.shape or not np.array_equal(cids, all_compound_ids):
raise ValueError("All *_results.zarr stores must share the same compound_ids axis")
all_protein_ids = np.concatenate(protein_ids_per) if len(protein_ids_per) > 1 else protein_ids_per[0]
n_proteins = sum(g["scores"].shape[0] for g in zarr_groups)
n_compounds = all_compound_ids.shape[0]
# ---- Compound-axis indices (tiny) ----
drug_mask_cmpd = np.array([s.startswith("drug") for s in all_compound_ids], dtype=bool)
if not drug_mask_cmpd.any():
raise ValueError("No 'drug_*' compounds present in compound_ids")
drug_cols_idx = np.where(drug_mask_cmpd)[0]
drug0_search = np.where(all_compound_ids == 'drug_0')[0]
if drug0_search.size == 0:
raise ValueError("'drug_0' not found in compound_ids")
drug0_idx = int(drug0_search[0])
# ---- Stream the score matrix in protein chunks, writing significant_interactions
# incrementally and accumulating per-protein summary stats. Avoids both the
# full score matrix load and the giant in-memory above-threshold DataFrame. ----
# Per chunk peak ≈ CHUNK_PROTEINS * n_compounds * 4 bytes; for 1000×38k ≈ 150 MB.
CHUNK_PROTEINS = 1000
drug_position = np.zeros(n_proteins, dtype=np.int64)
drug_0_col_full = np.empty(n_proteins, dtype=np.float32)
prot_count = defaultdict(int) # protein_id -> count of above-threshold compounds
prot_sum = defaultdict(float) # protein_id -> sum of above-threshold scores
# Open significant_interactions.tsv before the loop (round 2 only); write rows per chunk.
sig_path = workdir / f'{name_out}_significant_interactions.tsv'
sig_writer = None
smile_dict = None
if round == 2:
test_smiles = pd.read_csv(workdir / 'smiles.smi', sep='\t', header=None)
smile_dict = dict(zip(test_smiles[1], test_smiles[0]))
sig_writer = open(sig_path, 'w')
sig_writer.write("drug/metabolite\tsmile\ttranscipt\tconplex_score\n")
cursor = 0
for group in zarr_groups:
scores_arr = group["scores"]
n_this = scores_arr.shape[0]
for start in range(0, n_this, CHUNK_PROTEINS):
end = min(start + CHUNK_PROTEINS, n_this)
chunk = np.asarray(scores_arr[start:end]) # (chunk_size, n_compounds)
global_start = cursor + start
global_end = cursor + end
chunk_size = end - start
# drug score columns + max-per-protein for this chunk
chunk_max_drug = chunk[:, drug_cols_idx].max(axis=1)
# drug_position[i] = # compounds with score strictly > max_drug_score_i.
# Matches production's stable-sort + first-drug-position convention.
drug_position[global_start:global_end] = (chunk > chunk_max_drug[:, None]).sum(axis=1)
# drug_0 column
drug_0_col_full[global_start:global_end] = chunk[:, drug0_idx].astype(np.float32, copy=False)
# Above-threshold rows for this chunk
pi_local, ci = np.where(chunk > threshold)
if pi_local.size:
scores_vec = chunk[pi_local, ci].astype(np.float32, copy=False)
# Per-protein aggregates accumulated via bincount on local indices.
counts_local = np.bincount(pi_local, minlength=chunk_size)
sums_local = np.bincount(pi_local, weights=scores_vec.astype(np.float64), minlength=chunk_size)
for pi in np.flatnonzero(counts_local):
pid = all_protein_ids[global_start + pi]
prot_count[pid] += int(counts_local[pi])
prot_sum[pid] += float(sums_local[pi])
# Write significant_interactions rows for this chunk directly to the file.
if sig_writer is not None:
compound_ids_vec = all_compound_ids[ci]
protein_ids_vec = all_protein_ids[global_start + pi_local]
smiles_vec = np.array(
[smile_dict.get(c, "UNKNOWN") for c in compound_ids_vec]
)
pd.DataFrame({
"drug/metabolite": compound_ids_vec,
"smile": smiles_vec,
"transcipt": protein_ids_vec,
"conplex_score": scores_vec,
}).to_csv(sig_writer, sep='\t', index=False, header=False)
cursor += n_this
if sig_writer is not None:
sig_writer.close()
drug_0_score = pd.DataFrame({
"Drug": "drug_0",
"Transcript": all_protein_ids,
"Score": drug_0_col_full,
})
if round == 2:
drug_0_score.to_csv(workdir / f'{name_out}_drug_scores.tsv', sep='\t', index=False)
# ---- Per-protein summary (only proteins with any above-threshold compound) ----
drug_pos_map = dict(zip(all_protein_ids, drug_position))
if round == 1 and not prot_count:
# Equivalent to old "transcipts_summary.empty" early-out for round 1.
pd.DataFrame().to_csv(workdir / 'round_1.csv', index=False)
with open(workdir / 'round_1.fasta', 'w') as f:
f.write("all_data_is_filtered_out\n")
return 'STOP NO TRANSCIPTS ABOVE THRESHOLD'
if prot_count:
proteins_with_above = list(prot_count.keys())
# mean dtype kept as float32 to match the precision of the previous
# groupby-on-float32 path (CSV output is bit-identical that way).
means = np.array(
[prot_sum[p] / prot_count[p] for p in proteins_with_above],
dtype=np.float32,
)
summary = pd.DataFrame({
'transcipt_name': proteins_with_above,
'number_of_iteracting_compounds': [prot_count[p] for p in proteins_with_above],
'mean_binding_above_threshold': means,
})
else:
summary = pd.DataFrame(columns=[
'transcipt_name', 'number_of_iteracting_compounds', 'mean_binding_above_threshold'
])
summary['drug_position'] = summary['transcipt_name'].map(drug_pos_map)
path_out = workdir / f'round_{round}.csv'
if len(summary) == 0:
with path_out.open("w") as f:
f.write("NO TRANSCIPTS ABOVE THRESHOLD")
return 'STOP NO TRANSCIPTS ABOVE THRESHOLD'
summary['if_drug_above_threshold'] = (
summary['number_of_iteracting_compounds'] > summary['drug_position']
)
# MANE symbol lookup; strip mutated-protein "_2" suffix.
clean_transcript_names = [str(t).split('_')[0] for t in summary['transcipt_name']]
seq_lookup = seq_exist.set_index('transcipt')['symbol']
summary['protein_name'] = [
seq_lookup.get(t, "") for t in clean_transcript_names
]
summary.to_csv(path_out, index=False)
# ---- Round 1 also writes the round-2 FASTA ----
if round == 1:
name_2_filtered = list(summary['protein_name'])
transcipts_2 = list(chain.from_iterable(
list(seq_exist[seq_exist['symbol'] == sym]['transcipt'])
for sym in name_2_filtered
))
transcipts_2 = list(np.unique(transcipts_2))
existing = set(summary['transcipt_name'])
transcipts_2 = [t for t in transcipts_2 if t not in existing]
fasta_new = []
for t in transcipts_2:
row = seq_exist[seq_exist['transcipt'] == t]
if not row.empty:
fasta_new.append(f">{t}")
fasta_new.append(row['seq'].iloc[0])
with open(workdir / 'round_2.fasta', 'w') as f:
for line in fasta_new:
f.write(f"{line}\n")
print(f"Round {round} processing complete.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Process and screen fragments.")
parser.add_argument("--threshold", required=True, type=float,
help="Threshold for 1. round of conplex scores")
parser.add_argument("--workdir", required=True, type=Path, help="workdir")
parser.add_argument("--round", required=True, type=int, help="Round 1 or 2")
parser.add_argument("--drug-csv", required=True, type=Path,
help="csv file with drug smiles")
args = parser.parse_args()
get_round_2(args.threshold, args.workdir, args.round, args.drug_csv)