alphavx 0.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alphavx/__init__.py +3 -0
- alphavx/__main__.py +6 -0
- alphavx/cache.py +170 -0
- alphavx/cli.py +296 -0
- alphavx/config.py +142 -0
- alphavx/plots.py +174 -0
- alphavx/reporter.py +677 -0
- alphavx/scorer.py +246 -0
- alphavx/vcf_parser.py +188 -0
- alphavx-0.0.5.dist-info/METADATA +234 -0
- alphavx-0.0.5.dist-info/RECORD +14 -0
- alphavx-0.0.5.dist-info/WHEEL +4 -0
- alphavx-0.0.5.dist-info/entry_points.txt +2 -0
- alphavx-0.0.5.dist-info/licenses/LICENSE +190 -0
alphavx/__init__.py
ADDED
alphavx/__main__.py
ADDED
alphavx/cache.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""SQLite-based result caching for AlphaVX.
|
|
2
|
+
|
|
3
|
+
Caches variant scoring results so interrupted batch runs can resume
|
|
4
|
+
without re-querying the AlphaGenome API for already-scored variants.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_SCHEMA = """
|
|
19
|
+
CREATE TABLE IF NOT EXISTS variant_scores (
|
|
20
|
+
variant_key TEXT NOT NULL,
|
|
21
|
+
scores_json TEXT NOT NULL,
|
|
22
|
+
scored_at TEXT NOT NULL,
|
|
23
|
+
config_hash TEXT
|
|
24
|
+
);
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Index for fast lookups on the composite key used by has() / get().
|
|
28
|
+
_INDEX = """
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_variant_config
|
|
30
|
+
ON variant_scores (variant_key, config_hash);
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResultCache:
|
|
35
|
+
"""SQLite cache for variant scoring results.
|
|
36
|
+
|
|
37
|
+
Results are keyed by both the variant identifier **and** an optional
|
|
38
|
+
config hash so that different scoring configurations (modalities,
|
|
39
|
+
sequence_length, etc.) are cached independently.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
cache_dir: Directory where the cache database will be stored.
|
|
43
|
+
config_hash: Optional hash string identifying the scoring
|
|
44
|
+
configuration. When *None*, every ``has``/``get`` call will
|
|
45
|
+
behave as a cache miss (safe default for backward compat).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, cache_dir: Path, config_hash: str | None = None) -> None:
|
|
49
|
+
import threading
|
|
50
|
+
self.cache_dir = Path(cache_dir)
|
|
51
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self.db_path = self.cache_dir / "alphavx_cache.db"
|
|
53
|
+
self.config_hash = config_hash
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
self._init_db()
|
|
56
|
+
|
|
57
|
+
def _init_db(self) -> None:
|
|
58
|
+
"""Create the cache table if it doesn't exist and migrate old schemas."""
|
|
59
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
60
|
+
conn.execute(_SCHEMA)
|
|
61
|
+
# Migrate legacy databases that lack the config_hash column.
|
|
62
|
+
self._migrate(conn)
|
|
63
|
+
conn.execute(_INDEX)
|
|
64
|
+
conn.commit()
|
|
65
|
+
logger.debug("Cache initialized at %s", self.db_path)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
69
|
+
"""Add the config_hash column if it is missing (legacy DB migration)."""
|
|
70
|
+
cursor = conn.execute("PRAGMA table_info(variant_scores)")
|
|
71
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
72
|
+
if "config_hash" not in columns:
|
|
73
|
+
# Recreate table to remove the PRIMARY KEY constraint on variant_key
|
|
74
|
+
conn.execute("ALTER TABLE variant_scores RENAME TO variant_scores_old")
|
|
75
|
+
conn.execute(_SCHEMA)
|
|
76
|
+
conn.execute(
|
|
77
|
+
"INSERT INTO variant_scores (variant_key, scores_json, scored_at, config_hash) "
|
|
78
|
+
"SELECT variant_key, scores_json, scored_at, NULL FROM variant_scores_old"
|
|
79
|
+
)
|
|
80
|
+
conn.execute("DROP TABLE variant_scores_old")
|
|
81
|
+
logger.info("Migrated cache DB: added config_hash column and removed old primary key")
|
|
82
|
+
|
|
83
|
+
def has(self, variant_key: str) -> bool:
|
|
84
|
+
"""Check if a variant has cached results for the current config.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
variant_key: Variant identifier (chr:pos:ref>alt).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if the variant has cached scores **and** the cache was
|
|
91
|
+
created with a non-None ``config_hash``.
|
|
92
|
+
"""
|
|
93
|
+
if self.config_hash is None:
|
|
94
|
+
return False
|
|
95
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
96
|
+
cursor = conn.execute(
|
|
97
|
+
"SELECT 1 FROM variant_scores "
|
|
98
|
+
"WHERE variant_key = ? AND config_hash = ?",
|
|
99
|
+
(variant_key, self.config_hash),
|
|
100
|
+
)
|
|
101
|
+
return cursor.fetchone() is not None
|
|
102
|
+
|
|
103
|
+
def get(self, variant_key: str) -> dict | None:
|
|
104
|
+
"""Retrieve cached scores for a variant under the current config.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
variant_key: Variant identifier (chr:pos:ref>alt).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Deserialized scores dict, or None if not cached.
|
|
111
|
+
"""
|
|
112
|
+
if self.config_hash is None:
|
|
113
|
+
return None
|
|
114
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
115
|
+
cursor = conn.execute(
|
|
116
|
+
"SELECT scores_json FROM variant_scores "
|
|
117
|
+
"WHERE variant_key = ? AND config_hash = ?",
|
|
118
|
+
(variant_key, self.config_hash),
|
|
119
|
+
)
|
|
120
|
+
row = cursor.fetchone()
|
|
121
|
+
if row is None:
|
|
122
|
+
return None
|
|
123
|
+
return json.loads(row[0])
|
|
124
|
+
|
|
125
|
+
def put(self, variant_key: str, scores: dict) -> None:
|
|
126
|
+
"""Store scores for a variant in the cache.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
variant_key: Variant identifier (chr:pos:ref>alt).
|
|
130
|
+
scores: Scoring results to cache (must be JSON-serializable).
|
|
131
|
+
"""
|
|
132
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
133
|
+
scores_json = json.dumps(scores, default=str)
|
|
134
|
+
with self._lock:
|
|
135
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
136
|
+
# Remove any previous entry for the same (variant, config) pair.
|
|
137
|
+
conn.execute(
|
|
138
|
+
"DELETE FROM variant_scores "
|
|
139
|
+
"WHERE variant_key = ? AND config_hash IS ?",
|
|
140
|
+
(variant_key, self.config_hash),
|
|
141
|
+
)
|
|
142
|
+
conn.execute(
|
|
143
|
+
"INSERT INTO variant_scores "
|
|
144
|
+
"(variant_key, scores_json, scored_at, config_hash) "
|
|
145
|
+
"VALUES (?, ?, ?, ?)",
|
|
146
|
+
(variant_key, scores_json, now, self.config_hash),
|
|
147
|
+
)
|
|
148
|
+
conn.commit()
|
|
149
|
+
logger.debug("Cached scores for %s", variant_key)
|
|
150
|
+
|
|
151
|
+
def clear(self) -> None:
|
|
152
|
+
"""Delete all cached results."""
|
|
153
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
154
|
+
conn.execute("DELETE FROM variant_scores")
|
|
155
|
+
conn.commit()
|
|
156
|
+
logger.info("Cache cleared")
|
|
157
|
+
|
|
158
|
+
def stats(self) -> dict:
|
|
159
|
+
"""Get cache statistics.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dict with 'count' (number of cached variants) and
|
|
163
|
+
'cache_size_bytes' (database file size).
|
|
164
|
+
"""
|
|
165
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
166
|
+
cursor = conn.execute("SELECT COUNT(*) FROM variant_scores")
|
|
167
|
+
count = cursor.fetchone()[0]
|
|
168
|
+
|
|
169
|
+
size = os.path.getsize(self.db_path) if self.db_path.exists() else 0
|
|
170
|
+
return {"count": count, "cache_size_bytes": size}
|
alphavx/cli.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""AlphaVX command-line interface.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
alphavx score input.vcf -o results/
|
|
5
|
+
alphavx query chr17:7674220:G>A
|
|
6
|
+
alphavx report results/
|
|
7
|
+
alphavx cache stats
|
|
8
|
+
alphavx cache clear
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .config import load_config
|
|
22
|
+
from .vcf_parser import parse_vcf, parse_variant_string
|
|
23
|
+
from .cache import ResultCache
|
|
24
|
+
from .scorer import VariantScorer
|
|
25
|
+
from .reporter import generate_csv_report, generate_html_report, generate_report, generate_vcf_report
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="alphavx",
|
|
29
|
+
help="AlphaVX — AlphaGenome Variant Effect Interpreter.\n\n"
|
|
30
|
+
"Batch-score genetic variants against AlphaGenome and generate "
|
|
31
|
+
"interpretable multi-modal effect reports.",
|
|
32
|
+
add_completion=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
cache_app = typer.Typer(help="Manage the variant scoring cache.")
|
|
36
|
+
app.add_typer(cache_app, name="cache")
|
|
37
|
+
|
|
38
|
+
# Configure logging
|
|
39
|
+
logging.basicConfig(
|
|
40
|
+
level=logging.INFO,
|
|
41
|
+
format="%(asctime)s | %(levelname)-7s | %(message)s",
|
|
42
|
+
datefmt="%H:%M:%S",
|
|
43
|
+
)
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _version_callback(value: bool) -> None:
|
|
48
|
+
if value:
|
|
49
|
+
typer.echo(f"alphavx {__version__}")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_console():
|
|
54
|
+
"""Get a Rich console for pretty output, or fall back to plain print."""
|
|
55
|
+
try:
|
|
56
|
+
from rich.console import Console
|
|
57
|
+
return Console()
|
|
58
|
+
except ImportError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.callback()
|
|
63
|
+
def main(
|
|
64
|
+
version: Optional[bool] = typer.Option(
|
|
65
|
+
None, "--version", "-v", callback=_version_callback,
|
|
66
|
+
is_eager=True, help="Show version and exit.",
|
|
67
|
+
),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""AlphaVX — AlphaGenome Variant Effect Interpreter."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def score(
|
|
74
|
+
vcf_path: Path = typer.Argument(..., help="Path to VCF file containing variants to score."),
|
|
75
|
+
output: Path = typer.Option("results", "--output", "-o", help="Output directory for results."),
|
|
76
|
+
genes: Optional[str] = typer.Option(
|
|
77
|
+
None, "--genes", "-g",
|
|
78
|
+
help="Comma-separated gene list to filter results (e.g., BRCA1,TP53,CFTR).",
|
|
79
|
+
),
|
|
80
|
+
config_path: Optional[Path] = typer.Option(
|
|
81
|
+
None, "--config", "-c", help="Path to alphavx.yaml configuration file.",
|
|
82
|
+
),
|
|
83
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Disable result caching."),
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Score all variants in a VCF file against AlphaGenome."""
|
|
86
|
+
console = _get_console()
|
|
87
|
+
|
|
88
|
+
# Load config
|
|
89
|
+
try:
|
|
90
|
+
config = load_config(config_path)
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
typer.echo(f"Error: {e}", err=True)
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
config.output_dir = output
|
|
96
|
+
if no_cache:
|
|
97
|
+
config.cache_enabled = False
|
|
98
|
+
|
|
99
|
+
# Parse VCF
|
|
100
|
+
try:
|
|
101
|
+
records = parse_vcf(vcf_path)
|
|
102
|
+
except (FileNotFoundError, ValueError) as e:
|
|
103
|
+
typer.echo(f"Error: {e}", err=True)
|
|
104
|
+
raise typer.Exit(1)
|
|
105
|
+
|
|
106
|
+
typer.echo(f"Parsed {len(records)} variants from {vcf_path}")
|
|
107
|
+
|
|
108
|
+
# Setup cache
|
|
109
|
+
cache = None
|
|
110
|
+
if config.cache_enabled:
|
|
111
|
+
cache = ResultCache(output / "cache", config_hash=config.scoring_fingerprint)
|
|
112
|
+
stats = cache.stats()
|
|
113
|
+
if stats["count"] > 0:
|
|
114
|
+
typer.echo(f"Cache: {stats['count']} variants already cached")
|
|
115
|
+
|
|
116
|
+
# Score
|
|
117
|
+
scorer = VariantScorer(config)
|
|
118
|
+
|
|
119
|
+
def progress(i: int, total: int, record) -> None:
|
|
120
|
+
typer.echo(f"[{i + 1}/{total}] Scoring {record.key}...")
|
|
121
|
+
|
|
122
|
+
df = scorer.score_batch(records, cache=cache, progress_callback=progress)
|
|
123
|
+
|
|
124
|
+
if df.empty:
|
|
125
|
+
typer.echo("No results — all variants failed or returned empty scores.")
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
# Filter by genes if specified
|
|
129
|
+
if genes:
|
|
130
|
+
gene_list = [g.strip().upper() for g in genes.split(",")]
|
|
131
|
+
gene_col = "gene_name" if "gene_name" in df.columns else None
|
|
132
|
+
if gene_col:
|
|
133
|
+
before = len(df)
|
|
134
|
+
df = df[df[gene_col].str.upper().isin(gene_list)]
|
|
135
|
+
typer.echo(f"Gene filter: {before} → {len(df)} rows (genes: {', '.join(gene_list)})")
|
|
136
|
+
|
|
137
|
+
# Generate reports
|
|
138
|
+
generate_csv_report(df, output, quantile_threshold=config.quantile_threshold)
|
|
139
|
+
generate_html_report(df, output, quantile_threshold=config.quantile_threshold)
|
|
140
|
+
|
|
141
|
+
# Generate annotated VCF
|
|
142
|
+
try:
|
|
143
|
+
generate_vcf_report(vcf_path, df, output, quantile_threshold=config.quantile_threshold)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.warning("Annotated VCF generation failed: %s", e)
|
|
146
|
+
|
|
147
|
+
# Generate plots
|
|
148
|
+
try:
|
|
149
|
+
from .plots import plot_summary_heatmap, plot_variant_detail
|
|
150
|
+
plots_dir = output / "plots"
|
|
151
|
+
plot_summary_heatmap(df, plots_dir / "summary_heatmap.png", config.quantile_threshold)
|
|
152
|
+
|
|
153
|
+
# Generate per-variant detail plots
|
|
154
|
+
variant_keys = df["variant_key"].unique() if "variant_key" in df.columns else []
|
|
155
|
+
for vk in variant_keys:
|
|
156
|
+
safe_name = str(vk).replace(":", "_").replace(">", "_")
|
|
157
|
+
plot_variant_detail(
|
|
158
|
+
df, vk, plots_dir / "per_variant" / f"{safe_name}.png",
|
|
159
|
+
config.quantile_threshold,
|
|
160
|
+
)
|
|
161
|
+
if len(variant_keys):
|
|
162
|
+
logger.info("Generated %d per-variant plots", len(variant_keys))
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning("Plot generation failed: %s", e)
|
|
165
|
+
|
|
166
|
+
# Print summary
|
|
167
|
+
sig_count = 0
|
|
168
|
+
if "quantile_score" in df.columns:
|
|
169
|
+
sig_count = (df["quantile_score"].abs() > config.quantile_threshold).sum()
|
|
170
|
+
|
|
171
|
+
typer.echo("")
|
|
172
|
+
typer.echo("═" * 50)
|
|
173
|
+
typer.echo(f" Variants scored: {df['variant_key'].nunique() if 'variant_key' in df.columns else '?'}")
|
|
174
|
+
typer.echo(f" Significant hits: {sig_count}")
|
|
175
|
+
typer.echo(f" Results: {output / 'scores.csv'}")
|
|
176
|
+
typer.echo(f" Report: {output / 'report.html'}")
|
|
177
|
+
typer.echo("═" * 50)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def query(
|
|
182
|
+
variant: str = typer.Argument(
|
|
183
|
+
..., help="Variant to score (e.g., chr17:7674220:G>A or chr17:7674220:G:A).",
|
|
184
|
+
),
|
|
185
|
+
output: Optional[Path] = typer.Option(
|
|
186
|
+
None, "--output", "-o", help="Optional directory to save full CSV results.",
|
|
187
|
+
),
|
|
188
|
+
config_path: Optional[Path] = typer.Option(
|
|
189
|
+
None, "--config", "-c", help="Path to alphavx.yaml configuration file.",
|
|
190
|
+
),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Score a single variant and display results."""
|
|
193
|
+
# Parse variant
|
|
194
|
+
try:
|
|
195
|
+
record = parse_variant_string(variant)
|
|
196
|
+
except ValueError as e:
|
|
197
|
+
typer.echo(f"Error: {e}", err=True)
|
|
198
|
+
raise typer.Exit(1)
|
|
199
|
+
|
|
200
|
+
# Load config
|
|
201
|
+
try:
|
|
202
|
+
config = load_config(config_path)
|
|
203
|
+
except ValueError as e:
|
|
204
|
+
typer.echo(f"Error: {e}", err=True)
|
|
205
|
+
raise typer.Exit(1)
|
|
206
|
+
|
|
207
|
+
typer.echo(f"Scoring {record.key}...")
|
|
208
|
+
|
|
209
|
+
# Score
|
|
210
|
+
scorer = VariantScorer(config)
|
|
211
|
+
df = scorer.score_variant(record)
|
|
212
|
+
|
|
213
|
+
if df.empty:
|
|
214
|
+
typer.echo("No results returned for this variant.")
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
217
|
+
# Display significant results
|
|
218
|
+
if "quantile_score" in df.columns:
|
|
219
|
+
sig = df[df["quantile_score"].abs() > config.quantile_threshold]
|
|
220
|
+
else:
|
|
221
|
+
sig = df.head(0)
|
|
222
|
+
|
|
223
|
+
typer.echo(f"\nTotal scores: {len(df)}")
|
|
224
|
+
typer.echo(f"Significant: {len(sig)}")
|
|
225
|
+
|
|
226
|
+
if not sig.empty:
|
|
227
|
+
typer.echo(f"\n{'Variant':<25} {'Gene':<12} {'Modality':<20} {'Tissue':<25} {'Raw':<12} {'Quantile':<10}")
|
|
228
|
+
typer.echo("─" * 104)
|
|
229
|
+
display_cols = ["variant_key", "gene_name", "output_type", "biosample_name", "raw_score", "quantile_score"]
|
|
230
|
+
for _, row in sig.sort_values("quantile_score", key=abs, ascending=False).head(30).iterrows():
|
|
231
|
+
vk = str(row.get("variant_key", ""))[:24]
|
|
232
|
+
gene = str(row.get("gene_name", ""))[:11]
|
|
233
|
+
mod = str(row.get("output_type", ""))[:19]
|
|
234
|
+
tissue = str(row.get("biosample_name", ""))[:24]
|
|
235
|
+
raw = f"{row.get('raw_score', 0):.6f}"
|
|
236
|
+
quant = f"{row.get('quantile_score', 0):.6f}"
|
|
237
|
+
typer.echo(f"{vk:<25} {gene:<12} {mod:<20} {tissue:<25} {raw:<12} {quant:<10}")
|
|
238
|
+
|
|
239
|
+
# Save if output specified
|
|
240
|
+
if output:
|
|
241
|
+
generate_csv_report(df, output, quantile_threshold=config.quantile_threshold)
|
|
242
|
+
typer.echo(f"\nFull results saved to {output / 'scores.csv'}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command()
|
|
246
|
+
def report(
|
|
247
|
+
results_dir: Path = typer.Argument(
|
|
248
|
+
..., help="Directory containing scores.csv to generate report from.",
|
|
249
|
+
),
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Generate an HTML report from existing scoring results."""
|
|
252
|
+
try:
|
|
253
|
+
generate_report(results_dir)
|
|
254
|
+
typer.echo(f"Report generated: {results_dir / 'report.html'}")
|
|
255
|
+
except FileNotFoundError as e:
|
|
256
|
+
typer.echo(f"Error: {e}", err=True)
|
|
257
|
+
raise typer.Exit(1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@cache_app.command("stats")
|
|
261
|
+
def cache_stats(
|
|
262
|
+
cache_dir: Path = typer.Option(
|
|
263
|
+
"results/cache", "--dir", "-d", help="Cache directory.",
|
|
264
|
+
),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Show cache statistics."""
|
|
267
|
+
cache = ResultCache(cache_dir)
|
|
268
|
+
stats = cache.stats()
|
|
269
|
+
typer.echo(f"Cached variants: {stats['count']}")
|
|
270
|
+
typer.echo(f"Cache size: {stats['cache_size_bytes'] / 1024:.1f} KB")
|
|
271
|
+
typer.echo(f"Cache location: {cache.db_path}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@cache_app.command("clear")
|
|
275
|
+
def cache_clear(
|
|
276
|
+
cache_dir: Path = typer.Option(
|
|
277
|
+
"results/cache", "--dir", "-d", help="Cache directory.",
|
|
278
|
+
),
|
|
279
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Clear all cached results."""
|
|
282
|
+
cache = ResultCache(cache_dir)
|
|
283
|
+
stats = cache.stats()
|
|
284
|
+
|
|
285
|
+
if stats["count"] == 0:
|
|
286
|
+
typer.echo("Cache is already empty.")
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
if not force:
|
|
290
|
+
confirm = typer.confirm(f"Delete {stats['count']} cached variant scores?")
|
|
291
|
+
if not confirm:
|
|
292
|
+
typer.echo("Cancelled.")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
cache.clear()
|
|
296
|
+
typer.echo(f"Cleared {stats['count']} cached entries.")
|
alphavx/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Configuration loading and validation for AlphaVX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Load .env.alphavx file if present (before any os.environ reads)
|
|
13
|
+
# NOTE: We use ".env.alphavx" instead of ".env" because anndata (an
|
|
14
|
+
# alphagenome dependency) uses pydantic-settings which auto-reads ".env"
|
|
15
|
+
# and rejects unknown keys like ALPHAVX_API_KEY.
|
|
16
|
+
try:
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
|
|
19
|
+
_ENV_FILENAME = ".env.alphavx"
|
|
20
|
+
|
|
21
|
+
# Search current dir and project root
|
|
22
|
+
_candidates = [
|
|
23
|
+
Path.cwd() / _ENV_FILENAME,
|
|
24
|
+
Path(__file__).resolve().parent.parent.parent / _ENV_FILENAME,
|
|
25
|
+
]
|
|
26
|
+
for _candidate in _candidates:
|
|
27
|
+
if _candidate.exists():
|
|
28
|
+
load_dotenv(_candidate)
|
|
29
|
+
break
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEFAULT_MODALITIES = [
|
|
36
|
+
"RNA_SEQ",
|
|
37
|
+
"SPLICE_SITES",
|
|
38
|
+
"SPLICE_SITE_USAGE",
|
|
39
|
+
"SPLICE_JUNCTIONS",
|
|
40
|
+
"DNASE",
|
|
41
|
+
"ATAC",
|
|
42
|
+
"CHIP_HISTONE",
|
|
43
|
+
"CHIP_TF",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Config:
|
|
49
|
+
"""AlphaVX runtime configuration."""
|
|
50
|
+
|
|
51
|
+
api_key: str = ""
|
|
52
|
+
max_retries: int = 3
|
|
53
|
+
retry_delay: float = 5.0
|
|
54
|
+
sequence_length: int = 2**20 # 1,048,576 — AlphaGenome optimal
|
|
55
|
+
quantile_threshold: float = 0.995
|
|
56
|
+
output_dir: Path = field(default_factory=lambda: Path("results"))
|
|
57
|
+
cache_enabled: bool = True
|
|
58
|
+
modalities: list[str] = field(default_factory=lambda: list(DEFAULT_MODALITIES))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def scoring_fingerprint(self) -> str:
|
|
62
|
+
"""Return a short hash of config fields that affect API results.
|
|
63
|
+
|
|
64
|
+
Covers ``modalities`` (sorted) and ``sequence_length`` so that
|
|
65
|
+
different scoring configurations produce distinct cache entries.
|
|
66
|
+
"""
|
|
67
|
+
canonical = json.dumps(
|
|
68
|
+
{"modalities": sorted(self.modalities),
|
|
69
|
+
"sequence_length": self.sequence_length},
|
|
70
|
+
sort_keys=True,
|
|
71
|
+
)
|
|
72
|
+
return hashlib.sha256(canonical.encode()).hexdigest()[:12]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_config(config_path: Path | None = None) -> Config:
|
|
76
|
+
"""Load configuration from YAML file and/or environment variables.
|
|
77
|
+
|
|
78
|
+
Priority: YAML file values > environment variables > defaults.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
config_path: Optional path to an alphavx.yaml configuration file.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Populated Config instance.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If no API key is found in config or environment.
|
|
88
|
+
"""
|
|
89
|
+
config = Config()
|
|
90
|
+
key_env_name: str | None = None
|
|
91
|
+
|
|
92
|
+
# Load from YAML if provided
|
|
93
|
+
if config_path is not None:
|
|
94
|
+
try:
|
|
95
|
+
import yaml
|
|
96
|
+
except ImportError:
|
|
97
|
+
logger.warning("pyyaml not installed — ignoring config file %s", config_path)
|
|
98
|
+
else:
|
|
99
|
+
with open(config_path) as f:
|
|
100
|
+
raw = yaml.safe_load(f) or {}
|
|
101
|
+
|
|
102
|
+
api_section = raw.get("api", {})
|
|
103
|
+
scoring_section = raw.get("scoring", {})
|
|
104
|
+
output_section = raw.get("output", {})
|
|
105
|
+
|
|
106
|
+
key_env_name = api_section.get("key_env")
|
|
107
|
+
|
|
108
|
+
if "max_retries" in api_section:
|
|
109
|
+
config.max_retries = int(api_section["max_retries"])
|
|
110
|
+
if "retry_delay" in api_section:
|
|
111
|
+
config.retry_delay = float(api_section["retry_delay"])
|
|
112
|
+
if "modalities" in scoring_section:
|
|
113
|
+
config.modalities = scoring_section["modalities"]
|
|
114
|
+
if "quantile_threshold" in scoring_section:
|
|
115
|
+
config.quantile_threshold = float(scoring_section["quantile_threshold"])
|
|
116
|
+
if "sequence_length" in scoring_section:
|
|
117
|
+
config.sequence_length = int(scoring_section["sequence_length"])
|
|
118
|
+
if "cache" in output_section:
|
|
119
|
+
config.cache_enabled = bool(output_section["cache"])
|
|
120
|
+
|
|
121
|
+
logger.info("Loaded config from %s", config_path)
|
|
122
|
+
|
|
123
|
+
# Resolve API key: env var takes precedence if config doesn't set it
|
|
124
|
+
if not config.api_key:
|
|
125
|
+
# If YAML specified a custom env var name via key_env, try it first
|
|
126
|
+
if key_env_name:
|
|
127
|
+
config.api_key = os.environ.get(key_env_name, "")
|
|
128
|
+
# Fall back to the hardcoded env var names
|
|
129
|
+
if not config.api_key:
|
|
130
|
+
config.api_key = os.environ.get(
|
|
131
|
+
"ALPHAVX_API_KEY",
|
|
132
|
+
os.environ.get("ALPHAGENOME_API_KEY", ""),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not config.api_key:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"No API key found. Set ALPHAVX_API_KEY or ALPHAGENOME_API_KEY environment "
|
|
138
|
+
"variable, or provide it in your alphavx.yaml config file.\n"
|
|
139
|
+
"Sign up at: https://deepmind.google.com/science/alphagenome/"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return config
|