bindsight 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -0,0 +1,118 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """cBioPortal molecular-subtype fetcher.
4
+
5
+ Pulls canonical PAM50 intrinsic-subtype calls for a TCGA study from the public
6
+ cBioPortal REST API (https://www.cbioportal.org/api). Subtype labels are keyed
7
+ by patient barcode (``TCGA-XX-XXXX``), which is identical to the GDC
8
+ ``cases.submitter_id`` — so the labels map straight onto the STAR-Counts files
9
+ fetched by :mod:`bindsight.io.gdc`.
10
+
11
+ This is what lets the rediscovery validation run *subtype-stratified* cohorts
12
+ (e.g. HER2-enriched breast tumors vs. adjacent normal), which is the scientific
13
+ prerequisite for recovering a subtype-specific antigen like ERBB2 — bulk
14
+ tumor-vs-normal over the whole BRCA cohort averages the HER2 signal away.
15
+
16
+ Source: cBioPortal (https://www.cbioportal.org/), Cerami et al. 2012
17
+ (doi:10.1158/2159-8290.CD-12-0095), Gao et al. 2013 (doi:10.1126/scisignal.2004088).
18
+ PAM50 subtypes: Parker et al. 2009 (doi:10.1200/JCO.2008.18.1370).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import collections
24
+ import datetime as _dt
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import requests
32
+ from tenacity import retry, stop_after_attempt, wait_exponential
33
+
34
+ LOG = logging.getLogger(__name__)
35
+
36
+ CBIOPORTAL_API = "https://www.cbioportal.org/api"
37
+ DEFAULT_STUDY = "brca_tcga_pan_can_atlas_2018"
38
+
39
+
40
+ @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2, min=2, max=16))
41
+ def _get_subtype_clinical_data(study_id: str) -> list[dict[str, Any]]:
42
+ """GET the per-patient SUBTYPE clinical attribute for a study (retried)."""
43
+ resp = requests.get(
44
+ f"{CBIOPORTAL_API}/studies/{study_id}/clinical-data",
45
+ params={
46
+ "clinicalDataType": "PATIENT",
47
+ "attributeId": "SUBTYPE",
48
+ "projection": "SUMMARY",
49
+ },
50
+ headers={"Accept": "application/json"},
51
+ timeout=60,
52
+ )
53
+ resp.raise_for_status()
54
+ data: list[dict[str, Any]] = resp.json()
55
+ return data
56
+
57
+
58
+ def fetch_pam50_subtypes(
59
+ study_id: str = DEFAULT_STUDY,
60
+ *,
61
+ cache_dir: Path | None = None,
62
+ ) -> dict[str, str]:
63
+ """Return ``{patient_barcode: subtype_label}`` for a cBioPortal study.
64
+
65
+ For ``brca_tcga_pan_can_atlas_2018`` the labels are the PAM50 calls
66
+ ``BRCA_Basal``, ``BRCA_Her2``, ``BRCA_LumA``, ``BRCA_LumB``, ``BRCA_Normal``.
67
+
68
+ Args:
69
+ study_id: cBioPortal study id.
70
+ cache_dir: if given, write the raw labels + provenance JSON here and
71
+ reuse them on subsequent calls (offline after the first run).
72
+
73
+ Returns:
74
+ Mapping from TCGA patient barcode to subtype label.
75
+ """
76
+ cache_path = None
77
+ if cache_dir is not None:
78
+ cache_dir = Path(cache_dir)
79
+ cache_dir.mkdir(parents=True, exist_ok=True)
80
+ cache_path = cache_dir / f"{study_id}_SUBTYPE.json"
81
+ if cache_path.exists():
82
+ cached = json.loads(cache_path.read_text(encoding="utf-8"))
83
+ LOG.info("cBioPortal: using cached subtypes (%d patients)", len(cached["labels"]))
84
+ return dict(cached["labels"])
85
+
86
+ LOG.info("cBioPortal: fetching SUBTYPE for %s", study_id)
87
+ rows = _get_subtype_clinical_data(study_id)
88
+ labels = {r["patientId"]: r["value"] for r in rows if r.get("value")}
89
+ if not labels:
90
+ raise RuntimeError(f"cBioPortal returned no SUBTYPE labels for {study_id}")
91
+
92
+ counts = collections.Counter(labels.values())
93
+ LOG.info("cBioPortal: %d patients with SUBTYPE (%s)", len(labels), dict(counts))
94
+
95
+ if cache_path is not None:
96
+ payload = {
97
+ "schema": "bindsight-cbioportal-subtypes/1",
98
+ "source": "cBioPortal (https://www.cbioportal.org/)",
99
+ "study_id": study_id,
100
+ "attribute": "SUBTYPE",
101
+ "retrieved_utc": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
102
+ "n_patients": len(labels),
103
+ "counts": dict(sorted(counts.items())),
104
+ "labels": labels,
105
+ }
106
+ text = json.dumps(payload, indent=2, sort_keys=True) + "\n"
107
+ cache_path.write_text(text, encoding="utf-8", newline="\n")
108
+ payload_sha = hashlib.sha256(text.encode()).hexdigest()
109
+ (cache_path.parent / f"{study_id}_SUBTYPE.sha256").write_text(
110
+ payload_sha + "\n", encoding="utf-8", newline="\n"
111
+ )
112
+
113
+ return labels
114
+
115
+
116
+ def patients_with_subtype(labels: dict[str, str], subtype: str) -> list[str]:
117
+ """Return the sorted patient barcodes whose label equals ``subtype``."""
118
+ return sorted(bc for bc, st in labels.items() if st == subtype)
bindsight/io/gdc.py ADDED
@@ -0,0 +1,343 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """NIH/GDC RNA-seq cohort fetcher.
4
+
5
+ Downloads **real** TCGA gene-expression (STAR - Counts) for a project such as
6
+ ``TCGA-BRCA``, assembles a gene × sample integer-count matrix plus a sample
7
+ design table, and writes full provenance (GDC file UUIDs, case barcodes,
8
+ sample types, workflow, retrieval timestamp, SHA-256 of the outputs).
9
+
10
+ This is what makes ``bindsight demo`` / a ``bindsight discover`` config run on
11
+ authentic patient data rather than illustrative numbers. The GDC open-access
12
+ gene-level expression API needs no token. Outputs are written where the run
13
+ config's ``inputs.counts`` / ``inputs.design`` point (typically a cache dir),
14
+ so the first run downloads and subsequent runs are offline.
15
+
16
+ Source: GDC Data Portal, https://portal.gdc.cancer.gov/ (NIH/NCI, open access).
17
+ TCGA data usage: https://www.cancer.gov/ccg/research/genome-sequencing/tcga/using-tcga-data/citing
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime as _dt
23
+ import gzip
24
+ import hashlib
25
+ import json
26
+ import logging
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import pandas as pd
32
+ import requests
33
+ from tenacity import retry, stop_after_attempt, wait_exponential
34
+
35
+ LOG = logging.getLogger(__name__)
36
+
37
+ GDC_FILES_ENDPOINT = "https://api.gdc.cancer.gov/files"
38
+ GDC_DATA_ENDPOINT = "https://api.gdc.cancer.gov/data/{file_id}"
39
+
40
+ # STAR-Counts column layout (GENCODE v36); column 3 (0-based) is the raw count.
41
+ _UNSTRANDED_COL = 3
42
+ _SKIP_ROW_PREFIXES = ("N_unmapped", "N_multimapping", "N_noFeature", "N_ambiguous")
43
+
44
+ _SAMPLE_TYPES = {"tumor": "Primary Tumor", "normal": "Solid Tissue Normal"}
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class CohortSample:
49
+ """One downloaded sample's provenance."""
50
+
51
+ sample: str # short bindsight sample id, e.g. "T01"
52
+ condition: str # "tumor" | "normal"
53
+ sample_type: str # GDC sample_type, e.g. "Primary Tumor"
54
+ case_barcode: str # TCGA case submitter id
55
+ sample_barcode: str # TCGA sample submitter id
56
+ gdc_file_id: str
57
+
58
+
59
+ @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2, min=2, max=16))
60
+ def _post_files_query(
61
+ filters: dict[str, Any], fields: list[str], size: int
62
+ ) -> list[dict[str, Any]]:
63
+ """Query the GDC files endpoint; return the hits list (retried on failure)."""
64
+ resp = requests.post(
65
+ GDC_FILES_ENDPOINT,
66
+ json={
67
+ "filters": filters,
68
+ "fields": ",".join(fields),
69
+ "format": "JSON",
70
+ "size": str(size),
71
+ "sort": "file_id:asc", # deterministic selection
72
+ },
73
+ timeout=60,
74
+ )
75
+ resp.raise_for_status()
76
+ hits: list[dict[str, Any]] = resp.json()["data"]["hits"]
77
+ return hits
78
+
79
+
80
+ _FILE_FIELDS = [
81
+ "file_id",
82
+ "cases.submitter_id",
83
+ "cases.samples.submitter_id",
84
+ "cases.samples.sample_type",
85
+ ]
86
+
87
+
88
+ def _list_cohort_files(
89
+ project: str, condition: str, n: int, *, cases: list[str] | None = None
90
+ ) -> list[dict[str, Any]]:
91
+ """List STAR-Counts files for a project + tumor/normal class.
92
+
93
+ With ``cases=None`` this returns the first ``n`` files (deterministic by
94
+ ``file_id``). With ``cases`` (a list of TCGA case submitter ids, e.g. the
95
+ HER2-enriched patients from cBioPortal) it restricts to those cases and
96
+ de-duplicates to at most one file per case before capping at ``n`` — so a
97
+ subtype-stratified cohort fetches exactly the patients you asked for.
98
+
99
+ An empty list raises. It means the restriction was applied and matched
100
+ nobody, which is not the same request as ``None`` and must not be answered
101
+ with an unrestricted cohort.
102
+ """
103
+ if cases is not None and not cases:
104
+ # `None` means "no restriction"; `[]` means "restrict to these, and
105
+ # there are none". Every guard below was `if cases:`, which merged them,
106
+ # so a subtype that matched nobody dropped the `cases.submitter_id`
107
+ # clause and fetched the first `n` files of any subtype instead. The
108
+ # cohort then carried the label of a stratified one while being an
109
+ # unstratified sample, with nothing raised, nothing warned, and
110
+ # `selection: "first_n"` recorded in its own provenance.
111
+ raise ValueError(
112
+ f"an empty case list was supplied for {project} {condition}: the "
113
+ "restriction matched no patients. Passing None asks for an "
114
+ "unrestricted cohort; passing [] asks for a cohort of nobody, which "
115
+ "cannot be silently widened to everybody."
116
+ )
117
+
118
+ content = [
119
+ {"op": "in", "content": {"field": "cases.project.project_id", "value": [project]}},
120
+ {
121
+ "op": "in",
122
+ "content": {"field": "data_type", "value": ["Gene Expression Quantification"]},
123
+ },
124
+ {"op": "in", "content": {"field": "analysis.workflow_type", "value": ["STAR - Counts"]}},
125
+ {
126
+ "op": "in",
127
+ "content": {"field": "cases.samples.sample_type", "value": [_SAMPLE_TYPES[condition]]},
128
+ },
129
+ ]
130
+ if cases:
131
+ content.append({"op": "in", "content": {"field": "cases.submitter_id", "value": cases}})
132
+
133
+ # When selecting by case, ask for all matching files (a case can have >1
134
+ # sample) then dedupe to one-per-case; otherwise just ask for n.
135
+ size = max(n, len(cases) * 2) if cases else n
136
+ hits = _post_files_query({"op": "and", "content": content}, _FILE_FIELDS, size=size)
137
+
138
+ if cases:
139
+ seen: set[str] = set()
140
+ deduped: list[dict[str, Any]] = []
141
+ for hit in hits:
142
+ barcode = (hit.get("cases", [{}])[0]).get("submitter_id", "")
143
+ if barcode and barcode not in seen:
144
+ seen.add(barcode)
145
+ deduped.append(hit)
146
+ hits = deduped
147
+
148
+ if len(hits) < n:
149
+ LOG.warning(
150
+ "%s %s: requested %d files but only %d available", project, condition, n, len(hits)
151
+ )
152
+ return hits[:n]
153
+
154
+
155
+ def matched_pair_cases(project: str, *, limit: int = 500) -> list[str]:
156
+ """Case barcodes with BOTH a primary tumour and a solid-tissue normal.
157
+
158
+ A paired differential-expression design removes between-patient variance,
159
+ which is the largest free power gain available to this study: in most TCGA
160
+ projects nearly every normal has a tumour from the same patient, so pairing
161
+ costs no data at all. It only works if both arms are drawn from the same
162
+ patients, which is what this selects.
163
+
164
+ The normal arm is the scarce one — a project with 113 normals and a thousand
165
+ tumours is limited by the normals — so normals are listed first and tumours
166
+ are then restricted to those patients.
167
+
168
+ Args:
169
+ project: GDC project id, for example ``"TCGA-BRCA"``.
170
+ limit: upper bound on files requested per arm.
171
+
172
+ Returns:
173
+ Sorted case submitter ids present in both arms. Sorted so a cohort is
174
+ reproducible rather than dependent on GDC's response ordering.
175
+ """
176
+
177
+ def _barcodes(condition: str, cases: list[str] | None = None) -> set[str]:
178
+ hits = _list_cohort_files(project, condition, limit, cases=cases)
179
+ return {
180
+ barcode
181
+ for hit in hits
182
+ if (barcode := (hit.get("cases", [{}])[0]).get("submitter_id", ""))
183
+ }
184
+
185
+ normals = _barcodes("normal")
186
+ if not normals:
187
+ LOG.warning("%s: no solid-tissue-normal files; no paired contrast is possible", project)
188
+ return []
189
+ tumours = _barcodes("tumor", cases=sorted(normals))
190
+ matched = sorted(normals & tumours)
191
+ LOG.info(
192
+ "%s: %d normal case(s), %d of which also have a primary tumour",
193
+ project,
194
+ len(normals),
195
+ len(matched),
196
+ )
197
+ return matched
198
+
199
+
200
+ @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=2, min=2, max=16))
201
+ def _download_counts(file_id: str, gene_types: tuple[str, ...]) -> dict[str, int]:
202
+ """Download one STAR-Counts file; return {ensembl_gene_id: unstranded_count}.
203
+
204
+ Strips the Ensembl version suffix, skips the STAR summary rows and the
205
+ pseudoautosomal ``_PAR_Y`` duplicates, and optionally restricts to the
206
+ given gene biotypes.
207
+ """
208
+ resp = requests.get(GDC_DATA_ENDPOINT.format(file_id=file_id), timeout=120)
209
+ resp.raise_for_status()
210
+ counts: dict[str, int] = {}
211
+ for line in resp.text.splitlines():
212
+ if not line or line.startswith("#") or line.startswith("gene_id\t"):
213
+ continue
214
+ if line.startswith(_SKIP_ROW_PREFIXES):
215
+ continue
216
+ parts = line.split("\t")
217
+ if len(parts) <= _UNSTRANDED_COL:
218
+ continue
219
+ gene_id = parts[0]
220
+ if "_PAR_Y" in gene_id:
221
+ continue
222
+ gene_type = parts[2]
223
+ if gene_types and gene_type not in gene_types:
224
+ continue
225
+ base = gene_id.split(".")[0]
226
+ try:
227
+ counts[base] = int(parts[_UNSTRANDED_COL])
228
+ except ValueError:
229
+ continue
230
+ return counts
231
+
232
+
233
+ def _sha256(path: Path) -> str:
234
+ return hashlib.sha256(path.read_bytes()).hexdigest()
235
+
236
+
237
+ def fetch_cohort(
238
+ *,
239
+ project: str,
240
+ n_tumor: int,
241
+ n_normal: int,
242
+ counts_out: Path,
243
+ design_out: Path,
244
+ gene_types: tuple[str, ...] = ("protein_coding",),
245
+ tumor_cases: list[str] | None = None,
246
+ normal_cases: list[str] | None = None,
247
+ ) -> dict[str, object]:
248
+ """Download a real TCGA cohort and write counts + design + provenance.
249
+
250
+ Args:
251
+ project: GDC project id, e.g. ``"TCGA-BRCA"``.
252
+ n_tumor: number of Primary Tumor samples to download.
253
+ n_normal: number of Solid Tissue Normal samples to download.
254
+ counts_out: where to write the gzipped gene × sample counts TSV.
255
+ design_out: where to write the sample design TSV.
256
+ gene_types: biotypes to keep (``()`` keeps all). Defaults to
257
+ protein-coding, which covers the surface-antigen targets and keeps
258
+ DESeq2 tractable.
259
+ tumor_cases: optional list of TCGA case submitter ids to restrict the
260
+ Primary Tumor samples to (e.g. a PAM50 subtype cohort). When given,
261
+ ``n_tumor`` caps how many of those cases are used.
262
+ normal_cases: optional list of case submitter ids for the Solid Tissue
263
+ Normal samples (usually left ``None`` to take adjacent normals).
264
+
265
+ Returns:
266
+ The provenance dict (also written next to ``counts_out`` as
267
+ ``provenance.json``).
268
+ """
269
+ counts_out = Path(counts_out)
270
+ design_out = Path(design_out)
271
+ counts_out.parent.mkdir(parents=True, exist_ok=True)
272
+ design_out.parent.mkdir(parents=True, exist_ok=True)
273
+
274
+ LOG.info("GDC: listing %s files (%d tumor + %d normal)…", project, n_tumor, n_normal)
275
+ tumor_hits = _list_cohort_files(project, "tumor", n_tumor, cases=tumor_cases)
276
+ normal_hits = _list_cohort_files(project, "normal", n_normal, cases=normal_cases)
277
+
278
+ samples: list[CohortSample] = []
279
+ per_sample_counts: dict[str, dict[str, int]] = {}
280
+ for condition, hits in (("tumor", tumor_hits), ("normal", normal_hits)):
281
+ prefix = "T" if condition == "tumor" else "N"
282
+ for i, hit in enumerate(hits, start=1):
283
+ sample_id = f"{prefix}{i:02d}"
284
+ case = hit.get("cases", [{}])[0]
285
+ sample0 = (case.get("samples") or [{}])[0]
286
+ LOG.info("GDC: downloading %s (%s) %s…", sample_id, condition, hit["file_id"][:8])
287
+ per_sample_counts[sample_id] = _download_counts(hit["file_id"], gene_types)
288
+ samples.append(
289
+ CohortSample(
290
+ sample=sample_id,
291
+ condition=condition,
292
+ sample_type=sample0.get("sample_type", _SAMPLE_TYPES[condition]),
293
+ case_barcode=case.get("submitter_id", ""),
294
+ sample_barcode=sample0.get("submitter_id", ""),
295
+ gdc_file_id=hit["file_id"],
296
+ )
297
+ )
298
+
299
+ if not per_sample_counts:
300
+ raise RuntimeError(f"GDC returned no files for {project}")
301
+
302
+ # Assemble the gene × sample matrix on the intersection of genes (all STAR
303
+ # files share the GENCODE v36 gene set, so this is the full set in practice).
304
+ counts_df = pd.DataFrame(per_sample_counts).dropna().astype(int)
305
+ counts_df.index.name = "gene_id"
306
+ counts_df = counts_df.sort_index()
307
+
308
+ with gzip.open(counts_out, "wt", newline="", encoding="utf-8") as fh:
309
+ counts_df.to_csv(fh, sep="\t")
310
+
311
+ design_df = pd.DataFrame([asdict(s) for s in samples]).set_index("sample")
312
+ design_df.to_csv(design_out, sep="\t", lineterminator="\n")
313
+
314
+ provenance: dict[str, object] = {
315
+ "schema": "bindsight-gdc-cohort/1",
316
+ "source": "NIH/GDC (https://portal.gdc.cancer.gov/)",
317
+ "project": project,
318
+ "workflow": "STAR - Counts (GENCODE v36)",
319
+ "data_column": "unstranded",
320
+ "gene_types": list(gene_types),
321
+ "retrieved_utc": _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds"),
322
+ "selection": "by_case" if (tumor_cases or normal_cases) else "first_n",
323
+ "requested_tumor_cases": sorted(tumor_cases) if tumor_cases else None,
324
+ "requested_normal_cases": sorted(normal_cases) if normal_cases else None,
325
+ "n_tumor": sum(1 for s in samples if s.condition == "tumor"),
326
+ "n_normal": sum(1 for s in samples if s.condition == "normal"),
327
+ "n_genes": int(counts_df.shape[0]),
328
+ "samples": [asdict(s) for s in samples],
329
+ "outputs": {
330
+ counts_out.name: {"sha256": _sha256(counts_out), "bytes": counts_out.stat().st_size},
331
+ design_out.name: {"sha256": _sha256(design_out), "bytes": design_out.stat().st_size},
332
+ },
333
+ }
334
+ prov_path = counts_out.parent / "provenance.json"
335
+ prov_path.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8", newline="\n")
336
+ LOG.info(
337
+ "GDC: wrote %s (%d genes × %d samples) + %s",
338
+ counts_out,
339
+ counts_df.shape[0],
340
+ counts_df.shape[1],
341
+ design_out.name,
342
+ )
343
+ return provenance
bindsight/io/paths.py ADDED
@@ -0,0 +1,124 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Cache and run directory helpers.
4
+
5
+ Cache layout (resolved via :mod:`platformdirs`):
6
+
7
+ <user_cache>/bindsight/
8
+ surface_bind/<commit_sha>/...
9
+ alphafolddb/<uniprot_id>.cif.gz
10
+ opentargets/<query_sha>.json
11
+
12
+ Run layout (per-run, user-chosen via ``--out``):
13
+
14
+ <run_dir>/
15
+ config.yaml
16
+ run_manifest.jsonld
17
+ deg/results.parquet
18
+ targets/candidates.parquet
19
+ epitopes/epitopes.parquet
20
+ structures/<uniprot_id>.cif
21
+ design/<uniprot_id>/...
22
+ validate/<uniprot_id>/...
23
+ rank/ranking.parquet
24
+ report.html
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import shutil
31
+ from pathlib import Path
32
+
33
+ from platformdirs import user_cache_path
34
+
35
+ #: Overrides the cache root. The only cross-platform seam for redirecting the
36
+ #: cache: ``XDG_CACHE_HOME`` is ignored by platformdirs on macOS, and a
37
+ #: ``monkeypatch`` cannot reach a subprocess — which is what the Snakemake
38
+ #: front-end and its test are. Same convention as ``BINDSIGHT_SURFACE_BIND_DATA``.
39
+ ENV_CACHE_DIR = "BINDSIGHT_CACHE_DIR"
40
+
41
+ #: Subdirectory of a run holding structures copied in from the cache.
42
+ STRUCTURES_SUBDIR = "structures"
43
+
44
+
45
+ def cache_root() -> Path:
46
+ """Return the bindsight cache root, honouring ``BINDSIGHT_CACHE_DIR``."""
47
+ override = os.environ.get(ENV_CACHE_DIR)
48
+ if override:
49
+ base = Path(override).expanduser()
50
+ base.mkdir(parents=True, exist_ok=True)
51
+ return base
52
+ return user_cache_path("bindsight", appauthor=False, ensure_exists=True)
53
+
54
+
55
+ def cache_dir(subdir: str | None = None) -> Path:
56
+ """Return ``<cache_root>/[subdir]``, creating it if missing."""
57
+ base = cache_root()
58
+ if subdir is None:
59
+ return base
60
+ p = base / subdir
61
+ p.mkdir(parents=True, exist_ok=True)
62
+ return p
63
+
64
+
65
+ def run_dir(out: Path | str) -> Path:
66
+ """Initialize a run directory, creating the standard subdirectories."""
67
+ root = Path(out)
68
+ for sub in ("deg", "targets", "epitopes", STRUCTURES_SUBDIR, "design", "validate", "rank"):
69
+ (root / sub).mkdir(parents=True, exist_ok=True)
70
+ return root
71
+
72
+
73
+ def adopt_structure(run_root: Path | str, source: Path | str) -> str:
74
+ """Copy a structure into the run's ``structures/`` directory.
75
+
76
+ ``structures/`` has always been created by :func:`run_dir` and never written
77
+ to, while the structure itself stayed in the machine-local cache. Runs were
78
+ therefore not portable: a candidate table, and any RO-Crate built from it,
79
+ pointed at absolute paths that exist on exactly one machine.
80
+
81
+ Copying is deliberate — the cache is shared across runs and may be pruned,
82
+ so a hard link or symlink would let an unrelated cleanup silently gut a
83
+ finished, exported run.
84
+
85
+ Args:
86
+ run_root: The run directory.
87
+ source: Path to the cached structure file.
88
+
89
+ Returns:
90
+ The path relative to ``run_root``, in POSIX form, suitable for storing
91
+ in a Parquet column and reading back on another machine.
92
+ """
93
+ root = Path(run_root)
94
+ src = Path(source)
95
+ dest_dir = root / STRUCTURES_SUBDIR
96
+ dest_dir.mkdir(parents=True, exist_ok=True)
97
+ dest = dest_dir / src.name
98
+ if not dest.exists() or dest.stat().st_size != src.stat().st_size:
99
+ shutil.copy2(src, dest)
100
+ return dest.relative_to(root).as_posix()
101
+
102
+
103
+ def resolve_run_path(run_root: Path | str, stored: str | Path | None) -> Path | None:
104
+ """Resolve a path stored in a run artifact back to a real file.
105
+
106
+ Accepts both forms deliberately. New runs store run-relative paths so they
107
+ stay portable; runs produced before that change stored absolute cache
108
+ paths, and must keep working.
109
+
110
+ Args:
111
+ run_root: The run directory the artifact belongs to.
112
+ stored: The stored path, possibly empty or ``None``.
113
+
114
+ Returns:
115
+ An existing absolute path, or ``None`` if the reference is empty or
116
+ cannot be resolved.
117
+ """
118
+ if not stored:
119
+ return None
120
+ p = Path(stored)
121
+ if p.is_absolute():
122
+ return p if p.exists() else None
123
+ candidate = Path(run_root) / p
124
+ return candidate if candidate.exists() else None
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """End-to-end pipeline orchestrators.
4
+
5
+ Each module here exposes a ``run(config: RunConfig, ...) -> Manifest``
6
+ function that drives a chunk of the pipeline. The Click CLI calls these
7
+ directly (and the Snakefile calls them via ``scripts/``), so behavior stays
8
+ identical regardless of how a user invokes a stage.
9
+ """
@@ -0,0 +1,60 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """User-facing scientific caveats for the discovery half.
4
+
5
+ Single source of truth so the pipeline logs, the run manifest, and the HTML
6
+ report all state the *same* limitations in the same words. Kept dependency-free
7
+ (stdlib only) so the report renderer can import it without pulling in the heavy
8
+ discovery module — the same reason the disposition list is duplicated in
9
+ ``bindsight.report.html``.
10
+
11
+ These are inherent limits of expression-based discovery, not bugs: bindsight
12
+ surfaces them rather than hiding them, consistent with its failure-honest design.
13
+
14
+ They are also worded for *any* two-condition contrast, because they are stamped
15
+ into every run manifest and every report. One of them used to be written purely
16
+ in tumour vocabulary, so a drug-versus-vehicle experiment came back carrying a
17
+ note about tumour purity — a limitation that is real and general, described in a
18
+ domain the run was not in.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ # Each entry is (title, body). Rendered as a "Limitations" section in the HTML
24
+ # report, logged at WARNING when discovery runs, and summarised into the run
25
+ # manifest's discover-stage notes.
26
+ DISCOVERY_LIMITATIONS: tuple[tuple[str, str], ...] = (
27
+ (
28
+ "mRNA abundance is not cell-surface protein abundance",
29
+ "Discovery ranks candidates from RNA-seq transcript abundance and a curated "
30
+ "surfaceome (SURFY). SURFY establishes that a protein *can* reach the cell "
31
+ "surface; it does not measure how much protein is actually there. Transcript "
32
+ "level is an imperfect proxy for surface-protein abundance — translation rate, "
33
+ "membrane trafficking and ectodomain shedding all intervene. Treat surfaced "
34
+ "candidates as hypotheses to confirm at the protein level (e.g. flow cytometry, "
35
+ "immunohistochemistry, or the Human Protein Atlas) before committing design effort.",
36
+ ),
37
+ (
38
+ "Bulk expression cannot attribute a transcript to a cell type",
39
+ "A bulk contrast measures the average over whatever cells each sample "
40
+ "contained. It cannot tell whether a transcript rose because the cells of "
41
+ "interest express more of it, or because the two arms hold different "
42
+ "mixtures of cells. Apparent over-expression can therefore reflect "
43
+ "composition rather than biology: in a tumour-versus-normal contrast that "
44
+ "is tumour purity and immune or stromal infiltration; in a treated-versus-"
45
+ "untreated contrast it is a shift in which cells survived or proliferated; "
46
+ "between two tissues it is simply that they are made of different cells. "
47
+ "Single-cell data or deconvolution is what establishes cell-intrinsic "
48
+ "expression, and neither is implemented (planned for v1.0).",
49
+ ),
50
+ )
51
+
52
+
53
+ def caveat_titles() -> tuple[str, ...]:
54
+ """Return just the short titles, for logging."""
55
+ return tuple(title for title, _ in DISCOVERY_LIMITATIONS)
56
+
57
+
58
+ def caveat_summary() -> str:
59
+ """One-line marker for the manifest discover-stage notes."""
60
+ return "caveats: " + "; ".join(caveat_titles()) + " (see report Limitations)"