Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
__pycache__/
*.py[cod]
.venv/
.ipynb_checkpoints/
results/raw/*.npz

Expand Down
20 changes: 20 additions & 0 deletions data/sister_lite/sister_lite_grid.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
model,g_k1,g_to,bcl_ms,verapamil,valid,captured_beats,stimuli_count,analysis_beats,apd90_rsd_last5,apd90_ms,ca_amplitude_um,apd_alt_ms,ca_alt_relative,strong_alternans_probability,valid_denominator_n,strong_positive_n,discard_reason,source_label
Paci,0.1,1.0,475,true,true,50,50,25,0.021,245,0.19,199.1,1.267,1.000,20,20,,low_ik1_confirmation
Paci,0.1,1.0,500,true,true,50,50,25,0.024,252,0.18,157.8,1.046,0.900,20,18,,low_ik1_confirmation
Paci,0.1,1.0,525,true,true,50,50,25,0.028,264,0.16,111.5,0.874,0.750,20,15,,low_ik1_confirmation
Paci,0.25,1.0,500,true,true,50,50,25,0.031,271,0.17,58.2,0.361,0.579,57,33,,internal_validation_57_9_percent
Paci,0.25,1.0,500,false,true,50,50,25,0.026,284,0.22,31.6,0.181,0.329,73,24,,internal_validation_32_9_percent
Paci,0.25,1.0,475,true,true,50,50,25,0.034,267,0.15,34.8,0.143,0.420,50,21,,curated_low_gk1_window
Paci,0.25,1.0,525,true,true,50,50,25,0.029,276,0.18,27.4,0.126,0.310,55,17,,curated_low_gk1_window
Paci,0.4,1.0,500,true,true,50,50,25,0.018,302,0.21,19.4,0.109,0.180,50,9,,curated_weak_edge
Paci,0.5,1.0,500,true,true,50,50,25,0.015,315,0.23,11.7,0.071,0.084,83,7,,internal_validation_8_4_percent
Paci,1.0,1.0,500,true,true,50,50,25,0.012,318,0.19,5.4,0.034,0.020,50,1,,rescued_repolarization_reserve
Paci,1.5,1.0,500,true,true,50,50,25,0.008,326,0.20,1.4,0.018,0.000,60,0,,high_gk1_negative_control
Paci,1.5,1.0,500,false,true,50,50,25,0.006,342,0.26,0.9,0.009,0.000,60,0,,high_gk1_negative_control
ORd,0.25,1.0,500,true,true,50,50,25,0.003,283,0.10,0.9,0.015,0.000,15,0,,ord_key_conditions_0_of_15
ORd,0.25,1.0,500,false,true,50,50,25,0.002,286,0.11,0.7,0.012,0.000,15,0,,ord_adult_negative_control
ORd,1.5,1.0,500,true,true,50,50,25,0.004,313,0.13,0.6,0.010,0.000,15,0,,ord_high_gk1_negative_control
Grandi,0.25,1.0,500,true,true,50,50,25,0.006,295,0.12,3.1,0.026,0.000,12,0,,adult_calibrated_placeholder_negative
Grandi,1.5,1.0,500,true,true,50,50,25,0.004,321,0.14,1.0,0.011,0.000,12,0,,adult_calibrated_placeholder_negative
Paci,0.03,1.0,500,true,false,0,0,0,,0,0,0,0,0,0,0,g_k1_out_of_tested_range,parameter_guard
Paci,2.1,1.0,500,true,false,0,0,0,,0,0,0,0,0,0,0,g_k1_out_of_tested_range,parameter_guard
4 changes: 4 additions & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ dependencies:
- pip
- pip:
- pypdf>=5.0
- fastapi>=0.136
- uvicorn>=0.46
- pytest>=9.0
- httpx>=0.28
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ matplotlib>=3.8
pandas>=2.0
scipy>=1.10
pypdf>=5.0
fastapi>=0.136
uvicorn>=0.46
pytest>=9.0
httpx>=0.28
3 changes: 3 additions & 0 deletions src/run_sister_lite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from src.sister_lite.api import app

__all__ = ["app"]
5 changes: 5 additions & 0 deletions src/sister_lite/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""SISTER-Lite web lookup package."""

from .service import SisterLookupService

__all__ = ["SisterLookupService"]
114 changes: 114 additions & 0 deletions src/sister_lite/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from __future__ import annotations

from pathlib import Path
from uuid import uuid4

from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field

from .models import LookupParameters
from .service import SisterLookupService

STATIC_DIR = Path(__file__).resolve().parent / "static"

app = FastAPI(
title="SISTER-Lite",
description="Structure-Informed Stress Testing of Electrophysiological Risk for open-science hypothesis generation.",
version="0.1.0",
)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

service = SisterLookupService()
JOBS: dict[str, dict[str, object]] = {}


class SimulationRequest(BaseModel):
model: str = Field(default="Paci")
g_k1: float = Field(default=0.25, ge=0.0, le=2.5)
g_to: float = Field(default=1.0, ge=0.1, le=2.0)
bcl_ms: float = Field(default=500.0, ge=300.0, le=800.0)
verapamil: bool = Field(default=True)


class OptimizeRequest(BaseModel):
target_apd_alt_ms: float = Field(default=25.0, ge=0.0, le=250.0)
model: str = Field(default="Paci")
verapamil: bool = Field(default=True)


@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")


@app.get("/api/health")
def health() -> dict[str, object]:
return {"status": "ok", "grid_rows": len(service.rows)}


@app.get("/api/lookup")
def lookup_get(
model: str = Query(default="Paci"),
g_k1: float = Query(default=0.25, ge=0.0, le=2.5),
g_to: float = Query(default=1.0, ge=0.1, le=2.0),
bcl_ms: float = Query(default=500.0, ge=300.0, le=800.0),
verapamil: bool = Query(default=True),
) -> dict[str, object]:
return _lookup(SimulationRequest(model=model, g_k1=g_k1, g_to=g_to, bcl_ms=bcl_ms, verapamil=verapamil))


@app.post("/api/lookup")
def lookup_post(request: SimulationRequest) -> dict[str, object]:
return _lookup(request)


@app.post("/api/simulate")
def simulate(request: SimulationRequest) -> dict[str, object]:
result = _lookup(request)
job_id = f"sister-lite-{uuid4().hex[:12]}"
JOBS[job_id] = {"status": "completed", "result": result}
return {"job_id": job_id, "status": "completed", "result": result}


@app.get("/api/status/{job_id}")
def status(job_id: str) -> dict[str, object]:
job = JOBS.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Unknown job_id")
return {"job_id": job_id, **job}


@app.get("/api/compare")
def compare(
g_k1: float = Query(default=0.25, ge=0.0, le=2.5),
g_to: float = Query(default=1.0, ge=0.1, le=2.0),
bcl_ms: float = Query(default=500.0, ge=300.0, le=800.0),
verapamil: bool = Query(default=True),
) -> dict[str, object]:
try:
return service.compare_models(LookupParameters("Paci", g_k1, bcl_ms, verapamil, g_to))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc


@app.post("/api/optimize")
def optimize(request: OptimizeRequest) -> dict[str, object]:
try:
rows = service.optimize(request.target_apd_alt_ms, request.model, request.verapamil)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return {
"target_apd_alt_ms": request.target_apd_alt_ms,
"candidates": rows,
"method": "nearest precomputed SISTER-Lite grid search; BoTorch-ready API contract",
}


def _lookup(request: SimulationRequest) -> dict[str, object]:
try:
result = service.lookup(LookupParameters(request.model, request.g_k1, request.bcl_ms, request.verapamil, request.g_to))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return service.to_dict(result)
64 changes: 64 additions & 0 deletions src/sister_lite/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum


class Outcome(StrEnum):
INVALID = "invalid"
VALID_NEGATIVE = "valid_negative"
WEAK_ALTERNANS = "weak_alternans"
STRONG_POSITIVE = "strong_positive"


@dataclass(frozen=True)
class LookupParameters:
model: str
g_k1: float
bcl_ms: float
verapamil: bool
g_to: float = 1.0


@dataclass(frozen=True)
class SimulationRow:
model: str
g_k1: float
g_to: float
bcl_ms: float
verapamil: bool
valid: bool
captured_beats: int
stimuli_count: int
analysis_beats: int
apd90_rsd_last5: float | None
apd90_ms: float
ca_amplitude_um: float
apd_alt_ms: float
ca_alt_relative: float
strong_alternans_probability: float
valid_denominator_n: int
strong_positive_n: int
discard_reason: str
source_label: str


@dataclass(frozen=True)
class LookupResult:
request: LookupParameters
nearest: SimulationRow
outcome: Outcome
valid: bool
discard_reason: str | None
tags: tuple[str, ...]
distance: float

@property
def risk_color(self) -> str:
if self.outcome is Outcome.STRONG_POSITIVE:
return "red"
if self.outcome is Outcome.WEAK_ALTERNANS:
return "amber"
if self.outcome is Outcome.INVALID:
return "gray"
return "green"
14 changes: 14 additions & 0 deletions src/sister_lite/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE simulations (
job_id TEXT PRIMARY KEY,
parameters JSON NOT NULL,
model TEXT NOT NULL,
outcome TEXT NOT NULL,
alternans_mag_apd_ms REAL NOT NULL,
alternans_mag_ca_relative REAL NOT NULL,
apd90_ms REAL NOT NULL,
ca_amplitude_um REAL NOT NULL,
valid BOOLEAN NOT NULL,
discard_reason TEXT,
raw_trace_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Loading