Skip to content

Commit a96ca0b

Browse files
committed
feat: add baseline local web app interface
1 parent aa94108 commit a96ca0b

8 files changed

Lines changed: 366 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ The library bridges classical compartmental pharmacology and modern control theo
3434
- **Population PK mixed-effects fitting** — estimate fixed effects (theta) and random effects (omega/eta)
3535
- **Bootstrap uncertainty for population fit** — confidence intervals for F/ka/ke/Vd
3636
- **External validation toolkit** — compare model predictions with observed and reference-software concentrations
37+
- **Web app baseline** — lightweight local browser interface for quick PK profile exploration
3738
- **End-to-end TDM workflow command** — run full clinical pipeline in one execution
3839
- **Multi-drug regimen benchmarking** — compare Cmax/trough/AUC across selected compounds
3940
- **Mixed-drug TDM fitting** — run MAP fits when a single CSV contains multiple drugs
@@ -87,7 +88,7 @@ On Windows: `make.bat test` or `python -m pytest -q`
8788

8889
With coverage: `pip install .[dev]` then `pytest --cov=opendose_poppk --cov-report=term-missing`
8990

90-
Latest local validation: March 5, 2026 (Python 3.14.2), `python -m pytest -q` -> `181 passed`.
91+
Latest local validation: March 5, 2026 (Python 3.14.2), `python -m pytest -q` -> `186 passed`.
9192

9293
### PyPI publishing (maintainers)
9394

@@ -124,6 +125,7 @@ opendose fit-population --input data/tdm.csv --maxiter 2000 --bootstrap-n 200 --
124125
opendose fit-population-mixed --drug Paracetamol --input data/tdm.csv --maxiter 1200 --eta-csv output/tables/pop_mixed_eta.csv --output-json output/reports/pop_mixed_fit.json
125126
opendose init-external-template --output data/external_validation_template.csv
126127
opendose validate-external --drug Paracetamol --input data/external_validation.csv --predictions-csv output/tables/external_predictions.csv --output-json output/reports/external_validation.json
128+
opendose web-app --drug Paracetamol --dose 750 --t-end 12 --output-html output/web/web_app.html --dry-run
127129
opendose init-tdm-template --output data/tdm_template.csv
128130
opendose init-tdm-template --format clinical --output data/tdm_template_clinical.csv
129131
opendose run-tdm-workflow --drug Paracetamol --input data/tdm.csv --outdir output/workflows/tdm_paracetamol

docs/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ lightweight library.
2828
:target: https://opensource.org/licenses/MIT
2929
:alt: License: MIT
3030

31-
Latest local validation: March 5, 2026 (Python 3.14.2), ``python -m pytest -q`` -> ``181 passed``.
31+
Latest local validation: March 5, 2026 (Python 3.14.2), ``python -m pytest -q`` -> ``186 passed``.
3232

3333
----
3434

docs/usage.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,24 @@ CLI External Validation
225225
--predictions-csv output/tables/external_predictions.csv \
226226
--output-json output/reports/external_validation.json
227227
228+
CLI Web App Baseline
229+
--------------------
230+
231+
.. code-block:: bash
232+
233+
# Dry-run mode (testable): generates HTML and exits.
234+
opendose web-app \
235+
--drug Paracetamol \
236+
--dose 750 \
237+
--t-end 12 \
238+
--output-html output/web/web_app.html \
239+
--dry-run
240+
241+
.. code-block:: bash
242+
243+
# Local server mode:
244+
opendose web-app --drug Paracetamol --host 127.0.0.1 --port 8000
245+
228246
CLI Dose Recommendation
229247
-----------------------
230248

opendose_poppk/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
summarize_external_validation,
3232
write_external_validation_template_csv,
3333
)
34+
from .web_app import build_web_app_payload, render_web_app_html, run_web_app_server, write_web_app_html
3435
from .benchmark import benchmark_regimen_across_drugs, write_benchmark_csv
3536
from .dosing import recommend_dose_for_target_auc, recommend_dose_for_target_cmax
3637
from .dose_sweep import sweep_dose_response
@@ -84,6 +85,10 @@
8485
"build_external_validation_table",
8586
"summarize_external_validation",
8687
"write_external_validation_template_csv",
88+
"build_web_app_payload",
89+
"render_web_app_html",
90+
"write_web_app_html",
91+
"run_web_app_server",
8792
"benchmark_regimen_across_drugs",
8893
"write_benchmark_csv",
8994
"recommend_dose_for_target_auc",

opendose_poppk/cli.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
)
4040
from .tdm_mixed import fit_tdm_mixed_by_drug, summarize_tdm_mixed_fit
4141
from .tdm_report import write_tdm_fit_markdown_report, write_tdm_prediction_plot
42+
from .web_app import build_web_app_payload, run_web_app_server, write_web_app_html
4243

4344

4445
def _default_dataset() -> str:
@@ -658,6 +659,63 @@ def cmd_init_external_template(args: argparse.Namespace) -> int:
658659
return 0
659660

660661

662+
def _run_web_app_server_mode(args: argparse.Namespace, dose: float | None) -> int: # pragma: no cover
663+
if args.output_html:
664+
payload = build_web_app_payload(
665+
dataset=args.dataset,
666+
drug=args.drug,
667+
dose=dose,
668+
t_end=float(args.t_end),
669+
n_points=int(args.n_points),
670+
)
671+
write_web_app_html(payload, args.output_html)
672+
run_web_app_server(
673+
dataset=args.dataset,
674+
host=str(args.host),
675+
port=int(args.port),
676+
default_drug=str(args.drug),
677+
default_dose=dose,
678+
default_t_end=float(args.t_end),
679+
default_n_points=int(args.n_points),
680+
)
681+
return 0
682+
683+
684+
def cmd_web_app(args: argparse.Namespace) -> int:
685+
dose = float(args.dose) if args.dose is not None else None
686+
if args.dry_run:
687+
payload = build_web_app_payload(
688+
dataset=args.dataset,
689+
drug=args.drug,
690+
dose=dose,
691+
t_end=float(args.t_end),
692+
n_points=int(args.n_points),
693+
)
694+
output_html = None
695+
if args.output_html:
696+
output_html = write_web_app_html(payload, args.output_html)
697+
_print_json(
698+
{
699+
"command": "web-app",
700+
"mode": "dry-run",
701+
"drug": payload["drug"],
702+
"dose": payload["dose"],
703+
"t_end": payload["t_end"],
704+
"n_points": payload["n_points"],
705+
"cmax": payload["cmax"],
706+
"tmax": payload["tmax"],
707+
"auc_0_tend": payload["auc_0_tend"],
708+
"available_drugs": int(len(payload.get("available_drugs", []))),
709+
"output_html": output_html,
710+
"host": str(args.host),
711+
"port": int(args.port),
712+
}
713+
)
714+
return 0
715+
716+
return _run_web_app_server_mode(args=args, dose=dose) # pragma: no cover
717+
718+
661719
def cmd_run_tdm_workflow(args: argparse.Namespace) -> int:
662720
outdir = Path(args.outdir)
663721
outdir.mkdir(parents=True, exist_ok=True)
@@ -1194,6 +1252,17 @@ def build_parser() -> argparse.ArgumentParser:
11941252
p_ext_template.add_argument("--output", required=True, help="Output CSV path")
11951253
p_ext_template.set_defaults(func=cmd_init_external_template)
11961254

1255+
p_web = sub.add_parser("web-app", help="Run simple local web app baseline")
1256+
p_web.add_argument("--drug", default="Paracetamol", help="Default drug shown in the web app")
1257+
p_web.add_argument("--dose", type=float, default=None, help="Default dose; falls back to dataset dose when omitted")
1258+
p_web.add_argument("--t-end", type=float, default=24.0, help="Default simulation horizon in hours")
1259+
p_web.add_argument("--n-points", type=int, default=300, help="Default number of points in profile")
1260+
p_web.add_argument("--host", default="127.0.0.1", help="Bind host for local server")
1261+
p_web.add_argument("--port", type=int, default=8000, help="Bind port for local server")
1262+
p_web.add_argument("--output-html", default=None, help="Optional path to write initial web app HTML")
1263+
p_web.add_argument("--dry-run", action="store_true", help="Generate payload/HTML and exit without starting server")
1264+
p_web.set_defaults(func=cmd_web_app)
1265+
11971266
p_workflow = sub.add_parser("run-tdm-workflow", help="Run end-to-end TDM pipeline in one command")
11981267
p_workflow.add_argument("--input", required=True, help="Path to TDM CSV")
11991268
p_workflow.add_argument("--drug", required=True, help="Drug name from dataset")

opendose_poppk/web_app.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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()

tests/test_cli.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,42 @@ def test_cli_init_external_template(tmp_path, capsys):
744744
assert out_csv.exists()
745745

746746

747+
def test_cli_web_app_dry_run(tmp_path, capsys):
748+
out_html = tmp_path / "web_app.html"
749+
code = main(
750+
[
751+
"web-app",
752+
"--drug",
753+
"Paracetamol",
754+
"--dose",
755+
"750",
756+
"--t-end",
757+
"12",
758+
"--n-points",
759+
"80",
760+
"--output-html",
761+
str(out_html),
762+
"--dry-run",
763+
]
764+
)
765+
out = capsys.readouterr().out
766+
payload = json.loads(out)
767+
assert code == 0
768+
assert payload["command"] == "web-app"
769+
assert payload["mode"] == "dry-run"
770+
assert payload["drug"] == "Paracetamol"
771+
assert payload["dose"] == pytest.approx(750.0)
772+
assert payload["output_html"] == str(out_html)
773+
assert out_html.exists()
774+
775+
776+
def test_cli_web_app_validation(capsys):
777+
code = main(["web-app", "--drug", "Paracetamol", "--t-end", "0", "--dry-run"])
778+
err = capsys.readouterr().err
779+
assert code == 1
780+
assert "t_end must be positive" in err
781+
782+
747783
def test_cli_run_tdm_workflow(tmp_path, capsys):
748784
input_csv = tmp_path / "tdm_workflow.csv"
749785
outdir = tmp_path / "workflow_out"

0 commit comments

Comments
 (0)