|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import json |
| 4 | +from html import escape |
| 5 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 6 | +from pathlib import Path |
| 7 | +from urllib.parse import parse_qs, urlparse |
| 8 | + |
| 9 | +import numpy as np |
| 10 | + |
| 11 | +from .database import DrugDatabase |
| 12 | +from .pk_model import PKModel |
| 13 | + |
| 14 | + |
| 15 | +def _validate_profile_args(dose: float, t_end: float, n_points: int) -> None: |
| 16 | + if dose <= 0: |
| 17 | + raise ValueError("dose must be positive") |
| 18 | + if t_end <= 0: |
| 19 | + raise ValueError("t_end must be positive") |
| 20 | + if n_points < 3: |
| 21 | + raise ValueError("n_points must be at least 3") |
| 22 | + |
| 23 | + |
| 24 | +def _profile_to_svg(t: np.ndarray, conc: np.ndarray, width: int = 780, height: int = 280, pad: int = 24) -> str: |
| 25 | + x = np.asarray(t, dtype=float) |
| 26 | + y = np.asarray(conc, dtype=float) |
| 27 | + if x.size == 0: |
| 28 | + return "" |
| 29 | + |
| 30 | + x_min, x_max = float(x.min()), float(x.max()) |
| 31 | + y_min, y_max = 0.0, float(max(y.max(), 1e-8)) |
| 32 | + x_span = max(x_max - x_min, 1e-8) |
| 33 | + y_span = max(y_max - y_min, 1e-8) |
| 34 | + |
| 35 | + px = pad + (x - x_min) / x_span * (width - 2 * pad) |
| 36 | + py = height - pad - (y - y_min) / y_span * (height - 2 * pad) |
| 37 | + points = " ".join(f"{float(a):.2f},{float(b):.2f}" for a, b in zip(px, py)) |
| 38 | + return ( |
| 39 | + f"<svg viewBox='0 0 {width} {height}' width='100%' role='img' aria-label='PK concentration profile'>" |
| 40 | + f"<rect x='0' y='0' width='{width}' height='{height}' fill='#f8fafc'/>" |
| 41 | + f"<line x1='{pad}' y1='{height-pad}' x2='{width-pad}' y2='{height-pad}' stroke='#64748b' stroke-width='1'/>" |
| 42 | + f"<line x1='{pad}' y1='{pad}' x2='{pad}' y2='{height-pad}' stroke='#64748b' stroke-width='1'/>" |
| 43 | + f"<polyline fill='none' stroke='#0f766e' stroke-width='2.5' points='{points}'/>" |
| 44 | + "</svg>" |
| 45 | + ) |
| 46 | + |
| 47 | + |
| 48 | +def build_web_app_payload( |
| 49 | + dataset: str, |
| 50 | + drug: str, |
| 51 | + dose: float | None = None, |
| 52 | + t_end: float = 24.0, |
| 53 | + n_points: int = 300, |
| 54 | +) -> dict: |
| 55 | + db = DrugDatabase(dataset) |
| 56 | + drug_obj = db.get_drug(drug) |
| 57 | + dose_final = float(dose) if dose is not None else float(drug_obj.dose) |
| 58 | + _validate_profile_args(dose=dose_final, t_end=float(t_end), n_points=int(n_points)) |
| 59 | + |
| 60 | + pk = PKModel(**drug_obj.pk_kwargs) |
| 61 | + t = np.linspace(0.0, float(t_end), int(n_points)) |
| 62 | + conc = pk.concentration(t, D=dose_final) |
| 63 | + idx = int(np.nanargmax(conc)) |
| 64 | + |
| 65 | + return { |
| 66 | + "drug": str(drug_obj.name), |
| 67 | + "dose": dose_final, |
| 68 | + "t_end": float(t_end), |
| 69 | + "n_points": int(n_points), |
| 70 | + "t": t.tolist(), |
| 71 | + "conc": conc.tolist(), |
| 72 | + "cmax": float(conc[idx]), |
| 73 | + "tmax": float(t[idx]), |
| 74 | + "auc_0_tend": float(np.trapezoid(conc, t)), |
| 75 | + "available_drugs": db.list_drugs(), |
| 76 | + } |
| 77 | + |
| 78 | + |
| 79 | +def render_web_app_html(payload: dict) -> str: |
| 80 | + t = np.asarray(payload["t"], dtype=float) |
| 81 | + conc = np.asarray(payload["conc"], dtype=float) |
| 82 | + svg = _profile_to_svg(t, conc) |
| 83 | + drug = escape(str(payload["drug"])) |
| 84 | + drugs = ", ".join(payload.get("available_drugs", [])) |
| 85 | + return f"""<!doctype html> |
| 86 | +<html lang="en"> |
| 87 | +<head> |
| 88 | + <meta charset="utf-8" /> |
| 89 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 90 | + <title>OpenDose-PopPK Web App</title> |
| 91 | + <style> |
| 92 | + body {{ font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; color: #0f172a; background: #f1f5f9; }} |
| 93 | + .card {{ background: white; border: 1px solid #cbd5e1; border-radius: 12px; padding: 16px; max-width: 980px; }} |
| 94 | + .kpi {{ display: inline-block; min-width: 170px; margin-right: 12px; margin-top: 6px; }} |
| 95 | + code {{ background: #e2e8f0; padding: 2px 6px; border-radius: 4px; }} |
| 96 | + </style> |
| 97 | +</head> |
| 98 | +<body> |
| 99 | + <h1>OpenDose-PopPK Web App (Baseline)</h1> |
| 100 | + <div class="card"> |
| 101 | + <p><strong>Drug:</strong> {drug} | <strong>Dose:</strong> {payload["dose"]:.2f}</p> |
| 102 | + <p>Available drugs in dataset: <code>{escape(drugs)}</code></p> |
| 103 | + <div class="kpi"><strong>Cmax</strong><br>{payload["cmax"]:.4f}</div> |
| 104 | + <div class="kpi"><strong>Tmax (h)</strong><br>{payload["tmax"]:.4f}</div> |
| 105 | + <div class="kpi"><strong>AUC 0→t_end</strong><br>{payload["auc_0_tend"]:.4f}</div> |
| 106 | + <div style="margin-top: 18px;">{svg}</div> |
| 107 | + </div> |
| 108 | +</body> |
| 109 | +</html> |
| 110 | +""" |
| 111 | + |
| 112 | + |
| 113 | +def write_web_app_html(payload: dict, output_path: str | Path) -> str: |
| 114 | + out = Path(output_path) |
| 115 | + out.parent.mkdir(parents=True, exist_ok=True) |
| 116 | + out.write_text(render_web_app_html(payload), encoding="utf-8") |
| 117 | + return str(out) |
| 118 | + |
| 119 | + |
| 120 | +def run_web_app_server( # pragma: no cover |
| 121 | + dataset: str, |
| 122 | + host: str = "127.0.0.1", |
| 123 | + port: int = 8000, |
| 124 | + default_drug: str = "Paracetamol", |
| 125 | + default_dose: float | None = None, |
| 126 | + default_t_end: float = 24.0, |
| 127 | + default_n_points: int = 300, |
| 128 | +) -> None: |
| 129 | + class _Handler(BaseHTTPRequestHandler): |
| 130 | + def do_GET(self): |
| 131 | + parsed = urlparse(self.path) |
| 132 | + if parsed.path == "/health": |
| 133 | + body = json.dumps({"status": "ok"}).encode("utf-8") |
| 134 | + self.send_response(200) |
| 135 | + self.send_header("Content-Type", "application/json; charset=utf-8") |
| 136 | + self.send_header("Content-Length", str(len(body))) |
| 137 | + self.end_headers() |
| 138 | + self.wfile.write(body) |
| 139 | + return |
| 140 | + |
| 141 | + if parsed.path != "/": |
| 142 | + self.send_error(404, "Not found") |
| 143 | + return |
| 144 | + |
| 145 | + q = parse_qs(parsed.query) |
| 146 | + drug = q.get("drug", [default_drug])[0] |
| 147 | + dose_raw = q.get("dose", [default_dose])[0] |
| 148 | + t_end_raw = q.get("t_end", [default_t_end])[0] |
| 149 | + n_points_raw = q.get("n_points", [default_n_points])[0] |
| 150 | + |
| 151 | + try: |
| 152 | + dose = None if dose_raw is None else float(dose_raw) |
| 153 | + t_end = float(t_end_raw) |
| 154 | + n_points = int(n_points_raw) |
| 155 | + payload = build_web_app_payload( |
| 156 | + dataset=dataset, |
| 157 | + drug=drug, |
| 158 | + dose=dose, |
| 159 | + t_end=t_end, |
| 160 | + n_points=n_points, |
| 161 | + ) |
| 162 | + body = render_web_app_html(payload).encode("utf-8") |
| 163 | + self.send_response(200) |
| 164 | + self.send_header("Content-Type", "text/html; charset=utf-8") |
| 165 | + self.send_header("Content-Length", str(len(body))) |
| 166 | + self.end_headers() |
| 167 | + self.wfile.write(body) |
| 168 | + except Exception as exc: |
| 169 | + body = f"<h1>Invalid request</h1><pre>{escape(str(exc))}</pre>".encode("utf-8") |
| 170 | + self.send_response(400) |
| 171 | + self.send_header("Content-Type", "text/html; charset=utf-8") |
| 172 | + self.send_header("Content-Length", str(len(body))) |
| 173 | + self.end_headers() |
| 174 | + self.wfile.write(body) |
| 175 | + |
| 176 | + def log_message(self, format, *args): |
| 177 | + return |
| 178 | + |
| 179 | + server = ThreadingHTTPServer((host, int(port)), _Handler) |
| 180 | + print(f"OpenDose web app running at http://{host}:{int(port)}") |
| 181 | + server.serve_forever() |
0 commit comments