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.
@@ -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