pen-stack 0.1.0__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.
- pen_stack/__init__.py +2 -0
- pen_stack/_resources.py +34 -0
- pen_stack/active/__init__.py +20 -0
- pen_stack/active/acquire.py +165 -0
- pen_stack/active/brains.py +74 -0
- pen_stack/active/campaign.py +109 -0
- pen_stack/active/design.py +66 -0
- pen_stack/active/validate.py +104 -0
- pen_stack/adapt/__init__.py +14 -0
- pen_stack/adapt/finetune.py +33 -0
- pen_stack/adapt/ingest.py +86 -0
- pen_stack/adapt/pipeline.py +101 -0
- pen_stack/adapt/recalibrate.py +58 -0
- pen_stack/adapt/report.py +130 -0
- pen_stack/agent/__init__.py +1 -0
- pen_stack/agent/cite.py +175 -0
- pen_stack/agent/co_scientist.py +262 -0
- pen_stack/agent/epistemic.py +102 -0
- pen_stack/agent/guardrails.py +67 -0
- pen_stack/agent/mcp_server.py +215 -0
- pen_stack/agent/orchestrator.py +112 -0
- pen_stack/agent/orchestrator_live.py +56 -0
- pen_stack/agent/pen_agent.py +242 -0
- pen_stack/agent/scope.py +60 -0
- pen_stack/agent/tools.py +130 -0
- pen_stack/api/__init__.py +12 -0
- pen_stack/api/manifest.py +160 -0
- pen_stack/atlas/__init__.py +1 -0
- pen_stack/atlas/atlas.parquet +0 -0
- pen_stack/atlas/build_wtkb.py +80 -0
- pen_stack/atlas/crosslink.py +179 -0
- pen_stack/atlas/expand.py +190 -0
- pen_stack/atlas/guide_design.py +178 -0
- pen_stack/atlas/schema.py +59 -0
- pen_stack/atlas/scorecard.py +134 -0
- pen_stack/atlas/scorecard_v3.parquet +0 -0
- pen_stack/atlas/universe.py +75 -0
- pen_stack/atlas/universe_v3.parquet +0 -0
- pen_stack/atlas/variant_propose.py +155 -0
- pen_stack/atlas/writer_efficiency.py +184 -0
- pen_stack/atlas/writer_predict.py +229 -0
- pen_stack/atlas/writer_recommend.py +170 -0
- pen_stack/atlas/writer_verify.py +167 -0
- pen_stack/atlas/wtkb.parquet +0 -0
- pen_stack/bridge/__init__.py +1 -0
- pen_stack/bridge/activity.py +52 -0
- pen_stack/bridge/cli.py +65 -0
- pen_stack/bridge/fold_qc.py +53 -0
- pen_stack/bridge/guide_qc.py +87 -0
- pen_stack/bridge/ingest.py +139 -0
- pen_stack/bridge/offtarget.py +191 -0
- pen_stack/bridge/offtarget_energetics.py +105 -0
- pen_stack/bridge/ortholog_screen.py +73 -0
- pen_stack/bridge/pipeline.py +83 -0
- pen_stack/build/__init__.py +16 -0
- pen_stack/build/cloudlab.py +74 -0
- pen_stack/build/ingest.py +47 -0
- pen_stack/build/protocol.py +82 -0
- pen_stack/build/simlab.py +30 -0
- pen_stack/cli.py +126 -0
- pen_stack/data/__init__.py +1 -0
- pen_stack/data/encode.py +84 -0
- pen_stack/data/genome.py +71 -0
- pen_stack/data/ingest_chromatin.py +119 -0
- pen_stack/data/ingest_integration.py +112 -0
- pen_stack/data/ingest_safety_annot.py +201 -0
- pen_stack/data/ingest_trip.py +76 -0
- pen_stack/design/__init__.py +14 -0
- pen_stack/design/capsid_generate.py +62 -0
- pen_stack/design/generate.py +70 -0
- pen_stack/design/pareto.py +70 -0
- pen_stack/design/space.py +137 -0
- pen_stack/design/writer_variants.py +121 -0
- pen_stack/env/__init__.py +1 -0
- pen_stack/env/genome_writing_env.py +248 -0
- pen_stack/env/policies.py +94 -0
- pen_stack/graph/__init__.py +21 -0
- pen_stack/graph/build.py +133 -0
- pen_stack/graph/cell_types.py +58 -0
- pen_stack/graph/ingest.py +132 -0
- pen_stack/graph/query.py +148 -0
- pen_stack/graph/schema.py +100 -0
- pen_stack/loop/__init__.py +15 -0
- pen_stack/loop/continual.py +61 -0
- pen_stack/loop/cycle.py +84 -0
- pen_stack/loop/drift.py +41 -0
- pen_stack/mech/__init__.py +1 -0
- pen_stack/mech/classify_atlas.py +71 -0
- pen_stack/mech/pfam_whitelist.yaml +247 -0
- pen_stack/mech/whitelist.py +66 -0
- pen_stack/monitor/__init__.py +1 -0
- pen_stack/monitor/europepmc.py +32 -0
- pen_stack/monitor/run.py +57 -0
- pen_stack/monitor/triage.py +63 -0
- pen_stack/oracles/__init__.py +65 -0
- pen_stack/oracles/affinity.py +116 -0
- pen_stack/oracles/cache.py +53 -0
- pen_stack/oracles/energetics.py +33 -0
- pen_stack/oracles/genome.py +167 -0
- pen_stack/oracles/protein_design.py +136 -0
- pen_stack/oracles/reliability.py +64 -0
- pen_stack/oracles/rna.py +28 -0
- pen_stack/oracles/schema.py +77 -0
- pen_stack/oracles/status.py +123 -0
- pen_stack/oracles/structure.py +42 -0
- pen_stack/oracles/structure_run.py +76 -0
- pen_stack/oracles/vcell.py +74 -0
- pen_stack/planner/__init__.py +1 -0
- pen_stack/planner/ada_risk.py +64 -0
- pen_stack/planner/antipeg_oracle.py +75 -0
- pen_stack/planner/capsid_epitope_oracle.py +135 -0
- pen_stack/planner/cargo.py +56 -0
- pen_stack/planner/cargo_polish.py +146 -0
- pen_stack/planner/chromosome.py +106 -0
- pen_stack/planner/delivery.py +55 -0
- pen_stack/planner/delivery_constraints.py +110 -0
- pen_stack/planner/delivery_immune.py +61 -0
- pen_stack/planner/delivery_immunology.py +222 -0
- pen_stack/planner/delivery_predict.py +196 -0
- pen_stack/planner/delivery_vehicles.py +37 -0
- pen_stack/planner/genotoxicity_oracle.py +112 -0
- pen_stack/planner/immune_mhc2.py +154 -0
- pen_stack/planner/immune_profile.py +292 -0
- pen_stack/planner/innate_sensing.py +135 -0
- pen_stack/planner/multiplex.py +110 -0
- pen_stack/planner/optimize.py +278 -0
- pen_stack/planner/pipeline.py +87 -0
- pen_stack/planner/report.py +26 -0
- pen_stack/planner/router.py +57 -0
- pen_stack/planner/seroprevalence_oracle.py +92 -0
- pen_stack/planner/target_site.py +118 -0
- pen_stack/rag/__init__.py +1 -0
- pen_stack/rag/corpus.py +133 -0
- pen_stack/rag/embed.py +98 -0
- pen_stack/rag/ground.py +131 -0
- pen_stack/rag/index.py +53 -0
- pen_stack/rag/llm.py +215 -0
- pen_stack/rag/qa.py +105 -0
- pen_stack/rag/retrieve.py +48 -0
- pen_stack/rules/__init__.py +9 -0
- pen_stack/rules/evaluators.py +318 -0
- pen_stack/rules/loader.py +31 -0
- pen_stack/rules/schema.py +99 -0
- pen_stack/rules/solver.py +43 -0
- pen_stack/rules/spec.py +78 -0
- pen_stack/safety/__init__.py +21 -0
- pen_stack/safety/audit.py +90 -0
- pen_stack/safety/gate.py +58 -0
- pen_stack/safety/pfam_scan.py +157 -0
- pen_stack/safety/policy.py +69 -0
- pen_stack/safety/redteam.py +71 -0
- pen_stack/safety/registry.py +255 -0
- pen_stack/safety/screen.py +59 -0
- pen_stack/safety/standards.py +141 -0
- pen_stack/score/__init__.py +1 -0
- pen_stack/score/recalibrate.py +77 -0
- pen_stack/score/therapeutic.py +85 -0
- pen_stack/server/__init__.py +1 -0
- pen_stack/server/api.py +647 -0
- pen_stack/spec/__init__.py +18 -0
- pen_stack/spec/clarify.py +42 -0
- pen_stack/spec/extract.py +406 -0
- pen_stack/spec/resolvers/__init__.py +17 -0
- pen_stack/spec/resolvers/cell.py +51 -0
- pen_stack/spec/resolvers/chem.py +32 -0
- pen_stack/spec/resolvers/feature.py +36 -0
- pen_stack/spec/resolvers/gene.py +43 -0
- pen_stack/spec/resolvers/locus.py +29 -0
- pen_stack/spec/resolvers/phenotype.py +37 -0
- pen_stack/spec/satisfy.py +114 -0
- pen_stack/spec/service.py +33 -0
- pen_stack/spec/writespec.py +252 -0
- pen_stack/twin/__init__.py +14 -0
- pen_stack/twin/calibrate.py +61 -0
- pen_stack/twin/data/__init__.py +12 -0
- pen_stack/twin/data/position_effect.py +245 -0
- pen_stack/twin/mechanistic.py +147 -0
- pen_stack/twin/outcome.py +132 -0
- pen_stack/twin/position_effect.py +454 -0
- pen_stack/ui/__init__.py +1 -0
- pen_stack/ui/app.py +713 -0
- pen_stack/validate/__init__.py +1 -0
- pen_stack/validate/adapt_demo.py +69 -0
- pen_stack/validate/agent_eval.py +117 -0
- pen_stack/validate/bench_adversarial_tasks.py +118 -0
- pen_stack/validate/bench_coscientist_tasks.py +60 -0
- pen_stack/validate/bench_graph_tasks.py +64 -0
- pen_stack/validate/bench_rule_tasks.py +84 -0
- pen_stack/validate/bench_trust_tasks.py +92 -0
- pen_stack/validate/bench_writetype_tasks.py +101 -0
- pen_stack/validate/blind_gsh_discovery.py +261 -0
- pen_stack/validate/cargo_directionality.py +57 -0
- pen_stack/validate/closed_loop.py +63 -0
- pen_stack/validate/durability_baselines.py +185 -0
- pen_stack/validate/experiment_design.py +65 -0
- pen_stack/validate/expr_controls.py +39 -0
- pen_stack/validate/forward_hypotheses.py +104 -0
- pen_stack/validate/generative_design.py +62 -0
- pen_stack/validate/guide_qc_demo.py +69 -0
- pen_stack/validate/heldout_celltype_expr.py +32 -0
- pen_stack/validate/immune_calibration.py +133 -0
- pen_stack/validate/intent_specification.py +82 -0
- pen_stack/validate/known_biology_expr.py +38 -0
- pen_stack/validate/offtarget_energetics_eval.py +144 -0
- pen_stack/validate/out_of_scope_refusal.py +82 -0
- pen_stack/validate/outcome_calibration.py +194 -0
- pen_stack/validate/outcome_prediction.py +76 -0
- pen_stack/validate/paper3_benchmark.py +165 -0
- pen_stack/validate/paper4_real_validation.py +144 -0
- pen_stack/validate/paper4_validation.py +82 -0
- pen_stack/validate/protocol_safety.py +62 -0
- pen_stack/validate/safety_screening.py +72 -0
- pen_stack/validate/selective_prediction.py +104 -0
- pen_stack/validate/seq_vs_measured.py +134 -0
- pen_stack/validate/target_site_controls.py +65 -0
- pen_stack/validate/uncertainty_eval.py +244 -0
- pen_stack/validate/ungrounded_baseline.py +234 -0
- pen_stack/validate/within_locus_ranking.py +84 -0
- pen_stack/validate/writer_recovery.py +91 -0
- pen_stack/verify/__init__.py +5 -0
- pen_stack/verify/proof.py +206 -0
- pen_stack/verify/schema.py +53 -0
- pen_stack/verify/service.py +191 -0
- pen_stack/web/__init__.py +18 -0
- pen_stack/web/guide.py +110 -0
- pen_stack/web/llm.py +393 -0
- pen_stack/web/llm_provider.py +119 -0
- pen_stack/web/router.py +119 -0
- pen_stack/web/server.py +96 -0
- pen_stack/web/tools.py +197 -0
- pen_stack/wgenome/__init__.py +1 -0
- pen_stack/wgenome/chromatin_seq.py +83 -0
- pen_stack/wgenome/durability.py +108 -0
- pen_stack/wgenome/export_tracks.py +52 -0
- pen_stack/wgenome/features.py +82 -0
- pen_stack/wgenome/genotoxic_blocklist.py +88 -0
- pen_stack/wgenome/gsh_baseline.py +154 -0
- pen_stack/wgenome/mesh_features.py +61 -0
- pen_stack/wgenome/offtarget_assay.py +80 -0
- pen_stack/wgenome/offtarget_bridge.py +47 -0
- pen_stack/wgenome/offtarget_cast.py +97 -0
- pen_stack/wgenome/offtarget_data.py +148 -0
- pen_stack/wgenome/offtarget_enumerate.py +274 -0
- pen_stack/wgenome/offtarget_integrase.py +155 -0
- pen_stack/wgenome/offtarget_nuclease.py +123 -0
- pen_stack/wgenome/offtarget_paste.py +41 -0
- pen_stack/wgenome/offtarget_predict.py +282 -0
- pen_stack/wgenome/ood.py +135 -0
- pen_stack/wgenome/providers.py +278 -0
- pen_stack/wgenome/safety.py +69 -0
- pen_stack/wgenome/structure3d.py +212 -0
- pen_stack/wgenome/uncertainty.py +250 -0
- pen_stack/wgenome/writability.py +72 -0
- pen_stack-0.1.0.dist-info/METADATA +401 -0
- pen_stack-0.1.0.dist-info/RECORD +259 -0
- pen_stack-0.1.0.dist-info/WHEEL +5 -0
- pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
- pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pen_stack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Safety-gated protocol export, the digital→physical bridge.
|
|
2
|
+
|
|
3
|
+
A cleared, legal design becomes a runnable protocol, emitted as a reviewed DRAFT carrying its immune-risk
|
|
4
|
+
profile in the metadata, never auto-run, and refused outright if the safety gate flags it. Protocols are
|
|
5
|
+
drafts for human/lab review, never auto-executed.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from pen_stack.verify import verify
|
|
12
|
+
|
|
13
|
+
_TARGETS = ("opentrons", "pylabrobot", "cloudlab")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProtocolExportError(RuntimeError):
|
|
17
|
+
"""Raised when a design cannot be exported (safety-refused or illegal)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_protocol_ir(design: dict, experiment: dict) -> dict:
|
|
21
|
+
"""Minimal, platform-agnostic protocol intermediate representation (a DRAFT plan, not lab-tuned steps)."""
|
|
22
|
+
return {
|
|
23
|
+
"assay": experiment.get("assay", "cassette_expression_readout"),
|
|
24
|
+
"cell_type": design.get("cell_type", "k562"),
|
|
25
|
+
"delivery_vehicle": design.get("delivery_vehicle"),
|
|
26
|
+
"writer_family": design.get("writer_family"),
|
|
27
|
+
"cargo_bp": design.get("cargo_bp"),
|
|
28
|
+
"round": experiment.get("round"),
|
|
29
|
+
"steps": ["seed cells", "prepare delivery vehicle + cargo", "deliver", "incubate", "assay readout"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _emit_opentrons(ir: dict) -> str:
|
|
34
|
+
return (f"# Opentrons Python Protocol API v2 (DRAFT)\nmetadata = {{'apiLevel': '2.15'}}\n"
|
|
35
|
+
f"def run(protocol):\n"
|
|
36
|
+
f" # assay: {ir['assay']} | cell_type: {ir['cell_type']} | vehicle: {ir['delivery_vehicle']}\n"
|
|
37
|
+
f" # steps: {ir['steps']}\n"
|
|
38
|
+
f" pass # DRAFT - human/lab review required; fill in labware + liquid handling\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _emit_pylabrobot(ir: dict) -> str:
|
|
42
|
+
return (f"# PyLabRobot protocol (DRAFT)\nfrom pylabrobot.liquid_handling import LiquidHandler\n"
|
|
43
|
+
f"async def run(lh: LiquidHandler):\n"
|
|
44
|
+
f" # assay: {ir['assay']} | vehicle: {ir['delivery_vehicle']} | steps: {ir['steps']}\n"
|
|
45
|
+
f" ... # DRAFT - human/lab review required\n")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _emit_cloudlab(ir: dict) -> str:
|
|
49
|
+
return ("# Cloud-lab protocol request (DRAFT)\n"
|
|
50
|
+
+ json.dumps({"assay": ir["assay"], "cell_type": ir["cell_type"], "steps": ir["steps"],
|
|
51
|
+
"status": "DRAFT - human/lab review required"}, indent=2) + "\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_EMITTERS = {"opentrons": _emit_opentrons, "pylabrobot": _emit_pylabrobot, "cloudlab": _emit_cloudlab}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _stamp_draft(code: str, *, provenance: dict) -> str:
|
|
58
|
+
header = ("# ============================================================\n"
|
|
59
|
+
"# DRAFT, human/lab review required. NOT auto-executed.\n"
|
|
60
|
+
f"# provenance: {json.dumps(provenance, default=str)}\n"
|
|
61
|
+
"# ============================================================\n")
|
|
62
|
+
return header + code
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def export_protocol(design: dict, experiment: dict, *, target: str = "opentrons",
|
|
66
|
+
actor: str = "anonymous") -> str:
|
|
67
|
+
"""Cleared design + experiment -> a runnable protocol DRAFT, with the immune-risk profile in the
|
|
68
|
+
metadata. HARD GATE: refuses anything the safety gate flags, or any illegal design."""
|
|
69
|
+
if target not in _TARGETS:
|
|
70
|
+
raise ProtocolExportError(f"unknown target {target!r}; choose from {_TARGETS}")
|
|
71
|
+
v = verify(dict(design), actor=actor)
|
|
72
|
+
if v.safety is not None and v.safety.decision == "refuse":
|
|
73
|
+
raise ProtocolExportError(f"export blocked by safety gate: {v.safety.reason}")
|
|
74
|
+
if v.legal is not True:
|
|
75
|
+
raise ProtocolExportError(f"export blocked: design not legal ({v.summary()})")
|
|
76
|
+
ir = _to_protocol_ir(design, experiment)
|
|
77
|
+
code = _EMITTERS[target](ir)
|
|
78
|
+
return _stamp_draft(code, provenance={
|
|
79
|
+
"verify": v.summary(), "safety": (v.safety.decision if v.safety else None),
|
|
80
|
+
"immune_profile": v.immune_profile, # metadata travels with the protocol
|
|
81
|
+
"target": target, "actor": actor,
|
|
82
|
+
"note": "immune profile is a screen carrying its known-unknowns, not a patient prediction"})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Simulated-lab harness, run the closed loop without hardware.
|
|
2
|
+
|
|
3
|
+
Executes a protocol in silico: samples an "observed" result from the twin + measurement noise, clearly
|
|
4
|
+
labelled SIMULATED. This lets the closed loop run end-to-end before any hardware exists. Sim outcomes
|
|
5
|
+
inherit the twin's limits and NEVER enter the curated world-model as measured truth, they are for development /
|
|
6
|
+
loop-validation only.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_simulated(protocol_ir: dict, design: dict, cell_state: str, *, seed: int = 0) -> dict:
|
|
14
|
+
"""Execute a protocol in silico: sample an 'observed' readout from the twin + measurement noise. Labelled
|
|
15
|
+
SIMULATED; the loop (export -> sim-run -> ingest) completes without a wet lab."""
|
|
16
|
+
from pen_stack.twin.outcome import predict_outcome
|
|
17
|
+
out = predict_outcome(design, cell_state)
|
|
18
|
+
truth = out["predicted_outcome"]
|
|
19
|
+
rng = random.Random(seed)
|
|
20
|
+
base = truth.get("relative_expression")
|
|
21
|
+
readout = None if base is None else round(base + rng.gauss(0.0, 0.05), 4)
|
|
22
|
+
return {
|
|
23
|
+
"assay": protocol_ir.get("assay", "cassette_expression_readout"),
|
|
24
|
+
"readout": readout,
|
|
25
|
+
"units": truth.get("units"),
|
|
26
|
+
"design_id": design.get("design_id", "design:sim"),
|
|
27
|
+
"confidence": out.get("interval"),
|
|
28
|
+
"provenance": {"source": "simlab", "seed": seed, "label": "SIMULATED",
|
|
29
|
+
"note": "sampled from the twin + measurement noise; NOT measured truth"},
|
|
30
|
+
}
|
pen_stack/cli.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""PEN-STACK unified CLI (subcommands: atlas, score, writable, crosslink, monitor).
|
|
2
|
+
|
|
3
|
+
One entry point - ``pen-stack`` - over the whole stack. Heavy data (the writability atlas) is
|
|
4
|
+
loaded lazily and degrades gracefully when absent, so ``info`` / ``atlas`` work from a clean install.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from pen_stack import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(__version__, prog_name="pen-stack")
|
|
15
|
+
def main():
|
|
16
|
+
"""PEN-STACK - open infrastructure for genome writing."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@main.command()
|
|
20
|
+
def info():
|
|
21
|
+
"""Show stack status and module map."""
|
|
22
|
+
click.echo(f"PEN-STACK v{__version__}")
|
|
23
|
+
click.echo("Pillar B (flagship): wgenome - Writable Genome (safety x durability x reachability)")
|
|
24
|
+
click.echo("Pillar A (companion): atlas, mech, score - Writer Atlas + WT-KB")
|
|
25
|
+
click.echo("Engine: planner - Write Planner (inverse design)")
|
|
26
|
+
click.echo("Beachhead: bridge - bridge-recombinase off-target engine")
|
|
27
|
+
click.echo("Services: monitor, rag, agent, ui, server")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@main.command()
|
|
31
|
+
@click.option("--family", default=None, help="Filter to one writer family.")
|
|
32
|
+
@click.option("--coverage", is_flag=True, help="Show per-family coverage + confidence breakdown.")
|
|
33
|
+
@click.option("--limit", default=10, help="Max rows to print.")
|
|
34
|
+
def atlas(family, coverage, limit):
|
|
35
|
+
"""Query the Writer Atlas."""
|
|
36
|
+
import pandas as pd
|
|
37
|
+
|
|
38
|
+
from pen_stack.atlas.crosslink import _ATLAS
|
|
39
|
+
df = pd.read_parquet(_ATLAS)
|
|
40
|
+
if coverage:
|
|
41
|
+
cov = (df.groupby("family")
|
|
42
|
+
.agg(n=("representative_system", "size"),
|
|
43
|
+
measured=("confidence", lambda s: (s == "measured").sum()),
|
|
44
|
+
tier=("reachability_tier", "first"))
|
|
45
|
+
.reset_index())
|
|
46
|
+
click.echo(cov.to_string(index=False))
|
|
47
|
+
click.echo(f"\nTOTAL systems: {len(df):,} across {df['family'].nunique()} families")
|
|
48
|
+
return
|
|
49
|
+
if family:
|
|
50
|
+
df = df[df["family"] == family]
|
|
51
|
+
cols = [c for c in ["representative_system", "family", "confidence", "deliv_class",
|
|
52
|
+
"readiness", "reachability_tier"] if c in df.columns]
|
|
53
|
+
click.echo(df[cols].head(limit).to_string(index=False))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@main.command()
|
|
57
|
+
@click.option("--gene", required=True, help="Target gene symbol.")
|
|
58
|
+
@click.option("--ct", default="k562", help="Cell type (k562/hepg2/hspc).")
|
|
59
|
+
@click.option("--top", default=10, help="Top writable bins to show.")
|
|
60
|
+
def writable(gene, ct, top):
|
|
61
|
+
"""Rank writable loci overlapping a gene."""
|
|
62
|
+
from pen_stack.atlas.crosslink import loci_for_gene
|
|
63
|
+
try:
|
|
64
|
+
g = loci_for_gene(gene, ct)
|
|
65
|
+
except FileNotFoundError as e:
|
|
66
|
+
raise click.ClickException(f"writability atlas not available: {e}") from e
|
|
67
|
+
if g.empty:
|
|
68
|
+
click.echo(f"No writable bins found for {gene} in {ct}.")
|
|
69
|
+
return
|
|
70
|
+
click.echo(g[["chrom", "bin", "safety", "p_durable", "writability"]].head(top).to_string(index=False))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@main.command()
|
|
74
|
+
@click.option("--family", help="Writer family -> ranked reachable loci.")
|
|
75
|
+
@click.option("--chrom", help="Locus chrom (with --bin) -> reachable writers.")
|
|
76
|
+
@click.option("--bin", "bin_idx", type=int, help="Locus 1kb bin index.")
|
|
77
|
+
@click.option("--ct", default="k562")
|
|
78
|
+
@click.option("--top", default=10)
|
|
79
|
+
def crosslink(family, chrom, bin_idx, ct, top):
|
|
80
|
+
"""Writer<->locus cross-link queries."""
|
|
81
|
+
from pen_stack.atlas import crosslink as cl
|
|
82
|
+
try:
|
|
83
|
+
if family:
|
|
84
|
+
click.echo(cl.loci_for_writer(family, ct, top=top).to_string(index=False))
|
|
85
|
+
elif chrom and bin_idx is not None:
|
|
86
|
+
click.echo(cl.writers_for_locus(chrom, bin_idx, ct).head(top).to_string(index=False))
|
|
87
|
+
else:
|
|
88
|
+
raise click.UsageError("provide --family OR (--chrom and --bin)")
|
|
89
|
+
except FileNotFoundError as e:
|
|
90
|
+
raise click.ClickException(f"writability atlas not available: {e}") from e
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@main.command()
|
|
94
|
+
@click.option("--gene", required=True, help="Target gene symbol.")
|
|
95
|
+
@click.option("--intent", required=True,
|
|
96
|
+
type=click.Choice(["safe_harbour_insertion", "knock_in_with_disruption",
|
|
97
|
+
"high_durability_insertion", "regulatory_excision", "repeat_excision"]))
|
|
98
|
+
@click.option("--cargo-bp", default=2000, help="Payload size (bp).")
|
|
99
|
+
@click.option("--ct", default="k562", help="Cell type (k562/hepg2/hspc).")
|
|
100
|
+
@click.option("--k", default=3, help="Number of ranked plans.")
|
|
101
|
+
def plan(gene, intent, cargo_bp, ct, k):
|
|
102
|
+
"""Write Planner: goal + edit_intent -> ranked, traceable plans."""
|
|
103
|
+
from pen_stack.planner.optimize import EditIntent
|
|
104
|
+
from pen_stack.planner.pipeline import plan_write
|
|
105
|
+
from pen_stack.planner.report import render_plans
|
|
106
|
+
try:
|
|
107
|
+
plans = plan_write(gene, EditIntent(intent), cargo_bp, ct, k=k)
|
|
108
|
+
except FileNotFoundError as e:
|
|
109
|
+
raise click.ClickException(f"writability atlas not available: {e}") from e
|
|
110
|
+
click.echo(render_plans(plans))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@main.command()
|
|
114
|
+
@click.option("--since", default="2026-01-01", help="Earliest publication date (YYYY-MM-DD).")
|
|
115
|
+
@click.option("--back-test", is_flag=True, help="Run the ISPpu10 back-test window.")
|
|
116
|
+
def monitor(since, back_test):
|
|
117
|
+
"""Run the living-literature monitor (Europe PMC scan -> curation queue)."""
|
|
118
|
+
from pen_stack.monitor.run import run_monitor
|
|
119
|
+
res = run_monitor(since=since, back_test=back_test)
|
|
120
|
+
click.echo(f"Monitor: {res['n_hits']} hits, {res['n_candidates']} candidates -> {res['queue']}")
|
|
121
|
+
if res.get("isppu10_found") is not None:
|
|
122
|
+
click.echo(f"ISPpu10 back-test: {'FOUND' if res['isppu10_found'] else 'not found'}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pen_stack.data - see PEN-STACK program doc."""
|
pen_stack/data/encode.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""ENCODE REST resolver.
|
|
2
|
+
|
|
3
|
+
Resolves released hg38 bigWig SIGNAL files for a (biosample, assay/target) pair via the ENCODE
|
|
4
|
+
Portal REST API - so we never hard-code possibly-wrong file accessions. Returns accession + href.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
ENCODE = "https://www.encodeproject.org"
|
|
11
|
+
HEADERS = {"accept": "application/json"}
|
|
12
|
+
|
|
13
|
+
# preferred processed signal output per assay (fold-change over control where available)
|
|
14
|
+
_PREF_OUTPUT = [
|
|
15
|
+
"fold change over control",
|
|
16
|
+
"signal p-value",
|
|
17
|
+
"read-depth normalized signal",
|
|
18
|
+
"signal",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _search(params: dict) -> list[dict]:
|
|
23
|
+
r = requests.get(f"{ENCODE}/search/", params=params, headers=HEADERS, timeout=60)
|
|
24
|
+
if r.status_code == 404:
|
|
25
|
+
return [] # ENCODE returns 404 for zero-result searches with some param combos
|
|
26
|
+
r.raise_for_status()
|
|
27
|
+
return r.json().get("@graph", [])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_bigwig(biosample: str, assay_title: str, target: str | None = None,
|
|
31
|
+
assembly: str = "GRCh38") -> dict | None:
|
|
32
|
+
"""Find one released bigWig signal file for a biosample + assay (+ histone target).
|
|
33
|
+
|
|
34
|
+
biosample e.g. 'K562'; assay_title e.g. 'Histone ChIP-seq' / 'ATAC-seq' / 'DNase-seq';
|
|
35
|
+
target e.g. 'H3K27ac' (None for ATAC/DNase).
|
|
36
|
+
"""
|
|
37
|
+
params = {
|
|
38
|
+
"type": "File",
|
|
39
|
+
"file_format": "bigWig",
|
|
40
|
+
"output_type": _PREF_OUTPUT,
|
|
41
|
+
"assembly": assembly,
|
|
42
|
+
"status": "released",
|
|
43
|
+
"biosample_ontology.term_name": biosample,
|
|
44
|
+
"assay_title": assay_title,
|
|
45
|
+
"format": "json",
|
|
46
|
+
"limit": "50",
|
|
47
|
+
}
|
|
48
|
+
if target:
|
|
49
|
+
params["target.label"] = target
|
|
50
|
+
files = _search(params)
|
|
51
|
+
if not files:
|
|
52
|
+
return None
|
|
53
|
+
# rank by preferred output_type order, prefer non-isogenic-replicate consensus where present
|
|
54
|
+
def rank(f):
|
|
55
|
+
ot = f.get("output_type", "")
|
|
56
|
+
return _PREF_OUTPUT.index(ot) if ot in _PREF_OUTPUT else len(_PREF_OUTPUT)
|
|
57
|
+
f = sorted(files, key=rank)[0]
|
|
58
|
+
return {"accession": f["accession"], "href": ENCODE + f["href"],
|
|
59
|
+
"output_type": f.get("output_type"), "assembly": assembly,
|
|
60
|
+
"biosample": biosample, "assay": assay_title, "target": target}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# default track panel per the prereg (durability features)
|
|
64
|
+
DEFAULT_PANEL = [
|
|
65
|
+
("ATAC-seq", None),
|
|
66
|
+
("DNase-seq", None),
|
|
67
|
+
("Histone ChIP-seq", "H3K27ac"),
|
|
68
|
+
("Histone ChIP-seq", "H3K4me1"),
|
|
69
|
+
("Histone ChIP-seq", "H3K4me3"),
|
|
70
|
+
("Histone ChIP-seq", "H3K9me3"),
|
|
71
|
+
("Histone ChIP-seq", "H3K27me3"),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_panel(biosample: str, panel=DEFAULT_PANEL, assembly: str = "GRCh38") -> dict[str, dict]:
|
|
76
|
+
"""Return {track_name: file_record} for the panel, skipping assays with no released bigWig.
|
|
77
|
+
Partial panels are returned as-is (e.g. a cell type lacking some histone marks) - graceful."""
|
|
78
|
+
out = {}
|
|
79
|
+
for assay, target in panel:
|
|
80
|
+
rec = find_bigwig(biosample, assay, target, assembly=assembly)
|
|
81
|
+
name = target or assay.split("-")[0].lower() # H3K27ac / atac / dnase
|
|
82
|
+
if rec:
|
|
83
|
+
out[name] = rec
|
|
84
|
+
return out
|
pen_stack/data/genome.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""hg38 genome scaffolding.
|
|
2
|
+
|
|
3
|
+
Fetches hg38 chromosome sizes and builds the canonical 1 kb bin grid (autosomes + X) that every
|
|
4
|
+
feature store is keyed on. Pure-CPU, small; runs in any container.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
UCSC_CHROM_SIZES = "https://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.chrom.sizes"
|
|
14
|
+
MAIN_CHROMS = [f"chr{i}" for i in range(1, 23)] + ["chrX"]
|
|
15
|
+
BIN_BP = 1000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fetch_chrom_sizes(out_tsv: str | Path, url: str = UCSC_CHROM_SIZES,
|
|
19
|
+
chroms: list[str] = MAIN_CHROMS) -> dict[str, int]:
|
|
20
|
+
txt = requests.get(url, timeout=60).text
|
|
21
|
+
sizes = {}
|
|
22
|
+
for line in txt.splitlines():
|
|
23
|
+
if not line.strip():
|
|
24
|
+
continue
|
|
25
|
+
c, n = line.split("\t")[:2]
|
|
26
|
+
if c in chroms:
|
|
27
|
+
sizes[c] = int(n)
|
|
28
|
+
sizes = {c: sizes[c] for c in chroms if c in sizes} # canonical order
|
|
29
|
+
Path(out_tsv).parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
Path(out_tsv).write_text("".join(f"{c}\t{n}\n" for c, n in sizes.items()))
|
|
31
|
+
return sizes
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_bin_grid(chrom_sizes: dict[str, int], out_parquet: str | Path | None = None,
|
|
35
|
+
bin_bp: int = BIN_BP) -> pd.DataFrame:
|
|
36
|
+
rows = []
|
|
37
|
+
for c, n in chrom_sizes.items():
|
|
38
|
+
nbins = n // bin_bp
|
|
39
|
+
starts = range(0, nbins * bin_bp, bin_bp)
|
|
40
|
+
rows.append(pd.DataFrame({"chrom": c, "start": starts}))
|
|
41
|
+
grid = pd.concat(rows, ignore_index=True)
|
|
42
|
+
grid["end"] = grid["start"] + bin_bp
|
|
43
|
+
grid["bin"] = grid["start"] // bin_bp
|
|
44
|
+
if out_parquet:
|
|
45
|
+
Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
grid.to_parquet(out_parquet, index=False)
|
|
47
|
+
return grid
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_chrom_sizes(tsv: str | Path) -> dict[str, int]:
|
|
51
|
+
out = {}
|
|
52
|
+
for line in Path(tsv).read_text().splitlines():
|
|
53
|
+
if line.strip():
|
|
54
|
+
c, n = line.split("\t")[:2]
|
|
55
|
+
out[c] = int(n)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
import argparse
|
|
61
|
+
ap = argparse.ArgumentParser()
|
|
62
|
+
ap.add_argument("--sizes-out", default="/data/raw/hg38.chrom.sizes")
|
|
63
|
+
ap.add_argument("--grid-out", default="/data/features/bin_grid_1kb.parquet")
|
|
64
|
+
a = ap.parse_args()
|
|
65
|
+
sizes = fetch_chrom_sizes(a.sizes_out)
|
|
66
|
+
grid = build_bin_grid(sizes, a.grid_out)
|
|
67
|
+
print(f"chroms={len(sizes)} total_bins={len(grid)} -> {a.grid_out}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Chromatin feature store.
|
|
2
|
+
|
|
3
|
+
Resolves the ENCODE bigWig panel for a cell type, downloads tracks IN PARALLEL, bins each to the
|
|
4
|
+
canonical 1 kb grid (mean signal per bin) IN PARALLEL across cores, merges into one feature-store
|
|
5
|
+
parquet, and deletes the raw bigWigs (500 GB discipline). Run in Docker on the VM.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import pyBigWig
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from pen_stack.data.encode import resolve_panel
|
|
20
|
+
from pen_stack.data.genome import MAIN_CHROMS, load_chrom_sizes
|
|
21
|
+
|
|
22
|
+
BIN_BP = 1000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def download(href: str, dest: str) -> str:
|
|
26
|
+
dest = str(dest)
|
|
27
|
+
if os.path.exists(dest) and os.path.getsize(dest) > 0:
|
|
28
|
+
return dest
|
|
29
|
+
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
with requests.get(href, stream=True, timeout=900) as r:
|
|
31
|
+
r.raise_for_status()
|
|
32
|
+
with open(dest, "wb") as fh:
|
|
33
|
+
for chunk in r.iter_content(chunk_size=1 << 20):
|
|
34
|
+
fh.write(chunk)
|
|
35
|
+
return dest
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bin_one(args) -> tuple[str, pd.DataFrame]:
|
|
39
|
+
"""Bin one bigWig to 1 kb mean per bin (module-level for ProcessPool picklability)."""
|
|
40
|
+
name, path, sizes = args
|
|
41
|
+
bw = pyBigWig.open(path)
|
|
42
|
+
bw_chroms = set(bw.chroms().keys())
|
|
43
|
+
frames = []
|
|
44
|
+
for c in MAIN_CHROMS:
|
|
45
|
+
if c not in sizes:
|
|
46
|
+
continue
|
|
47
|
+
n = sizes[c] // BIN_BP
|
|
48
|
+
key = c if c in bw_chroms else c.replace("chr", "")
|
|
49
|
+
if key not in bw_chroms:
|
|
50
|
+
frames.append(pd.DataFrame({"chrom": c, "bin": range(n), name: np.zeros(n, "float32")}))
|
|
51
|
+
continue
|
|
52
|
+
vals = bw.stats(key, 0, n * BIN_BP, nBins=n, type="mean")
|
|
53
|
+
v = np.array([0.0 if x is None else float(x) for x in vals], dtype="float32")
|
|
54
|
+
frames.append(pd.DataFrame({"chrom": c, "bin": range(n), name: v}))
|
|
55
|
+
bw.close()
|
|
56
|
+
return name, pd.concat(frames, ignore_index=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_feature_store(biosample: str, chrom_sizes_tsv: str, raw_dir: str, out_parquet: str,
|
|
60
|
+
max_dl: int = 7, max_bin: int = 7) -> pd.DataFrame:
|
|
61
|
+
sizes = load_chrom_sizes(chrom_sizes_tsv)
|
|
62
|
+
panel = resolve_panel(biosample)
|
|
63
|
+
print(f"[{biosample}] resolved tracks: {list(panel.keys())}", flush=True)
|
|
64
|
+
if not panel:
|
|
65
|
+
raise SystemExit(f"no ENCODE bigWig tracks resolved for {biosample}")
|
|
66
|
+
|
|
67
|
+
# 1) parallel download
|
|
68
|
+
paths = {}
|
|
69
|
+
with ThreadPoolExecutor(max_workers=max_dl) as ex:
|
|
70
|
+
futs = {ex.submit(download, rec["href"],
|
|
71
|
+
os.path.join(raw_dir, f"{biosample}_{name}_{rec['accession']}.bigWig")): name
|
|
72
|
+
for name, rec in panel.items()}
|
|
73
|
+
for fut in futs:
|
|
74
|
+
name = futs[fut]
|
|
75
|
+
paths[name] = fut.result()
|
|
76
|
+
print(f" downloaded {name}", flush=True)
|
|
77
|
+
|
|
78
|
+
# 2) parallel bin
|
|
79
|
+
binned = {}
|
|
80
|
+
with ProcessPoolExecutor(max_workers=max_bin) as ex:
|
|
81
|
+
for name, df in ex.map(bin_one, [(n, paths[n], sizes) for n in panel]):
|
|
82
|
+
binned[name] = df
|
|
83
|
+
print(f" binned {name}", flush=True)
|
|
84
|
+
|
|
85
|
+
base = None
|
|
86
|
+
for name in panel:
|
|
87
|
+
base = binned[name] if base is None else base.merge(binned[name], on=["chrom", "bin"])
|
|
88
|
+
base["biosample"] = biosample
|
|
89
|
+
|
|
90
|
+
# 3) clean raws
|
|
91
|
+
for p in paths.values():
|
|
92
|
+
try:
|
|
93
|
+
os.remove(p)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
base.to_parquet(out_parquet, index=False)
|
|
99
|
+
pd.DataFrame([{"track": n, "accession": panel[n]["accession"],
|
|
100
|
+
"output_type": panel[n]["output_type"]} for n in panel]
|
|
101
|
+
).to_csv(out_parquet.replace(".parquet", "_manifest.csv"), index=False)
|
|
102
|
+
return base
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main() -> None:
|
|
106
|
+
ap = argparse.ArgumentParser()
|
|
107
|
+
ap.add_argument("--biosample", required=True)
|
|
108
|
+
ap.add_argument("--sizes", default="/data/raw/hg38.chrom.sizes")
|
|
109
|
+
ap.add_argument("--raw-dir", default="/data/raw/encode")
|
|
110
|
+
ap.add_argument("--out", default=None)
|
|
111
|
+
a = ap.parse_args()
|
|
112
|
+
out = a.out or f"/data/features/chromatin_{a.biosample.lower()}.parquet"
|
|
113
|
+
df = build_feature_store(a.biosample, a.sizes, a.raw_dir, out)
|
|
114
|
+
cols = [c for c in df.columns if c not in ("chrom", "bin", "biosample")]
|
|
115
|
+
print(f"feature store {out}: bins={len(df)} tracks={cols}", flush=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Integration-propensity features.
|
|
2
|
+
|
|
3
|
+
Builds per-1 kb-bin retroviral integration density from VISDB integration tables (HIV, HTLV, MLV;
|
|
4
|
+
coordinates already lifted to hg38 in VISDB). Integration propensity reflects accessible/active
|
|
5
|
+
chromatin and is a feature for both the safety layer and "where insertions land".
|
|
6
|
+
|
|
7
|
+
NOTE (scope): VISDB's MLV set is tiny (~32 sites); the large >3.7M MLV-in-K562/HepG2 sets
|
|
8
|
+
referenced in the plan live in specific papers'/GEO supplements and are sourced separately. The
|
|
9
|
+
GENOTOXIC labels (clonal-outcome CIS) come from the clinical gene list - this module
|
|
10
|
+
supplies the integration-DENSITY feature, not the danger label.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import glob
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from pen_stack.data.genome import MAIN_CHROMS
|
|
23
|
+
|
|
24
|
+
BIN_BP = 1000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_visdb(csv_dir: str) -> pd.DataFrame:
|
|
28
|
+
frames = []
|
|
29
|
+
for f in sorted(glob.glob(os.path.join(csv_dir, "*.csv"))):
|
|
30
|
+
virus = Path(f).stem
|
|
31
|
+
df = pd.read_csv(f, dtype=str)
|
|
32
|
+
cols = {c.lower().strip(): c for c in df.columns}
|
|
33
|
+
chrom_c = cols.get("human chromosome")
|
|
34
|
+
start_c = cols.get("hg38_start")
|
|
35
|
+
if not chrom_c or not start_c:
|
|
36
|
+
continue
|
|
37
|
+
sub = pd.DataFrame({
|
|
38
|
+
"chrom": df[chrom_c].astype(str).map(lambda c: c if c.startswith("chr") else f"chr{c}"),
|
|
39
|
+
"pos": pd.to_numeric(df[start_c], errors="coerce"),
|
|
40
|
+
"virus": virus,
|
|
41
|
+
}).dropna(subset=["pos"])
|
|
42
|
+
frames.append(sub)
|
|
43
|
+
out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["chrom", "pos", "virus"])
|
|
44
|
+
out = out[out["chrom"].isin(MAIN_CHROMS)].copy()
|
|
45
|
+
out["pos"] = out["pos"].astype(int)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def density_per_bin(integ: pd.DataFrame, bin_grid: str, out_parquet: str) -> pd.DataFrame:
|
|
50
|
+
grid = pd.read_parquet(bin_grid)[["chrom", "bin"]]
|
|
51
|
+
integ["bin"] = integ["pos"] // BIN_BP
|
|
52
|
+
dens = integ.groupby(["chrom", "bin"]).size().rename("integ_density").reset_index()
|
|
53
|
+
out = grid.merge(dens, on=["chrom", "bin"], how="left")
|
|
54
|
+
out["integ_density"] = out["integ_density"].fillna(0).astype("int32")
|
|
55
|
+
out["integ_log_density"] = np.log1p(out["integ_density"]).astype("float32")
|
|
56
|
+
Path(out_parquet).parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
out.to_parquet(out_parquet, index=False)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lafave_density(bed_gz: str, chain_file: str, bin_grid: str, out_parquet: str) -> pd.DataFrame:
|
|
62
|
+
"""Cell-type-specific MLV integration density from a LaFave et al. 2014 BED (hg19 -> hg38 lift).
|
|
63
|
+
|
|
64
|
+
The LaFave K562/HepG2 MLV integration BEDs are on hg19; lift each site to hg38 with the UCSC
|
|
65
|
+
chain, then bin to 1 kb. This is the plan's >3.7M MLV-in-K562/HepG2 supervision (Bushman/NHGRI).
|
|
66
|
+
"""
|
|
67
|
+
from pyliftover import LiftOver
|
|
68
|
+
lo = LiftOver(chain_file)
|
|
69
|
+
sites = []
|
|
70
|
+
with __import__("gzip").open(bed_gz, "rt") as fh:
|
|
71
|
+
for line in fh:
|
|
72
|
+
if line.startswith("track") or not line.strip():
|
|
73
|
+
continue
|
|
74
|
+
f = line.split("\t")
|
|
75
|
+
chrom, start = f[0], int(f[1])
|
|
76
|
+
conv = lo.convert_coordinate(chrom, start)
|
|
77
|
+
if conv:
|
|
78
|
+
nc, npos = conv[0][0], conv[0][1]
|
|
79
|
+
if nc in MAIN_CHROMS:
|
|
80
|
+
sites.append((nc, npos))
|
|
81
|
+
integ = pd.DataFrame(sites, columns=["chrom", "pos"])
|
|
82
|
+
print(f"lifted {len(integ)} / sites to hg38")
|
|
83
|
+
out = density_per_bin(integ, bin_grid, out_parquet)
|
|
84
|
+
out = out.rename(columns={"integ_density": "integ_mlv_density",
|
|
85
|
+
"integ_log_density": "integ_mlv_log_density"})
|
|
86
|
+
out.to_parquet(out_parquet, index=False)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
ap = argparse.ArgumentParser()
|
|
92
|
+
ap.add_argument("--mode", choices=["visdb", "lafave"], default="visdb")
|
|
93
|
+
ap.add_argument("--visdb-dir", default="/data/external/visdb")
|
|
94
|
+
ap.add_argument("--lafave-bed", default=None)
|
|
95
|
+
ap.add_argument("--chain", default="/data/external/hg19ToHg38.over.chain.gz")
|
|
96
|
+
ap.add_argument("--bin-grid", default="/data/features/bin_grid_1kb.parquet")
|
|
97
|
+
ap.add_argument("--out", default="/data/features/integration_density.parquet")
|
|
98
|
+
a = ap.parse_args()
|
|
99
|
+
if a.mode == "lafave":
|
|
100
|
+
out = lafave_density(a.lafave_bed, a.chain, a.bin_grid, a.out)
|
|
101
|
+
nz = int((out["integ_mlv_density"] > 0).sum())
|
|
102
|
+
print(f"MLV density: bins={len(out)} nonzero={nz} max={int(out['integ_mlv_density'].max())} -> {a.out}")
|
|
103
|
+
return
|
|
104
|
+
integ = load_visdb(a.visdb_dir)
|
|
105
|
+
print(f"loaded {len(integ)} integration sites; by virus: {integ['virus'].value_counts().to_dict()}")
|
|
106
|
+
out = density_per_bin(integ, a.bin_grid, a.out)
|
|
107
|
+
nz = int((out["integ_density"] > 0).sum())
|
|
108
|
+
print(f"integration density: bins={len(out)} nonzero={nz} max={int(out['integ_density'].max())}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|