Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cf00a8a
Added server subcommand with web UI
Mar 24, 2026
70651c9
Added uv.lock and pyproject.toml; Added tooltips to the UI; Added opt…
Mar 31, 2026
143b890
Converted regex to compiled form and fullmatch instead of match
sanketwagle Mar 31, 2026
ccaabc7
Nitpick: conditions like !(a <= x<= b) should be x <= a or x>=b to ta…
sanketwagle Mar 31, 2026
c8a3441
Merge pull request #8 from sanketwagle/web-server
sriram98v Mar 31, 2026
73a72e7
feat: replace phylotree.js with paper.js canvas renderer + annotation…
Jun 15, 2026
6d66de2
feat: improve tree layout, legend, and theme-aware export
Jun 16, 2026
2bbc07c
fix: move legend to dedicated right column with padding to avoid labe…
Jun 16, 2026
62543ed
feat: add sidebar toggle, results collapse, and tree maximize controls
Jun 16, 2026
be2f6f0
feat: tabbed sidebar with results panel, legend to top, collapse anim…
Jun 18, 2026
44b53b1
feat: collapsible sections, smooth animations, fix sidebar resize lag
Jun 18, 2026
ef2c9ef
feat: remove sidebar-collapse btn, sweep-bar, maximize btn; smooth zo…
Jun 18, 2026
18005b8
feat: sweep sidebar tab, evaluate legend captions, evaluate export, t…
Jun 18, 2026
530f506
perf: warm up numba JIT at server start to eliminate 13s first-query …
Jun 18, 2026
4047896
feat: merge sweep into results tab, fix scroll/legend/panning/memoiza…
Jun 19, 2026
7ac184a
refactor: remove dead JS/CSS, implement web perf wins
Jun 19, 2026
c833e6f
perf: cache leaves() via WeakMap, fix sweepCache weights key
Jun 19, 2026
f1329cb
perf: memoize _effectiveColors, hoist cladeGroups to Map in renderer
Jun 19, 2026
10695ad
perf: reuse SymbolDefinition for leaf dots, reducing paper.js item count
Jun 19, 2026
2dcb862
fix: crash on redraw — SymbolDefinition has no remove() method
Jun 19, 2026
dbddd8b
style: compact header — h1→h2, tagline inline, reduce padding
Jun 19, 2026
b98c6e1
Merge branch 'web-server' of https://github.com/flu-crew/parnas into …
Jun 19, 2026
4dd8cac
Revert "style: compact header — h1→h2, tagline inline, reduce padding"
Jun 22, 2026
5bcdfb5
Reapply "style: compact header — h1→h2, tagline inline, reduce padding"
Jun 22, 2026
423274e
feat: dual sweep modes with publication-quality figure
Jun 25, 2026
48c5eaa
feat: sweep/tree viewer QOL improvements
Jun 26, 2026
46eadb6
feat: add performance benchmark scripts and update dependencies
Jun 26, 2026
16c2a85
docs: document parnas server subcommand; add study + supplementary data
Jul 2, 2026
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*.aln
*.fasta
*.fna
outputs/
manuscript/

# TreeTime output
treetime*
Expand Down Expand Up @@ -45,3 +47,10 @@ sims/
tutorial/workfiles/
testfiles/
misc/

# Local working notes (not shipped)
parnas/web/OPTIMIZATION.md

# Application-study datasets (tracked despite global fasta/aln ignores)
!data/
!data/*
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ parnas -t genbank_H1N1pdm_USA.rooted.tre --cover --radius 0.005 --subtree H1N1pd
```
This results in a tab-delimited file `r005.clusters.tab`, where taxa beloning to the same cluster are labeled by the same cluster index.

## Web UI ##
PARNAS ships an interactive web interface for exploring representative
selection, sweeps, and tree visualization. Launch it with:

`parnas server [--ip IP] [--port PORT]`

- `--ip` IP address to listen on (default: localhost)
- `--port` Port to listen on (default: 8080)

Then open http://localhost:8080 in a browser.
Requires Flask (`pip install flask`).

## PARNAS usage ##

`parnas -t TREE [-n SAMPLES] [other options]`
Expand Down
211 changes: 211 additions & 0 deletions bench/benchmark_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Benchmark POST /api/run latency and server memory vs tree size.

Usage:
python outputs/bench/benchmark_api.py [--port 8765] [--repeats 5]

Outputs:
outputs/bench/bench_results.csv
"""

import argparse
import csv
import io
import os
import statistics
import subprocess
import sys
import time

import random

import dendropy
import psutil
import requests

# Tree sizes to benchmark (number of taxa)
TREE_SIZES = [100, 500, 1000, 2000, 5000]
SEED = 42
N_REPS = 5 # requests per tree size
FIXED_N = 20 # representatives to select
WARMUP = 2 # warm-up requests before timing

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
OUT_CSV = os.path.join(SCRIPT_DIR, "bench_results.csv")


# ---------------------------------------------------------------------------
# Tree generation
# ---------------------------------------------------------------------------

def generate_newick(n_taxa: int, seed: int) -> str:
"""Return a Newick string for a birth-death tree with n_taxa leaves."""
rng = random.Random(seed)
tree = dendropy.simulate.treesim.birth_death_tree(
birth_rate=1.0,
death_rate=0.0,
num_extant_tips=n_taxa,
rng=rng,
)
return tree.as_string(schema="newick")


# ---------------------------------------------------------------------------
# Server management
# ---------------------------------------------------------------------------

def start_server(port: int) -> subprocess.Popen:
"""Start parnas server as a subprocess via uv run."""
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None) # don't let parent venv override uv's choice
proc = subprocess.Popen(
["uv", "run", "parnas", "server", "--port", str(port)],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
)
# Wait until ready (poll /api/run endpoint)
url = f"http://localhost:{port}"
# Wait for server to start (only break on successful connection from OUR process)
for _ in range(90):
if proc.poll() is not None:
out, _ = proc.communicate()
raise RuntimeError(f"Server exited early: {out[:300]}")
try:
requests.get(url, timeout=1)
break
except Exception:
time.sleep(0.5)
else:
proc.terminate()
raise RuntimeError(f"Server did not start on port {port}")
time.sleep(1) # let warmup solver finish
return proc


def stop_server(proc: subprocess.Popen) -> None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()


# ---------------------------------------------------------------------------
# Single request benchmark
# ---------------------------------------------------------------------------

def post_run(url: str, newick: str) -> float:
"""POST /api/run and return wall-clock seconds."""
data = {
"cover": "false",
"evaluate": "false",
"binary": "false",
"sweep": "none",
"n": str(FIXED_N),
}
files = {
"tree": ("tree.nwk", io.BytesIO(newick.encode()), "text/plain"),
}
t0 = time.perf_counter()
resp = requests.post(f"{url}/api/run", data=data, files=files, timeout=300)
elapsed = time.perf_counter() - t0
if resp.status_code != 200:
raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
return elapsed


def sample_server_rss(proc: subprocess.Popen) -> float:
"""Return current RSS in MB for the server process (best-effort)."""
try:
ps = psutil.Process(proc.pid)
return ps.memory_info().rss / 1024 / 1024
except Exception:
return float("nan")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--repeats", type=int, default=N_REPS)
parser.add_argument("--sizes", nargs="+", type=int, default=TREE_SIZES)
args = parser.parse_args()

url = f"http://localhost:{args.port}"

print(f"Starting PARNAS server on port {args.port} ...", flush=True)
proc = start_server(args.port)
print("Server ready.", flush=True)

rows = []
try:
for n_taxa in args.sizes:
print(f"\n--- n_taxa={n_taxa} ---", flush=True)
newick = generate_newick(n_taxa, SEED)

# Warm-up
for _ in range(WARMUP):
post_run(url, newick)

latencies = []
rss_samples = []
for rep in range(args.repeats):
rss_before = sample_server_rss(proc)
lat = post_run(url, newick)
rss_after = sample_server_rss(proc)
peak_mb = max(rss_before, rss_after)
latencies.append(lat)
rss_samples.append(peak_mb)
print(
f" rep {rep+1}: latency={lat:.3f}s RSS≈{peak_mb:.1f}MB",
flush=True,
)

median_lat = statistics.median(latencies)
iqr_lat = (
sorted(latencies)[int(len(latencies) * 0.75)]
- sorted(latencies)[int(len(latencies) * 0.25)]
)
peak_mem = max(rss_samples)
rows.append(
{
"n_taxa": n_taxa,
"latency_median_s": round(median_lat, 4),
"latency_iqr_s": round(iqr_lat, 4),
"peak_mem_mb": round(peak_mem, 1),
}
)
print(
f" → median={median_lat:.3f}s IQR={iqr_lat:.3f}s peakMem={peak_mem:.1f}MB"
)
finally:
stop_server(proc)
print("\nServer stopped.", flush=True)

# Write CSV
os.makedirs(os.path.dirname(OUT_CSV), exist_ok=True)
with open(OUT_CSV, "w", newline="") as f:
writer = csv.DictWriter(
f,
fieldnames=["n_taxa", "latency_median_s", "latency_iqr_s", "peak_mem_mb"],
)
writer.writeheader()
writer.writerows(rows)

print(f"\nResults written to {OUT_CSV}")
for row in rows:
print(
f" n_taxa={row['n_taxa']:>6} lat={row['latency_median_s']}s"
f" mem={row['peak_mem_mb']}MB"
)


if __name__ == "__main__":
main()
86 changes: 86 additions & 0 deletions bench/plot_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Generate benchmark figure and LaTeX table from bench_results.csv.

Usage:
python outputs/bench/plot_bench.py

Outputs:
manuscript/images/bench-latency.png
outputs/bench/bench_table.tex
"""

import csv
import os

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))

CSV_PATH = os.path.join(SCRIPT_DIR, "bench_results.csv")
IMG_OUT = os.path.join(SCRIPT_DIR, "bench-latency.png")

def load_results(path: str):
rows = []
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append({
"n_taxa": int(row["n_taxa"]),
"latency_median_s": float(row["latency_median_s"]),
"latency_iqr_s": float(row["latency_iqr_s"]),
"peak_mem_mb": float(row["peak_mem_mb"]),
})
return rows


def make_plot(rows, out_path: str):
n = [r["n_taxa"] for r in rows]
lat = [r["latency_median_s"] for r in rows]
iqr = [r["latency_iqr_s"] for r in rows]
mem = [r["peak_mem_mb"] for r in rows]

fig, ax1 = plt.subplots(figsize=(5, 3.5))

color_lat = "#2166ac"
color_mem = "#d6604d"

ax1.errorbar(n, lat, yerr=[i / 2 for i in iqr],
color=color_lat, marker="o", linewidth=1.5,
capsize=3, label="API latency (s)")
ax1.set_xlabel("Number of taxa")
ax1.set_ylabel("Median API latency (s)", color=color_lat)
ax1.tick_params(axis="y", labelcolor=color_lat)
ax1.set_xscale("log")
ax1.xaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f"{int(x):,}"))
ax1.grid(True, which="both", linestyle="--", linewidth=0.5, alpha=0.5)

ax2 = ax1.twinx()
ax2.plot(n, mem, color=color_mem, marker="s", linestyle="--",
linewidth=1.5, label="Peak memory (MB)")
ax2.set_ylabel("Peak server RSS (MB)", color=color_mem)
ax2.tick_params(axis="y", labelcolor=color_mem)

# Combined legend
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=8, loc="upper left")

fig.tight_layout()
os.makedirs(os.path.dirname(out_path), exist_ok=True)
fig.savefig(out_path, dpi=300, bbox_inches="tight")
print(f"Figure written to {out_path}")

def main():
if not os.path.exists(CSV_PATH):
print(f"ERROR: {CSV_PATH} not found. Run benchmark_api.py first.")
raise SystemExit(1)
rows = load_results(CSV_PATH)
make_plot(rows, IMG_OUT)


if __name__ == "__main__":
main()
Loading