encode-toolkit 0.3.0b1__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.
- encode_connector/__init__.py +4 -0
- encode_connector/__main__.py +5 -0
- encode_connector/client/__init__.py +6 -0
- encode_connector/client/auth.py +262 -0
- encode_connector/client/constants.py +348 -0
- encode_connector/client/downloader.py +305 -0
- encode_connector/client/encode_client.py +585 -0
- encode_connector/client/models.py +332 -0
- encode_connector/client/tracker.py +1129 -0
- encode_connector/client/validation.py +188 -0
- encode_connector/server/__init__.py +1 -0
- encode_connector/server/__main__.py +5 -0
- encode_connector/server/main.py +1495 -0
- encode_toolkit-0.3.0b1.dist-info/METADATA +810 -0
- encode_toolkit-0.3.0b1.dist-info/RECORD +18 -0
- encode_toolkit-0.3.0b1.dist-info/WHEEL +4 -0
- encode_toolkit-0.3.0b1.dist-info/entry_points.txt +2 -0
- encode_toolkit-0.3.0b1.dist-info/licenses/LICENSE +144 -0
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
"""Experiment tracker with SQLite storage.
|
|
2
|
+
|
|
3
|
+
Tracks experiments, publications, methods, pipeline info, quality metrics,
|
|
4
|
+
provenance of derived files, and supports compatibility analysis between
|
|
5
|
+
experiments.
|
|
6
|
+
|
|
7
|
+
All data stored locally in SQLite. No external connections except ENCODE API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from encode_connector.client.validation import escape_like, validate_reference_type
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
DEFAULT_DB_PATH = Path.home() / ".encode_connector" / "tracker.db"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExperimentTracker:
|
|
28
|
+
"""SQLite-backed experiment tracker for ENCODE data."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
31
|
+
self._db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
|
|
32
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
self._conn: sqlite3.Connection | None = None
|
|
34
|
+
self._lock = threading.Lock() # Protects connection creation AND write operations
|
|
35
|
+
self._ensure_schema()
|
|
36
|
+
|
|
37
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
38
|
+
with self._lock:
|
|
39
|
+
if self._conn is None:
|
|
40
|
+
self._conn = sqlite3.connect(
|
|
41
|
+
str(self._db_path),
|
|
42
|
+
check_same_thread=False,
|
|
43
|
+
)
|
|
44
|
+
self._conn.row_factory = sqlite3.Row
|
|
45
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
46
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
47
|
+
return self._conn
|
|
48
|
+
|
|
49
|
+
def _ensure_schema(self) -> None:
|
|
50
|
+
conn = self._get_conn()
|
|
51
|
+
# Note: executescript() implicitly commits any pending transaction.
|
|
52
|
+
# We re-issue PRAGMA foreign_keys=ON afterward because executescript
|
|
53
|
+
# can interfere with connection-level pragma state in some SQLite builds.
|
|
54
|
+
conn.executescript("""
|
|
55
|
+
CREATE TABLE IF NOT EXISTS tracked_experiments (
|
|
56
|
+
accession TEXT PRIMARY KEY,
|
|
57
|
+
assay_title TEXT,
|
|
58
|
+
target TEXT,
|
|
59
|
+
biosample_summary TEXT,
|
|
60
|
+
organism TEXT,
|
|
61
|
+
organ TEXT,
|
|
62
|
+
biosample_type TEXT,
|
|
63
|
+
status TEXT,
|
|
64
|
+
date_released TEXT,
|
|
65
|
+
description TEXT,
|
|
66
|
+
lab TEXT,
|
|
67
|
+
award TEXT,
|
|
68
|
+
assembly TEXT,
|
|
69
|
+
replication_type TEXT,
|
|
70
|
+
life_stage TEXT,
|
|
71
|
+
url TEXT,
|
|
72
|
+
raw_metadata TEXT,
|
|
73
|
+
tracked_at REAL,
|
|
74
|
+
updated_at REAL,
|
|
75
|
+
notes TEXT DEFAULT ''
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE TABLE IF NOT EXISTS publications (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
experiment_accession TEXT NOT NULL,
|
|
81
|
+
pmid TEXT,
|
|
82
|
+
doi TEXT,
|
|
83
|
+
title TEXT,
|
|
84
|
+
authors TEXT,
|
|
85
|
+
journal TEXT,
|
|
86
|
+
year TEXT,
|
|
87
|
+
abstract TEXT,
|
|
88
|
+
FOREIGN KEY (experiment_accession) REFERENCES tracked_experiments(accession),
|
|
89
|
+
UNIQUE(experiment_accession, pmid)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS pipeline_info (
|
|
93
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
94
|
+
experiment_accession TEXT NOT NULL,
|
|
95
|
+
pipeline_title TEXT,
|
|
96
|
+
pipeline_version TEXT,
|
|
97
|
+
software_list TEXT,
|
|
98
|
+
analysis_status TEXT,
|
|
99
|
+
FOREIGN KEY (experiment_accession) REFERENCES tracked_experiments(accession)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS quality_metrics (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
experiment_accession TEXT NOT NULL,
|
|
105
|
+
file_accession TEXT,
|
|
106
|
+
metric_type TEXT,
|
|
107
|
+
metric_data TEXT,
|
|
108
|
+
FOREIGN KEY (experiment_accession) REFERENCES tracked_experiments(accession)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS derived_files (
|
|
112
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
113
|
+
file_path TEXT NOT NULL,
|
|
114
|
+
source_accessions TEXT NOT NULL,
|
|
115
|
+
description TEXT,
|
|
116
|
+
created_at REAL,
|
|
117
|
+
file_type TEXT,
|
|
118
|
+
tool_used TEXT,
|
|
119
|
+
parameters TEXT,
|
|
120
|
+
notes TEXT DEFAULT ''
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
CREATE TABLE IF NOT EXISTS external_references (
|
|
124
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
|
+
experiment_accession TEXT NOT NULL,
|
|
126
|
+
reference_type TEXT NOT NULL,
|
|
127
|
+
reference_id TEXT NOT NULL,
|
|
128
|
+
description TEXT DEFAULT '',
|
|
129
|
+
linked_at REAL,
|
|
130
|
+
FOREIGN KEY (experiment_accession) REFERENCES tracked_experiments(accession),
|
|
131
|
+
UNIQUE(experiment_accession, reference_type, reference_id)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_pub_experiment
|
|
135
|
+
ON publications(experiment_accession);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_pipeline_experiment
|
|
137
|
+
ON pipeline_info(experiment_accession);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_qm_experiment
|
|
139
|
+
ON quality_metrics(experiment_accession);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_derived_sources
|
|
141
|
+
ON derived_files(source_accessions);
|
|
142
|
+
CREATE INDEX IF NOT EXISTS idx_extref_experiment
|
|
143
|
+
ON external_references(experiment_accession);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_extref_type
|
|
145
|
+
ON external_references(reference_type);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_exp_assay
|
|
147
|
+
ON tracked_experiments(assay_title);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_exp_organism
|
|
149
|
+
ON tracked_experiments(organism);
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_exp_organ
|
|
151
|
+
ON tracked_experiments(organ);
|
|
152
|
+
""")
|
|
153
|
+
# Re-issue foreign_keys pragma after executescript to ensure it's active
|
|
154
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
155
|
+
conn.commit()
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
if self._conn:
|
|
159
|
+
self._conn.close()
|
|
160
|
+
self._conn = None
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Track experiments
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def track_experiment(self, experiment_data: dict, raw_metadata: dict | None = None) -> dict:
|
|
167
|
+
"""Add or update an experiment in the tracker."""
|
|
168
|
+
conn = self._get_conn()
|
|
169
|
+
now = time.time()
|
|
170
|
+
accession = experiment_data.get("accession", "")
|
|
171
|
+
|
|
172
|
+
# Check if already tracked
|
|
173
|
+
existing = conn.execute(
|
|
174
|
+
"SELECT accession, tracked_at FROM tracked_experiments WHERE accession = ?",
|
|
175
|
+
(accession,),
|
|
176
|
+
).fetchone()
|
|
177
|
+
|
|
178
|
+
raw_json = json.dumps(raw_metadata) if raw_metadata else "{}"
|
|
179
|
+
# Limit raw metadata size to 512KB to prevent storage bloat
|
|
180
|
+
max_raw_size = 512 * 1024
|
|
181
|
+
if len(raw_json) > max_raw_size:
|
|
182
|
+
raw_json = "{}"
|
|
183
|
+
|
|
184
|
+
if existing:
|
|
185
|
+
conn.execute(
|
|
186
|
+
"""
|
|
187
|
+
UPDATE tracked_experiments SET
|
|
188
|
+
assay_title=?, target=?, biosample_summary=?, organism=?,
|
|
189
|
+
organ=?, biosample_type=?, status=?, date_released=?,
|
|
190
|
+
description=?, lab=?, award=?, assembly=?,
|
|
191
|
+
replication_type=?, life_stage=?, url=?,
|
|
192
|
+
raw_metadata=?, updated_at=?
|
|
193
|
+
WHERE accession=?
|
|
194
|
+
""",
|
|
195
|
+
(
|
|
196
|
+
experiment_data.get("assay_title", ""),
|
|
197
|
+
experiment_data.get("target", ""),
|
|
198
|
+
experiment_data.get("biosample_summary", ""),
|
|
199
|
+
experiment_data.get("organism", ""),
|
|
200
|
+
experiment_data.get("organ", ""),
|
|
201
|
+
experiment_data.get("biosample_type", ""),
|
|
202
|
+
experiment_data.get("status", ""),
|
|
203
|
+
experiment_data.get("date_released", ""),
|
|
204
|
+
experiment_data.get("description", ""),
|
|
205
|
+
experiment_data.get("lab", ""),
|
|
206
|
+
experiment_data.get("award", ""),
|
|
207
|
+
experiment_data.get("assembly", ""),
|
|
208
|
+
experiment_data.get("replication_type", ""),
|
|
209
|
+
experiment_data.get("life_stage", ""),
|
|
210
|
+
experiment_data.get("url", ""),
|
|
211
|
+
raw_json,
|
|
212
|
+
now,
|
|
213
|
+
accession,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
action = "updated"
|
|
217
|
+
else:
|
|
218
|
+
conn.execute(
|
|
219
|
+
"""
|
|
220
|
+
INSERT INTO tracked_experiments (
|
|
221
|
+
accession, assay_title, target, biosample_summary, organism,
|
|
222
|
+
organ, biosample_type, status, date_released, description,
|
|
223
|
+
lab, award, assembly, replication_type, life_stage, url,
|
|
224
|
+
raw_metadata, tracked_at, updated_at
|
|
225
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
226
|
+
""",
|
|
227
|
+
(
|
|
228
|
+
accession,
|
|
229
|
+
experiment_data.get("assay_title", ""),
|
|
230
|
+
experiment_data.get("target", ""),
|
|
231
|
+
experiment_data.get("biosample_summary", ""),
|
|
232
|
+
experiment_data.get("organism", ""),
|
|
233
|
+
experiment_data.get("organ", ""),
|
|
234
|
+
experiment_data.get("biosample_type", ""),
|
|
235
|
+
experiment_data.get("status", ""),
|
|
236
|
+
experiment_data.get("date_released", ""),
|
|
237
|
+
experiment_data.get("description", ""),
|
|
238
|
+
experiment_data.get("lab", ""),
|
|
239
|
+
experiment_data.get("award", ""),
|
|
240
|
+
experiment_data.get("assembly", ""),
|
|
241
|
+
experiment_data.get("replication_type", ""),
|
|
242
|
+
experiment_data.get("life_stage", ""),
|
|
243
|
+
experiment_data.get("url", ""),
|
|
244
|
+
raw_json,
|
|
245
|
+
now,
|
|
246
|
+
now,
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
action = "tracked"
|
|
250
|
+
|
|
251
|
+
conn.commit()
|
|
252
|
+
return {"accession": accession, "action": action}
|
|
253
|
+
|
|
254
|
+
def get_tracked_experiment(self, accession: str) -> dict | None:
|
|
255
|
+
"""Get a tracked experiment by accession."""
|
|
256
|
+
conn = self._get_conn()
|
|
257
|
+
row = conn.execute(
|
|
258
|
+
"SELECT * FROM tracked_experiments WHERE accession = ?",
|
|
259
|
+
(accession,),
|
|
260
|
+
).fetchone()
|
|
261
|
+
if row:
|
|
262
|
+
return dict(row)
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def list_tracked_experiments(
|
|
266
|
+
self,
|
|
267
|
+
assay_title: str | None = None,
|
|
268
|
+
organism: str | None = None,
|
|
269
|
+
organ: str | None = None,
|
|
270
|
+
) -> list[dict]:
|
|
271
|
+
"""List tracked experiments with optional filters."""
|
|
272
|
+
conn = self._get_conn()
|
|
273
|
+
query = "SELECT * FROM tracked_experiments WHERE 1=1"
|
|
274
|
+
params: list[Any] = []
|
|
275
|
+
|
|
276
|
+
if assay_title:
|
|
277
|
+
query += " AND assay_title LIKE ? ESCAPE '\\'"
|
|
278
|
+
params.append(f"%{escape_like(assay_title)}%")
|
|
279
|
+
if organism:
|
|
280
|
+
query += " AND organism LIKE ? ESCAPE '\\'"
|
|
281
|
+
params.append(f"%{escape_like(organism)}%")
|
|
282
|
+
if organ:
|
|
283
|
+
query += " AND organ LIKE ? ESCAPE '\\'"
|
|
284
|
+
params.append(f"%{escape_like(organ)}%")
|
|
285
|
+
|
|
286
|
+
query += " ORDER BY tracked_at DESC"
|
|
287
|
+
rows = conn.execute(query, params).fetchall()
|
|
288
|
+
return [dict(r) for r in rows]
|
|
289
|
+
|
|
290
|
+
def remove_tracked_experiment(self, accession: str) -> bool:
|
|
291
|
+
"""Remove an experiment and all related data from tracking.
|
|
292
|
+
|
|
293
|
+
Uses an explicit transaction to ensure all child table deletes
|
|
294
|
+
and the parent delete are atomic — no partial state on failure.
|
|
295
|
+
"""
|
|
296
|
+
conn = self._get_conn()
|
|
297
|
+
with self._lock:
|
|
298
|
+
try:
|
|
299
|
+
conn.execute("BEGIN")
|
|
300
|
+
conn.execute("DELETE FROM publications WHERE experiment_accession = ?", (accession,))
|
|
301
|
+
conn.execute("DELETE FROM pipeline_info WHERE experiment_accession = ?", (accession,))
|
|
302
|
+
conn.execute("DELETE FROM quality_metrics WHERE experiment_accession = ?", (accession,))
|
|
303
|
+
conn.execute("DELETE FROM external_references WHERE experiment_accession = ?", (accession,))
|
|
304
|
+
# Also remove derived_files referencing this experiment (stored as JSON array)
|
|
305
|
+
conn.execute(
|
|
306
|
+
"DELETE FROM derived_files WHERE source_accessions LIKE ? ESCAPE '\\'",
|
|
307
|
+
(f"%{escape_like(accession)}%",),
|
|
308
|
+
)
|
|
309
|
+
result = conn.execute("DELETE FROM tracked_experiments WHERE accession = ?", (accession,))
|
|
310
|
+
conn.commit()
|
|
311
|
+
return result.rowcount > 0
|
|
312
|
+
except Exception:
|
|
313
|
+
conn.rollback()
|
|
314
|
+
raise
|
|
315
|
+
|
|
316
|
+
def add_note(self, accession: str, note: str) -> bool:
|
|
317
|
+
"""Add or update a note on a tracked experiment."""
|
|
318
|
+
conn = self._get_conn()
|
|
319
|
+
result = conn.execute(
|
|
320
|
+
"UPDATE tracked_experiments SET notes = ?, updated_at = ? WHERE accession = ?",
|
|
321
|
+
(note, time.time(), accession),
|
|
322
|
+
)
|
|
323
|
+
conn.commit()
|
|
324
|
+
return result.rowcount > 0
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
# Publications
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def store_publications(self, accession: str, publications: list[dict]) -> int:
|
|
331
|
+
"""Store publications for an experiment. Returns count stored."""
|
|
332
|
+
conn = self._get_conn()
|
|
333
|
+
count = 0
|
|
334
|
+
for pub in publications:
|
|
335
|
+
try:
|
|
336
|
+
conn.execute(
|
|
337
|
+
"""
|
|
338
|
+
INSERT OR REPLACE INTO publications
|
|
339
|
+
(experiment_accession, pmid, doi, title, authors, journal, year, abstract)
|
|
340
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
341
|
+
""",
|
|
342
|
+
(
|
|
343
|
+
accession,
|
|
344
|
+
pub.get("pmid", ""),
|
|
345
|
+
pub.get("doi", ""),
|
|
346
|
+
pub.get("title", ""),
|
|
347
|
+
pub.get("authors", ""),
|
|
348
|
+
pub.get("journal", ""),
|
|
349
|
+
pub.get("year", ""),
|
|
350
|
+
pub.get("abstract", ""),
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
count += 1
|
|
354
|
+
except sqlite3.IntegrityError:
|
|
355
|
+
pass
|
|
356
|
+
conn.commit()
|
|
357
|
+
return count
|
|
358
|
+
|
|
359
|
+
def get_publications(self, accession: str) -> list[dict]:
|
|
360
|
+
"""Get publications for a tracked experiment."""
|
|
361
|
+
conn = self._get_conn()
|
|
362
|
+
rows = conn.execute(
|
|
363
|
+
"SELECT * FROM publications WHERE experiment_accession = ?",
|
|
364
|
+
(accession,),
|
|
365
|
+
).fetchall()
|
|
366
|
+
return [dict(r) for r in rows]
|
|
367
|
+
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
# Pipeline info
|
|
370
|
+
# ------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def store_pipeline_info(self, accession: str, pipelines: list[dict]) -> int:
|
|
373
|
+
"""Store pipeline/analysis info for an experiment.
|
|
374
|
+
|
|
375
|
+
Uses an explicit transaction so the DELETE + INSERTs are atomic.
|
|
376
|
+
If any INSERT fails, the existing data is preserved (not erased).
|
|
377
|
+
Holds self._lock for the entire transaction to prevent concurrent
|
|
378
|
+
BEGIN on the same connection (which raises OperationalError).
|
|
379
|
+
"""
|
|
380
|
+
conn = self._get_conn()
|
|
381
|
+
with self._lock:
|
|
382
|
+
try:
|
|
383
|
+
conn.execute("BEGIN")
|
|
384
|
+
conn.execute("DELETE FROM pipeline_info WHERE experiment_accession = ?", (accession,))
|
|
385
|
+
count = 0
|
|
386
|
+
for p in pipelines:
|
|
387
|
+
conn.execute(
|
|
388
|
+
"""
|
|
389
|
+
INSERT INTO pipeline_info
|
|
390
|
+
(experiment_accession, pipeline_title, pipeline_version, software_list, analysis_status)
|
|
391
|
+
VALUES (?, ?, ?, ?, ?)
|
|
392
|
+
""",
|
|
393
|
+
(
|
|
394
|
+
accession,
|
|
395
|
+
p.get("title", ""),
|
|
396
|
+
p.get("version", ""),
|
|
397
|
+
json.dumps(p.get("software", [])),
|
|
398
|
+
p.get("status", ""),
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
count += 1
|
|
402
|
+
conn.commit()
|
|
403
|
+
return count
|
|
404
|
+
except Exception:
|
|
405
|
+
conn.rollback()
|
|
406
|
+
raise
|
|
407
|
+
|
|
408
|
+
def get_pipeline_info(self, accession: str) -> list[dict]:
|
|
409
|
+
"""Get pipeline info for a tracked experiment."""
|
|
410
|
+
conn = self._get_conn()
|
|
411
|
+
rows = conn.execute(
|
|
412
|
+
"SELECT * FROM pipeline_info WHERE experiment_accession = ?",
|
|
413
|
+
(accession,),
|
|
414
|
+
).fetchall()
|
|
415
|
+
result = []
|
|
416
|
+
for r in rows:
|
|
417
|
+
d = dict(r)
|
|
418
|
+
d["software_list"] = json.loads(d.get("software_list", "[]"))
|
|
419
|
+
result.append(d)
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
# Quality metrics
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def store_quality_metrics(self, accession: str, metrics: list[dict]) -> int:
|
|
427
|
+
"""Store quality metrics for an experiment.
|
|
428
|
+
|
|
429
|
+
Uses an explicit transaction so the DELETE + INSERTs are atomic.
|
|
430
|
+
If any INSERT fails, the existing data is preserved (not erased).
|
|
431
|
+
Holds self._lock for the entire transaction to prevent concurrent
|
|
432
|
+
BEGIN on the same connection (which raises OperationalError).
|
|
433
|
+
"""
|
|
434
|
+
conn = self._get_conn()
|
|
435
|
+
with self._lock:
|
|
436
|
+
try:
|
|
437
|
+
conn.execute("BEGIN")
|
|
438
|
+
conn.execute("DELETE FROM quality_metrics WHERE experiment_accession = ?", (accession,))
|
|
439
|
+
count = 0
|
|
440
|
+
for m in metrics:
|
|
441
|
+
conn.execute(
|
|
442
|
+
"""
|
|
443
|
+
INSERT INTO quality_metrics
|
|
444
|
+
(experiment_accession, file_accession, metric_type, metric_data)
|
|
445
|
+
VALUES (?, ?, ?, ?)
|
|
446
|
+
""",
|
|
447
|
+
(
|
|
448
|
+
accession,
|
|
449
|
+
m.get("file_accession", ""),
|
|
450
|
+
m.get("metric_type", ""),
|
|
451
|
+
json.dumps(m.get("data", {})),
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
count += 1
|
|
455
|
+
conn.commit()
|
|
456
|
+
return count
|
|
457
|
+
except Exception:
|
|
458
|
+
conn.rollback()
|
|
459
|
+
raise
|
|
460
|
+
|
|
461
|
+
def get_quality_metrics(self, accession: str) -> list[dict]:
|
|
462
|
+
"""Get quality metrics for a tracked experiment."""
|
|
463
|
+
conn = self._get_conn()
|
|
464
|
+
rows = conn.execute(
|
|
465
|
+
"SELECT * FROM quality_metrics WHERE experiment_accession = ?",
|
|
466
|
+
(accession,),
|
|
467
|
+
).fetchall()
|
|
468
|
+
result = []
|
|
469
|
+
for r in rows:
|
|
470
|
+
d = dict(r)
|
|
471
|
+
d["metric_data"] = json.loads(d.get("metric_data", "{}"))
|
|
472
|
+
result.append(d)
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
# Provenance / derived files
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def log_derived_file(
|
|
480
|
+
self,
|
|
481
|
+
file_path: str,
|
|
482
|
+
source_accessions: list[str],
|
|
483
|
+
description: str = "",
|
|
484
|
+
file_type: str = "",
|
|
485
|
+
tool_used: str = "",
|
|
486
|
+
parameters: str = "",
|
|
487
|
+
) -> int:
|
|
488
|
+
"""Log a file derived from ENCODE data. Returns the row ID."""
|
|
489
|
+
conn = self._get_conn()
|
|
490
|
+
cursor = conn.execute(
|
|
491
|
+
"""
|
|
492
|
+
INSERT INTO derived_files
|
|
493
|
+
(file_path, source_accessions, description, created_at, file_type, tool_used, parameters)
|
|
494
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
495
|
+
""",
|
|
496
|
+
(
|
|
497
|
+
file_path,
|
|
498
|
+
json.dumps(source_accessions),
|
|
499
|
+
description,
|
|
500
|
+
time.time(),
|
|
501
|
+
file_type,
|
|
502
|
+
tool_used,
|
|
503
|
+
parameters,
|
|
504
|
+
),
|
|
505
|
+
)
|
|
506
|
+
conn.commit()
|
|
507
|
+
return cursor.lastrowid # type: ignore
|
|
508
|
+
|
|
509
|
+
def get_derived_files(self, source_accession: str | None = None) -> list[dict]:
|
|
510
|
+
"""Get derived files, optionally filtered by source accession."""
|
|
511
|
+
conn = self._get_conn()
|
|
512
|
+
if source_accession:
|
|
513
|
+
rows = conn.execute(
|
|
514
|
+
"SELECT * FROM derived_files WHERE source_accessions LIKE ? ESCAPE '\\' ORDER BY created_at DESC",
|
|
515
|
+
(f"%{escape_like(source_accession)}%",),
|
|
516
|
+
).fetchall()
|
|
517
|
+
else:
|
|
518
|
+
rows = conn.execute(
|
|
519
|
+
"SELECT * FROM derived_files ORDER BY created_at DESC",
|
|
520
|
+
).fetchall()
|
|
521
|
+
result = []
|
|
522
|
+
for r in rows:
|
|
523
|
+
d = dict(r)
|
|
524
|
+
d["source_accessions"] = json.loads(d.get("source_accessions", "[]"))
|
|
525
|
+
result.append(d)
|
|
526
|
+
return result
|
|
527
|
+
|
|
528
|
+
def get_provenance_chain(self, file_path: str) -> dict:
|
|
529
|
+
"""Get full provenance chain for a derived file."""
|
|
530
|
+
conn = self._get_conn()
|
|
531
|
+
row = conn.execute(
|
|
532
|
+
"SELECT * FROM derived_files WHERE file_path = ?",
|
|
533
|
+
(file_path,),
|
|
534
|
+
).fetchone()
|
|
535
|
+
if not row:
|
|
536
|
+
return {"error": f"No provenance record for {file_path}"}
|
|
537
|
+
d = dict(row)
|
|
538
|
+
d["source_accessions"] = json.loads(d.get("source_accessions", "[]"))
|
|
539
|
+
|
|
540
|
+
# Get info about source experiments
|
|
541
|
+
sources = []
|
|
542
|
+
for acc in d["source_accessions"]:
|
|
543
|
+
exp = self.get_tracked_experiment(acc)
|
|
544
|
+
if exp:
|
|
545
|
+
sources.append(
|
|
546
|
+
{
|
|
547
|
+
"accession": acc,
|
|
548
|
+
"assay_title": exp.get("assay_title", ""),
|
|
549
|
+
"biosample_summary": exp.get("biosample_summary", ""),
|
|
550
|
+
"organism": exp.get("organism", ""),
|
|
551
|
+
}
|
|
552
|
+
)
|
|
553
|
+
else:
|
|
554
|
+
sources.append({"accession": acc, "tracked": False})
|
|
555
|
+
|
|
556
|
+
d["source_experiments"] = sources
|
|
557
|
+
return d
|
|
558
|
+
|
|
559
|
+
# ------------------------------------------------------------------
|
|
560
|
+
# Compatibility analysis
|
|
561
|
+
# ------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
def analyze_compatibility(self, accession1: str, accession2: str) -> dict:
|
|
564
|
+
"""Analyze whether two experiments are compatible for combined analysis."""
|
|
565
|
+
exp1 = self.get_tracked_experiment(accession1)
|
|
566
|
+
exp2 = self.get_tracked_experiment(accession2)
|
|
567
|
+
|
|
568
|
+
if not exp1:
|
|
569
|
+
return {"error": f"Experiment {accession1} not tracked. Track it first."}
|
|
570
|
+
if not exp2:
|
|
571
|
+
return {"error": f"Experiment {accession2} not tracked. Track it first."}
|
|
572
|
+
|
|
573
|
+
issues: list[str] = []
|
|
574
|
+
warnings: list[str] = []
|
|
575
|
+
compatible_aspects: list[str] = []
|
|
576
|
+
|
|
577
|
+
# Check organism
|
|
578
|
+
if exp1.get("organism") and exp2.get("organism"):
|
|
579
|
+
if exp1["organism"] != exp2["organism"]:
|
|
580
|
+
issues.append(
|
|
581
|
+
f"Different organisms: {exp1['organism']} vs {exp2['organism']}. "
|
|
582
|
+
"Cross-species comparison requires ortholog mapping."
|
|
583
|
+
)
|
|
584
|
+
else:
|
|
585
|
+
compatible_aspects.append(f"Same organism: {exp1['organism']}")
|
|
586
|
+
|
|
587
|
+
# Check assembly
|
|
588
|
+
if exp1.get("assembly") and exp2.get("assembly"):
|
|
589
|
+
if exp1["assembly"] != exp2["assembly"]:
|
|
590
|
+
issues.append(
|
|
591
|
+
f"Different genome assemblies: {exp1['assembly']} vs {exp2['assembly']}. "
|
|
592
|
+
"Coordinate liftover needed before comparison."
|
|
593
|
+
)
|
|
594
|
+
else:
|
|
595
|
+
compatible_aspects.append(f"Same assembly: {exp1['assembly']}")
|
|
596
|
+
|
|
597
|
+
# Check assay type
|
|
598
|
+
if exp1.get("assay_title") and exp2.get("assay_title"):
|
|
599
|
+
if exp1["assay_title"] != exp2["assay_title"]:
|
|
600
|
+
warnings.append(
|
|
601
|
+
f"Different assay types: {exp1['assay_title']} vs {exp2['assay_title']}. "
|
|
602
|
+
"Multi-omic integration may be needed."
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
compatible_aspects.append(f"Same assay: {exp1['assay_title']}")
|
|
606
|
+
|
|
607
|
+
# Check biosample type
|
|
608
|
+
if exp1.get("biosample_type") and exp2.get("biosample_type"):
|
|
609
|
+
if exp1["biosample_type"] != exp2["biosample_type"]:
|
|
610
|
+
warnings.append(
|
|
611
|
+
f"Different biosample types: {exp1['biosample_type']} vs {exp2['biosample_type']}. "
|
|
612
|
+
"Results may reflect sample type differences."
|
|
613
|
+
)
|
|
614
|
+
else:
|
|
615
|
+
compatible_aspects.append(f"Same biosample type: {exp1['biosample_type']}")
|
|
616
|
+
|
|
617
|
+
# Check organ
|
|
618
|
+
if exp1.get("organ") and exp2.get("organ"):
|
|
619
|
+
if exp1["organ"] != exp2["organ"]:
|
|
620
|
+
warnings.append(f"Different organs/tissues: {exp1['organ']} vs {exp2['organ']}.")
|
|
621
|
+
else:
|
|
622
|
+
compatible_aspects.append(f"Same organ: {exp1['organ']}")
|
|
623
|
+
|
|
624
|
+
# Check target (for ChIP-seq)
|
|
625
|
+
if exp1.get("target") or exp2.get("target"):
|
|
626
|
+
if exp1.get("target") != exp2.get("target"):
|
|
627
|
+
if exp1.get("target") and exp2.get("target"):
|
|
628
|
+
warnings.append(f"Different targets: {exp1['target']} vs {exp2['target']}.")
|
|
629
|
+
else:
|
|
630
|
+
compatible_aspects.append(f"Same target: {exp1['target']}")
|
|
631
|
+
|
|
632
|
+
# Check replication type
|
|
633
|
+
if exp1.get("replication_type") and exp2.get("replication_type"):
|
|
634
|
+
if exp1["replication_type"] != exp2["replication_type"]:
|
|
635
|
+
warnings.append(f"Different replication: {exp1['replication_type']} vs {exp2['replication_type']}.")
|
|
636
|
+
|
|
637
|
+
# Check lab
|
|
638
|
+
if exp1.get("lab") and exp2.get("lab"):
|
|
639
|
+
if exp1["lab"] != exp2["lab"]:
|
|
640
|
+
warnings.append(f"Different labs: {exp1['lab']} vs {exp2['lab']}. Batch effects possible.")
|
|
641
|
+
else:
|
|
642
|
+
compatible_aspects.append(f"Same lab: {exp1['lab']}")
|
|
643
|
+
|
|
644
|
+
# Determine overall compatibility
|
|
645
|
+
if issues:
|
|
646
|
+
verdict = "NOT_COMPATIBLE"
|
|
647
|
+
recommendation = (
|
|
648
|
+
"These experiments have fundamental incompatibilities that must be resolved before combined analysis."
|
|
649
|
+
)
|
|
650
|
+
elif warnings:
|
|
651
|
+
verdict = "COMPATIBLE_WITH_CAVEATS"
|
|
652
|
+
recommendation = "These experiments can be compared, but the warnings should be addressed in your analysis."
|
|
653
|
+
else:
|
|
654
|
+
verdict = "FULLY_COMPATIBLE"
|
|
655
|
+
recommendation = "These experiments appear fully compatible for combined analysis."
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
"experiment_1": {
|
|
659
|
+
"accession": accession1,
|
|
660
|
+
"assay": exp1.get("assay_title", ""),
|
|
661
|
+
"biosample": exp1.get("biosample_summary", ""),
|
|
662
|
+
},
|
|
663
|
+
"experiment_2": {
|
|
664
|
+
"accession": accession2,
|
|
665
|
+
"assay": exp2.get("assay_title", ""),
|
|
666
|
+
"biosample": exp2.get("biosample_summary", ""),
|
|
667
|
+
},
|
|
668
|
+
"verdict": verdict,
|
|
669
|
+
"recommendation": recommendation,
|
|
670
|
+
"compatible_aspects": compatible_aspects,
|
|
671
|
+
"issues": issues,
|
|
672
|
+
"warnings": warnings,
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
# ------------------------------------------------------------------
|
|
676
|
+
# Citation export
|
|
677
|
+
# ------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
def export_citations_bibtex(self, accessions: list[str] | None = None) -> str:
|
|
680
|
+
"""Export publications as BibTeX format."""
|
|
681
|
+
conn = self._get_conn()
|
|
682
|
+
if accessions:
|
|
683
|
+
placeholders = ",".join("?" for _ in accessions)
|
|
684
|
+
rows = conn.execute(
|
|
685
|
+
f"SELECT * FROM publications WHERE experiment_accession IN ({placeholders})",
|
|
686
|
+
accessions,
|
|
687
|
+
).fetchall()
|
|
688
|
+
else:
|
|
689
|
+
rows = conn.execute("SELECT * FROM publications").fetchall()
|
|
690
|
+
|
|
691
|
+
entries = []
|
|
692
|
+
for r in rows:
|
|
693
|
+
pub = dict(r)
|
|
694
|
+
key = pub.get("pmid", "") or pub.get("doi", "") or f"encode_{pub.get('experiment_accession', '')}"
|
|
695
|
+
key = key.replace("/", "_").replace(".", "_")
|
|
696
|
+
|
|
697
|
+
entry = f"@article{{{key},\n"
|
|
698
|
+
if pub.get("title"):
|
|
699
|
+
entry += f" title = {{{pub['title']}}},\n"
|
|
700
|
+
if pub.get("authors"):
|
|
701
|
+
entry += f" author = {{{pub['authors']}}},\n"
|
|
702
|
+
if pub.get("journal"):
|
|
703
|
+
entry += f" journal = {{{pub['journal']}}},\n"
|
|
704
|
+
if pub.get("year"):
|
|
705
|
+
entry += f" year = {{{pub['year']}}},\n"
|
|
706
|
+
if pub.get("doi"):
|
|
707
|
+
entry += f" doi = {{{pub['doi']}}},\n"
|
|
708
|
+
if pub.get("pmid"):
|
|
709
|
+
entry += f" pmid = {{{pub['pmid']}}},\n"
|
|
710
|
+
entry += f" note = {{ENCODE experiment: {pub.get('experiment_accession', '')}}},\n"
|
|
711
|
+
entry += "}"
|
|
712
|
+
entries.append(entry)
|
|
713
|
+
|
|
714
|
+
return "\n\n".join(entries)
|
|
715
|
+
|
|
716
|
+
def export_citations_ris(self, accessions: list[str] | None = None) -> str:
|
|
717
|
+
"""Export publications as RIS format (compatible with Endnote, Zotero, Mendeley)."""
|
|
718
|
+
conn = self._get_conn()
|
|
719
|
+
if accessions:
|
|
720
|
+
placeholders = ",".join("?" for _ in accessions)
|
|
721
|
+
rows = conn.execute(
|
|
722
|
+
f"SELECT * FROM publications WHERE experiment_accession IN ({placeholders})",
|
|
723
|
+
accessions,
|
|
724
|
+
).fetchall()
|
|
725
|
+
else:
|
|
726
|
+
rows = conn.execute("SELECT * FROM publications").fetchall()
|
|
727
|
+
|
|
728
|
+
entries = []
|
|
729
|
+
for r in rows:
|
|
730
|
+
pub = dict(r)
|
|
731
|
+
lines = ["TY - JOUR"]
|
|
732
|
+
if pub.get("title"):
|
|
733
|
+
lines.append(f"TI - {pub['title']}")
|
|
734
|
+
if pub.get("authors"):
|
|
735
|
+
for author in pub["authors"].split(", "):
|
|
736
|
+
lines.append(f"AU - {author}")
|
|
737
|
+
if pub.get("journal"):
|
|
738
|
+
lines.append(f"JO - {pub['journal']}")
|
|
739
|
+
if pub.get("year"):
|
|
740
|
+
lines.append(f"PY - {pub['year']}")
|
|
741
|
+
if pub.get("doi"):
|
|
742
|
+
lines.append(f"DO - {pub['doi']}")
|
|
743
|
+
if pub.get("pmid"):
|
|
744
|
+
lines.append(f"AN - PMID:{pub['pmid']}")
|
|
745
|
+
lines.append(f"N1 - ENCODE experiment: {pub.get('experiment_accession', '')}")
|
|
746
|
+
if pub.get("abstract"):
|
|
747
|
+
lines.append(f"AB - {pub['abstract']}")
|
|
748
|
+
lines.append("ER - ")
|
|
749
|
+
entries.append("\n".join(lines))
|
|
750
|
+
|
|
751
|
+
return "\n\n".join(entries)
|
|
752
|
+
|
|
753
|
+
# ------------------------------------------------------------------
|
|
754
|
+
# Metadata table export
|
|
755
|
+
# ------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
def get_metadata_table(self, accessions: list[str] | None = None) -> list[dict]:
|
|
758
|
+
"""Get a metadata table of tracked experiments for analysis."""
|
|
759
|
+
conn = self._get_conn()
|
|
760
|
+
if accessions:
|
|
761
|
+
placeholders = ",".join("?" for _ in accessions)
|
|
762
|
+
rows = conn.execute(
|
|
763
|
+
f"SELECT * FROM tracked_experiments WHERE accession IN ({placeholders}) ORDER BY tracked_at DESC",
|
|
764
|
+
accessions,
|
|
765
|
+
).fetchall()
|
|
766
|
+
else:
|
|
767
|
+
rows = conn.execute(
|
|
768
|
+
"SELECT * FROM tracked_experiments ORDER BY tracked_at DESC",
|
|
769
|
+
).fetchall()
|
|
770
|
+
|
|
771
|
+
table = []
|
|
772
|
+
for r in rows:
|
|
773
|
+
d = dict(r)
|
|
774
|
+
# Remove raw_metadata from table view (too large)
|
|
775
|
+
d.pop("raw_metadata", None)
|
|
776
|
+
# Add publication count
|
|
777
|
+
pub_count = conn.execute(
|
|
778
|
+
"SELECT COUNT(*) FROM publications WHERE experiment_accession = ?",
|
|
779
|
+
(d["accession"],),
|
|
780
|
+
).fetchone()[0]
|
|
781
|
+
d["publication_count"] = pub_count
|
|
782
|
+
# Add derived file count
|
|
783
|
+
derived_count = conn.execute(
|
|
784
|
+
"SELECT COUNT(*) FROM derived_files WHERE source_accessions LIKE ? ESCAPE '\\'",
|
|
785
|
+
(f"%{escape_like(d['accession'])}%",),
|
|
786
|
+
).fetchone()[0]
|
|
787
|
+
d["derived_file_count"] = derived_count
|
|
788
|
+
table.append(d)
|
|
789
|
+
|
|
790
|
+
return table
|
|
791
|
+
|
|
792
|
+
# ------------------------------------------------------------------
|
|
793
|
+
# External references (cross-server linking)
|
|
794
|
+
# ------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
def link_reference(
|
|
797
|
+
self,
|
|
798
|
+
accession: str,
|
|
799
|
+
reference_type: str,
|
|
800
|
+
reference_id: str,
|
|
801
|
+
description: str = "",
|
|
802
|
+
) -> dict:
|
|
803
|
+
"""Link an external reference to a tracked experiment."""
|
|
804
|
+
validate_reference_type(reference_type)
|
|
805
|
+
conn = self._get_conn()
|
|
806
|
+
|
|
807
|
+
# Check experiment is tracked
|
|
808
|
+
exp = conn.execute(
|
|
809
|
+
"SELECT accession FROM tracked_experiments WHERE accession = ?",
|
|
810
|
+
(accession,),
|
|
811
|
+
).fetchone()
|
|
812
|
+
if not exp:
|
|
813
|
+
return {"error": f"Experiment {accession} not tracked. Track it first."}
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
conn.execute(
|
|
817
|
+
"""
|
|
818
|
+
INSERT INTO external_references
|
|
819
|
+
(experiment_accession, reference_type, reference_id, description, linked_at)
|
|
820
|
+
VALUES (?, ?, ?, ?, ?)
|
|
821
|
+
""",
|
|
822
|
+
(accession, reference_type, reference_id, description, time.time()),
|
|
823
|
+
)
|
|
824
|
+
conn.commit()
|
|
825
|
+
return {
|
|
826
|
+
"action": "linked",
|
|
827
|
+
"experiment_accession": accession,
|
|
828
|
+
"reference_type": reference_type,
|
|
829
|
+
"reference_id": reference_id,
|
|
830
|
+
}
|
|
831
|
+
except sqlite3.IntegrityError:
|
|
832
|
+
return {
|
|
833
|
+
"action": "already_linked",
|
|
834
|
+
"experiment_accession": accession,
|
|
835
|
+
"reference_type": reference_type,
|
|
836
|
+
"reference_id": reference_id,
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
def get_references(
|
|
840
|
+
self,
|
|
841
|
+
accession: str | None = None,
|
|
842
|
+
reference_type: str | None = None,
|
|
843
|
+
) -> list[dict]:
|
|
844
|
+
"""Get external references, optionally filtered by experiment or type."""
|
|
845
|
+
conn = self._get_conn()
|
|
846
|
+
query = "SELECT * FROM external_references WHERE 1=1"
|
|
847
|
+
params: list[Any] = []
|
|
848
|
+
|
|
849
|
+
if accession:
|
|
850
|
+
query += " AND experiment_accession = ?"
|
|
851
|
+
params.append(accession)
|
|
852
|
+
if reference_type:
|
|
853
|
+
validate_reference_type(reference_type)
|
|
854
|
+
query += " AND reference_type = ?"
|
|
855
|
+
params.append(reference_type)
|
|
856
|
+
|
|
857
|
+
query += " ORDER BY linked_at DESC"
|
|
858
|
+
rows = conn.execute(query, params).fetchall()
|
|
859
|
+
return [dict(r) for r in rows]
|
|
860
|
+
|
|
861
|
+
def unlink_reference(
|
|
862
|
+
self,
|
|
863
|
+
accession: str,
|
|
864
|
+
reference_type: str,
|
|
865
|
+
reference_id: str,
|
|
866
|
+
) -> bool:
|
|
867
|
+
"""Remove an external reference link."""
|
|
868
|
+
conn = self._get_conn()
|
|
869
|
+
result = conn.execute(
|
|
870
|
+
"DELETE FROM external_references WHERE experiment_accession = ? AND reference_type = ? AND reference_id = ?",
|
|
871
|
+
(accession, reference_type, reference_id),
|
|
872
|
+
)
|
|
873
|
+
conn.commit()
|
|
874
|
+
return result.rowcount > 0
|
|
875
|
+
|
|
876
|
+
# ------------------------------------------------------------------
|
|
877
|
+
# Data export (CSV/TSV/JSON)
|
|
878
|
+
# ------------------------------------------------------------------
|
|
879
|
+
|
|
880
|
+
def export_tracked_data(
|
|
881
|
+
self,
|
|
882
|
+
format: str = "csv",
|
|
883
|
+
assay_title: str | None = None,
|
|
884
|
+
organism: str | None = None,
|
|
885
|
+
organ: str | None = None,
|
|
886
|
+
) -> str:
|
|
887
|
+
"""Export tracked experiments as CSV, TSV, or JSON."""
|
|
888
|
+
experiments = self.list_tracked_experiments(
|
|
889
|
+
assay_title=assay_title,
|
|
890
|
+
organism=organism,
|
|
891
|
+
organ=organ,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
table = self.get_metadata_table([e["accession"] for e in experiments] if experiments else None)
|
|
895
|
+
|
|
896
|
+
# Enrich with external reference counts and PMIDs
|
|
897
|
+
conn = self._get_conn()
|
|
898
|
+
for row in table:
|
|
899
|
+
row.pop("raw_metadata", None)
|
|
900
|
+
# Get PMIDs from publications
|
|
901
|
+
pmids = conn.execute(
|
|
902
|
+
"SELECT pmid FROM publications WHERE experiment_accession = ? AND pmid != ''",
|
|
903
|
+
(row["accession"],),
|
|
904
|
+
).fetchall()
|
|
905
|
+
row["pmids"] = ";".join(r[0] for r in pmids) if pmids else ""
|
|
906
|
+
# Get external reference count
|
|
907
|
+
ref_count = conn.execute(
|
|
908
|
+
"SELECT COUNT(*) FROM external_references WHERE experiment_accession = ?",
|
|
909
|
+
(row["accession"],),
|
|
910
|
+
).fetchone()[0]
|
|
911
|
+
row["external_reference_count"] = ref_count
|
|
912
|
+
|
|
913
|
+
if not table:
|
|
914
|
+
if format == "json":
|
|
915
|
+
return "[]"
|
|
916
|
+
return ""
|
|
917
|
+
|
|
918
|
+
if format == "json":
|
|
919
|
+
return json.dumps(table, indent=2, default=str)
|
|
920
|
+
|
|
921
|
+
# CSV / TSV
|
|
922
|
+
sep = "," if format == "csv" else "\t"
|
|
923
|
+
headers = [
|
|
924
|
+
"accession",
|
|
925
|
+
"assay_title",
|
|
926
|
+
"target",
|
|
927
|
+
"organism",
|
|
928
|
+
"organ",
|
|
929
|
+
"biosample_type",
|
|
930
|
+
"biosample_summary",
|
|
931
|
+
"lab",
|
|
932
|
+
"assembly",
|
|
933
|
+
"status",
|
|
934
|
+
"date_released",
|
|
935
|
+
"replication_type",
|
|
936
|
+
"life_stage",
|
|
937
|
+
"publication_count",
|
|
938
|
+
"pmids",
|
|
939
|
+
"derived_file_count",
|
|
940
|
+
"external_reference_count",
|
|
941
|
+
]
|
|
942
|
+
lines = [sep.join(headers)]
|
|
943
|
+
for row in table:
|
|
944
|
+
values = []
|
|
945
|
+
for h in headers:
|
|
946
|
+
val = str(row.get(h, ""))
|
|
947
|
+
# Escape separators in values for CSV
|
|
948
|
+
if sep in val or '"' in val:
|
|
949
|
+
val = '"' + val.replace('"', '""') + '"'
|
|
950
|
+
values.append(val)
|
|
951
|
+
lines.append(sep.join(values))
|
|
952
|
+
return "\n".join(lines)
|
|
953
|
+
|
|
954
|
+
# ------------------------------------------------------------------
|
|
955
|
+
# Collection summary
|
|
956
|
+
# ------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
def summarize_collection(
|
|
959
|
+
self,
|
|
960
|
+
assay_title: str | None = None,
|
|
961
|
+
organism: str | None = None,
|
|
962
|
+
organ: str | None = None,
|
|
963
|
+
) -> dict:
|
|
964
|
+
"""Summarize tracked experiments by various groupings."""
|
|
965
|
+
experiments = self.list_tracked_experiments(
|
|
966
|
+
assay_title=assay_title,
|
|
967
|
+
organism=organism,
|
|
968
|
+
organ=organ,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
if not experiments:
|
|
972
|
+
return {
|
|
973
|
+
"total_experiments": 0,
|
|
974
|
+
"message": "No tracked experiments found matching filters.",
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
conn = self._get_conn()
|
|
978
|
+
|
|
979
|
+
# Group by various fields
|
|
980
|
+
by_assay: dict[str, int] = {}
|
|
981
|
+
by_target: dict[str, int] = {}
|
|
982
|
+
by_organism: dict[str, int] = {}
|
|
983
|
+
by_organ: dict[str, int] = {}
|
|
984
|
+
by_biosample_type: dict[str, int] = {}
|
|
985
|
+
by_lab: dict[str, int] = {}
|
|
986
|
+
|
|
987
|
+
for exp in experiments:
|
|
988
|
+
assay = exp.get("assay_title", "") or "unknown"
|
|
989
|
+
by_assay[assay] = by_assay.get(assay, 0) + 1
|
|
990
|
+
|
|
991
|
+
target_val = exp.get("target", "") or "none"
|
|
992
|
+
by_target[target_val] = by_target.get(target_val, 0) + 1
|
|
993
|
+
|
|
994
|
+
org = exp.get("organism", "") or "unknown"
|
|
995
|
+
by_organism[org] = by_organism.get(org, 0) + 1
|
|
996
|
+
|
|
997
|
+
organ_val = exp.get("organ", "") or "unknown"
|
|
998
|
+
by_organ[organ_val] = by_organ.get(organ_val, 0) + 1
|
|
999
|
+
|
|
1000
|
+
btype = exp.get("biosample_type", "") or "unknown"
|
|
1001
|
+
by_biosample_type[btype] = by_biosample_type.get(btype, 0) + 1
|
|
1002
|
+
|
|
1003
|
+
lab_val = exp.get("lab", "") or "unknown"
|
|
1004
|
+
by_lab[lab_val] = by_lab.get(lab_val, 0) + 1
|
|
1005
|
+
|
|
1006
|
+
# Totals scoped to the filtered experiments
|
|
1007
|
+
if experiments:
|
|
1008
|
+
accessions = [exp.get("accession", "") for exp in experiments]
|
|
1009
|
+
placeholders = ",".join("?" * len(accessions))
|
|
1010
|
+
total_pubs = conn.execute(
|
|
1011
|
+
f"SELECT COUNT(*) FROM publications WHERE experiment_accession IN ({placeholders})",
|
|
1012
|
+
accessions,
|
|
1013
|
+
).fetchone()[0]
|
|
1014
|
+
# derived_files stores source_accessions as a JSON array, so use LIKE matching
|
|
1015
|
+
like_clauses = " OR ".join("source_accessions LIKE ? ESCAPE '\\'" for _ in accessions)
|
|
1016
|
+
like_params = [f"%{escape_like(acc)}%" for acc in accessions]
|
|
1017
|
+
total_derived = conn.execute(
|
|
1018
|
+
f"SELECT COUNT(*) FROM derived_files WHERE {like_clauses}",
|
|
1019
|
+
like_params,
|
|
1020
|
+
).fetchone()[0]
|
|
1021
|
+
total_refs = conn.execute(
|
|
1022
|
+
f"SELECT COUNT(*) FROM external_references WHERE experiment_accession IN ({placeholders})",
|
|
1023
|
+
accessions,
|
|
1024
|
+
).fetchone()[0]
|
|
1025
|
+
else:
|
|
1026
|
+
total_pubs = total_derived = total_refs = 0
|
|
1027
|
+
|
|
1028
|
+
return {
|
|
1029
|
+
"total_experiments": len(experiments),
|
|
1030
|
+
"total_publications": total_pubs,
|
|
1031
|
+
"total_derived_files": total_derived,
|
|
1032
|
+
"total_external_references": total_refs,
|
|
1033
|
+
"by_assay": dict(sorted(by_assay.items(), key=lambda x: -x[1])),
|
|
1034
|
+
"by_target": dict(sorted(by_target.items(), key=lambda x: -x[1])[:20]),
|
|
1035
|
+
"by_organism": dict(sorted(by_organism.items(), key=lambda x: -x[1])),
|
|
1036
|
+
"by_organ": dict(sorted(by_organ.items(), key=lambda x: -x[1])[:20]),
|
|
1037
|
+
"by_biosample_type": dict(sorted(by_biosample_type.items(), key=lambda x: -x[1])),
|
|
1038
|
+
"by_lab": dict(sorted(by_lab.items(), key=lambda x: -x[1])[:20]),
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
@property
|
|
1042
|
+
def db_path(self) -> str:
|
|
1043
|
+
return str(self._db_path)
|
|
1044
|
+
|
|
1045
|
+
@property
|
|
1046
|
+
def stats(self) -> dict:
|
|
1047
|
+
"""Get tracker statistics."""
|
|
1048
|
+
conn = self._get_conn()
|
|
1049
|
+
return {
|
|
1050
|
+
"tracked_experiments": conn.execute("SELECT COUNT(*) FROM tracked_experiments").fetchone()[0],
|
|
1051
|
+
"publications": conn.execute("SELECT COUNT(*) FROM publications").fetchone()[0],
|
|
1052
|
+
"pipeline_records": conn.execute("SELECT COUNT(*) FROM pipeline_info").fetchone()[0],
|
|
1053
|
+
"quality_metrics": conn.execute("SELECT COUNT(*) FROM quality_metrics").fetchone()[0],
|
|
1054
|
+
"derived_files": conn.execute("SELECT COUNT(*) FROM derived_files").fetchone()[0],
|
|
1055
|
+
"external_references": conn.execute("SELECT COUNT(*) FROM external_references").fetchone()[0],
|
|
1056
|
+
"db_path": str(self._db_path),
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def parse_encode_publications(references: list[dict]) -> list[dict]:
|
|
1061
|
+
"""Parse ENCODE API references array into publication records."""
|
|
1062
|
+
pubs = []
|
|
1063
|
+
for ref in references:
|
|
1064
|
+
if isinstance(ref, str):
|
|
1065
|
+
continue # Just a path reference, skip
|
|
1066
|
+
if not isinstance(ref, dict):
|
|
1067
|
+
continue
|
|
1068
|
+
|
|
1069
|
+
# Extract identifiers
|
|
1070
|
+
identifiers = ref.get("identifiers", [])
|
|
1071
|
+
pmid = ""
|
|
1072
|
+
doi = ""
|
|
1073
|
+
for ident in identifiers:
|
|
1074
|
+
if isinstance(ident, str):
|
|
1075
|
+
if ident.startswith("PMID:"):
|
|
1076
|
+
pmid = ident.replace("PMID:", "")
|
|
1077
|
+
elif ident.startswith("doi:"):
|
|
1078
|
+
doi = ident.replace("doi:", "")
|
|
1079
|
+
|
|
1080
|
+
# Extract authors (limit to first 10; handle both string and list formats)
|
|
1081
|
+
authors_raw = ref.get("authors", "")
|
|
1082
|
+
if isinstance(authors_raw, list):
|
|
1083
|
+
authors = ", ".join(str(a) for a in authors_raw[:10])
|
|
1084
|
+
elif isinstance(authors_raw, str):
|
|
1085
|
+
authors = ", ".join(authors_raw.split(", ")[:10])
|
|
1086
|
+
else:
|
|
1087
|
+
authors = str(authors_raw)
|
|
1088
|
+
|
|
1089
|
+
pubs.append(
|
|
1090
|
+
{
|
|
1091
|
+
"pmid": pmid,
|
|
1092
|
+
"doi": doi,
|
|
1093
|
+
"title": ref.get("title", ""),
|
|
1094
|
+
"authors": authors,
|
|
1095
|
+
"journal": ref.get("journal", ""),
|
|
1096
|
+
"year": ref.get("date_published", "")[:4] if ref.get("date_published") else "",
|
|
1097
|
+
"abstract": ref.get("abstract", ""),
|
|
1098
|
+
}
|
|
1099
|
+
)
|
|
1100
|
+
return pubs
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def parse_encode_pipelines(analyses: list[dict]) -> list[dict]:
|
|
1104
|
+
"""Parse ENCODE API analyses array into pipeline records."""
|
|
1105
|
+
pipelines = []
|
|
1106
|
+
for analysis in analyses:
|
|
1107
|
+
if not isinstance(analysis, dict):
|
|
1108
|
+
continue
|
|
1109
|
+
|
|
1110
|
+
# Extract pipeline info
|
|
1111
|
+
software_list = []
|
|
1112
|
+
for sw in analysis.get("pipeline_run_software", []):
|
|
1113
|
+
if isinstance(sw, dict):
|
|
1114
|
+
software_list.append(
|
|
1115
|
+
{
|
|
1116
|
+
"name": sw.get("name", ""),
|
|
1117
|
+
"version": sw.get("version", ""),
|
|
1118
|
+
}
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
pipelines.append(
|
|
1122
|
+
{
|
|
1123
|
+
"title": analysis.get("title", analysis.get("pipeline_title", "")),
|
|
1124
|
+
"version": analysis.get("pipeline_version", ""),
|
|
1125
|
+
"software": software_list,
|
|
1126
|
+
"status": analysis.get("status", ""),
|
|
1127
|
+
}
|
|
1128
|
+
)
|
|
1129
|
+
return pipelines
|