""" VesiScan CSV Analyzer — App 측정 결과 (raw ADC + computed BV) 를 Python pipeline (appshare `for_app_share`) 으로 재계산하고 비교. Usage: python tools/analyze_csv.py --true [options] Options: --true VOL Known phantom/catheter volume (mL). Required. --ablation Run 5 algorithm-config comparison (DPS, lr floor 등) --plot DIR Save matplotlib plots to DIR (BV timeseries, lr dist, per-ch) --cycle SID Inspect single scan_id detail (walls, signals) --appshare PATH Path to appshare repo. Default $APPSHARE_DIR or C:/Projects/appshare/piezo-phantom-test Examples: # Basic compare on phantom 150 mL CSV python tools/analyze_csv.py ~/Desktop/measure.csv --true 150 # With ablation (which fix breaks what) python tools/analyze_csv.py ~/Desktop/measure.csv --true 150 --ablation # With plots python tools/analyze_csv.py ~/Desktop/measure.csv --true 195 --plot ./out # Inspect specific cycle python tools/analyze_csv.py ~/Desktop/measure.csv --true 150 --cycle 42 CSV format (from AdcCsvLogger): scan_id, timestamp, ..., volume_ml, lr_ratio, ..., channel, s0..s99 One row per channel (CH0..CH5), 100 samples per row. """ from __future__ import annotations import argparse import csv import os import sys from collections import defaultdict from pathlib import Path from statistics import mean, median, stdev def _find_appshare(arg_path: str | None) -> Path: candidates = [] if arg_path: candidates.append(Path(arg_path)) env = os.environ.get("APPSHARE_DIR") if env: candidates.append(Path(env)) candidates.extend([ Path(r"C:/Projects/appshare/piezo-phantom-test"), Path.home() / "Projects/appshare/piezo-phantom-test", Path.home() / "appshare/piezo-phantom-test", ]) for c in candidates: if (c / "for_app_share" / "__init__.py").exists(): return c sys.exit(f"ERROR: appshare repo not found. Tried: {candidates}\n" f" Set --appshare PATH or APPSHARE_DIR env var.") def load_scans(csv_path: Path) -> dict: """Group CSV rows by scan_id → {sid: {meta, channels: {ch: [samples]}}}""" scans: dict = defaultdict(lambda: {"meta": None, "channels": {}}) with open(csv_path, encoding="utf-8") as f: for row in csv.DictReader(f): sid = int(row["scan_id"]) ch = int(row["channel"].replace("CH", "")) samples = [] for i in range(100): v = row.get(f"s{i}", "").strip() if v == "": break try: samples.append(int(v)) except ValueError: break scans[sid]["channels"][ch] = samples if scans[sid]["meta"] is None: scans[sid]["meta"] = { "timestamp": row.get("timestamp"), "app_vol": float(row["volume_ml"]) if row.get("volume_ml") else None, "app_lr": float(row["lr_ratio"]) if row.get("lr_ratio") else None, "fw": row.get("firmware_version", ""), "dps": float(row["dps"]) if row.get("dps") else None, } return scans def trimmed_mean(vals, trim_frac: float = 0.1): if not vals: return None if len(vals) < 5: return mean(vals) s = sorted(vals) k = max(1, int(len(s) * trim_frac)) return mean(s[k:len(s) - k]) # ── Mode 1: basic compare ──────────────────────────────────────────────────── def mode_compare(scans, true_vol, py): """App-computed BV vs Python-recomputed BV per scan.""" import numpy as np valid = [] for sid, s in sorted(scans.items()): if len(s["channels"]) != 6: continue if any(len(s["channels"][c]) < 100 for c in range(6)): continue sigs = np.stack([np.asarray(s["channels"][ch], dtype=float) for ch in range(6)]) walls = py.detect_walls_multichannel(sigs) walls_py = [None if w is None or w[0] is None or w[1] is None else tuple(w) for w in walls] bv = py.estimate_bladder_volume_6ch(walls_py) valid.append({ "sid": sid, "app_vol": s["meta"]["app_vol"], "app_lr": s["meta"]["app_lr"], "py_vol": bv.volume_ml if bv else None, "py_lr": bv.lr_ratio if bv else None, "center": sum(1 for i in (0, 1, 2, 3) if walls_py[i] is not None), "lateral": sum(1 for i in (4, 5) if walls_py[i] is not None), }) app_vols = [r["app_vol"] for r in valid if r["app_vol"] is not None] py_vols = [r["py_vol"] for r in valid if r["py_vol"] is not None] print(f"Valid scans: {len(valid)} (true volume = {true_vol} mL)\n") def stats(label, vals, lrs=None): if not vals: print(f" {label}: no data"); return m = mean(vals); sd = stdev(vals) if len(vals) > 1 else 0 b = m - true_vol cv = sd / m * 100 if m else 0 tm = trimmed_mean(vals) lr_str = "" if lrs: lr_clean = [x for x in lrs if x is not None] if lr_clean: lr_str = f" lr mean={mean(lr_clean):.3f}" print(f" {label:>12}: mean={m:>6.1f}±{sd:>4.1f} trim10%={tm:>6.1f} " f"bias={b:>+6.1f} ({b/true_vol*100:>+5.1f}%) CV={cv:>4.1f}%{lr_str}") print("=== BV comparison ===") stats("APP (Kotlin)", app_vols, [r["app_lr"] for r in valid]) stats("Python", py_vols, [r["py_lr"] for r in valid]) if app_vols and py_vols: diffs = [a - p for a, p in zip(app_vols, py_vols)] print(f"\n=== App ↔ Python diff ===") print(f" mean={mean(diffs):>+6.2f} std={stdev(diffs):>5.2f} " f"range=[{min(diffs):>+6.1f}, {max(diffs):>+6.1f}]") print(f" |diff| < 5 mL: {sum(1 for d in diffs if abs(d) < 5)}/{len(diffs)} " f"({sum(1 for d in diffs if abs(d) < 5) / len(diffs) * 100:.1f}%)") print(f" |diff| < 1 mL: {sum(1 for d in diffs if abs(d) < 1)}/{len(diffs)}") print(f"\n=== Method D detection ===") print(f" 4/4 center: {sum(1 for r in valid if r['center'] == 4) / len(valid) * 100:.1f}%") print(f" >=3/4 center: {sum(1 for r in valid if r['center'] >= 3) / len(valid) * 100:.1f}%") print(f" 2/2 lateral: {sum(1 for r in valid if r['lateral'] == 2) / len(valid) * 100:.1f}%") return valid # ── Mode 2: ablation ───────────────────────────────────────────────────────── CONFIGS = [ ("A. OLD eq (DPS=1.936, lr=1.0)", dict(distance_per_sample=1.936, lr_ratio_override=1.0)), ("B. DPS fix only", dict(distance_per_sample=1.981, lr_ratio_override=1.0)), ("C. NEW full (= b733d4f)", dict(distance_per_sample=1.981, lr_ratio_override=None)), ("D. NEW + lr floor 1.0", "hybrid_floor"), ("E. OLD DPS + new lr", dict(distance_per_sample=1.936, lr_ratio_override=None)), ] def mode_ablation(scans, true_vol, py): """Compare 5 algorithm configurations to isolate which fix matters.""" import numpy as np def bv_with(walls, cfg): if cfg == "hybrid_floor": r = py.estimate_bladder_volume_6ch(walls, distance_per_sample=1.981) if r is None: return None return py.estimate_bladder_volume_6ch( walls, distance_per_sample=1.981, lr_ratio_override=max(r.lr_ratio, 1.0)) return py.estimate_bladder_volume_6ch(walls, **cfg) results = {name: ([], []) for name, _ in CONFIGS} # (vols, lrs) app_vols = [] valid_n = 0 for sid, s in sorted(scans.items()): if len(s["channels"]) != 6: continue if any(len(s["channels"][c]) < 100 for c in range(6)): continue sigs = np.stack([np.asarray(s["channels"][ch], dtype=float) for ch in range(6)]) walls = py.detect_walls_multichannel(sigs) walls_py = [None if w is None or w[0] is None or w[1] is None else tuple(w) for w in walls] center = sum(1 for i in (0, 1, 2, 3) if walls_py[i] is not None) if center < 4: continue valid_n += 1 for name, cfg in CONFIGS: r = bv_with(walls_py, cfg) if r is not None: results[name][0].append(r.volume_ml) results[name][1].append(r.lr_ratio) if s["meta"]["app_vol"] is not None: app_vols.append(s["meta"]["app_vol"]) print(f"Valid scans: {valid_n} (true = {true_vol} mL)\n") print(f"{'config':<32} {'mean':>7} {'std':>6} {'bias':>7} {'%':>6} {'CV%':>5} {'lr':>6}") print("-" * 90) if app_vols: m = mean(app_vols); sd = stdev(app_vols) if len(app_vols) > 1 else 0 b = m - true_vol; cv = sd / m * 100 if m else 0 print(f"{'APP (Kotlin, OLD APK)':<32} {m:>7.1f} {sd:>6.1f} {b:>+7.1f} " f"{b/true_vol*100:>+5.1f}% {cv:>4.1f}% -") for name, _ in CONFIGS: vs, lrs = results[name] if not vs: continue m = mean(vs); sd = stdev(vs) if len(vs) > 1 else 0 b = m - true_vol; cv = sd / m * 100 if m else 0 lr_clean = [x for x in lrs if x is not None] lr_str = f"{mean(lr_clean):.3f}" if lr_clean else " -" print(f"{name:<32} {m:>7.1f} {sd:>6.1f} {b:>+7.1f} " f"{b/true_vol*100:>+5.1f}% {cv:>4.1f}% {lr_str:>6}") # ── Mode 3: plot ───────────────────────────────────────────────────────────── def mode_plot(valid, true_vol, out_dir: Path): try: import matplotlib.pyplot as plt except ImportError: print("matplotlib not installed — skipping plots. pip install matplotlib") return out_dir.mkdir(parents=True, exist_ok=True) sids = [r["sid"] for r in valid] app_vols = [r["app_vol"] if r["app_vol"] is not None else float("nan") for r in valid] py_vols = [r["py_vol"] if r["py_vol"] is not None else float("nan") for r in valid] # 1. BV timeseries fig, ax = plt.subplots(figsize=(12, 5)) ax.plot(sids, app_vols, "-", lw=1, alpha=0.6, label="APP") ax.plot(sids, py_vols, "-", lw=1, alpha=0.6, label="Python") ax.axhline(true_vol, ls="--", color="g", label=f"true = {true_vol} mL") ax.set_xlabel("scan_id"); ax.set_ylabel("BV (mL)") ax.set_title(f"BV time series (n={len(sids)})") ax.legend(); ax.grid(alpha=0.3) fig.tight_layout() fig.savefig(out_dir / "01_bv_timeseries.png", dpi=120) plt.close(fig) # 2. App vs Python scatter app_clean = [a for a, p in zip(app_vols, py_vols) if not (a != a or p != p)] py_clean = [p for a, p in zip(app_vols, py_vols) if not (a != a or p != p)] fig, ax = plt.subplots(figsize=(7, 7)) ax.scatter(app_clean, py_clean, alpha=0.4, s=10) lo, hi = min(app_clean + py_clean), max(app_clean + py_clean) ax.plot([lo, hi], [lo, hi], "r--", lw=1, label="y=x") ax.axhline(true_vol, ls=":", color="g", alpha=0.5) ax.axvline(true_vol, ls=":", color="g", alpha=0.5, label=f"true {true_vol} mL") ax.set_xlabel("APP BV (mL)"); ax.set_ylabel("Python BV (mL)") ax.set_title("APP vs Python — per-scan agreement") ax.legend(); ax.grid(alpha=0.3); ax.set_aspect("equal") fig.tight_layout() fig.savefig(out_dir / "02_app_vs_python_scatter.png", dpi=120) plt.close(fig) # 3. lr_ratio distribution py_lrs = [r["py_lr"] for r in valid if r["py_lr"] is not None] app_lrs = [r["app_lr"] for r in valid if r["app_lr"] is not None] fig, ax = plt.subplots(figsize=(10, 4)) bins = 50 if app_lrs: ax.hist(app_lrs, bins=bins, alpha=0.5, label=f"APP (n={len(app_lrs)})") if py_lrs: ax.hist(py_lrs, bins=bins, alpha=0.5, label=f"Python (n={len(py_lrs)})") ax.axvline(1.0, ls="--", color="k", alpha=0.5, label="lr = 1.0") ax.set_xlabel("lr_ratio"); ax.set_ylabel("count") ax.set_title("lr_ratio distribution") ax.legend(); ax.grid(alpha=0.3) fig.tight_layout() fig.savefig(out_dir / "03_lr_ratio_hist.png", dpi=120) plt.close(fig) print(f"Plots saved: {out_dir.resolve()}") # ── Mode 4: single cycle inspect ───────────────────────────────────────────── def mode_cycle(scans, sid, py): if sid not in scans: print(f"scan_id={sid} not found. Range: {min(scans)}..{max(scans)}") return import numpy as np s = scans[sid] print(f"=== Scan {sid} ({s['meta']['timestamp']}) ===") print(f"App BV: {s['meta']['app_vol']} mL lr={s['meta']['app_lr']}\n") sigs = np.stack([np.asarray(s["channels"][ch], dtype=float) for ch in range(6)]) walls = py.detect_walls_multichannel(sigs) print(f"{'CH':>3} {'peak':>5} {'idx':>4} {'ant':>4} {'post':>5} {'urine_len':>10}") for ch in range(6): sig = sigs[ch] peak = int(sig.max()) if len(sig) else 0 pidx = int(sig.argmax()) if len(sig) else 0 w = walls[ch] if w is None or w[0] is None: print(f"{ch:>3} {peak:>5} {pidx:>4} - - -") else: ul = w[1] - w[0] print(f"{ch:>3} {peak:>5} {pidx:>4} {w[0]:>4} {w[1]:>5} {ul:>10}") walls_py = [None if w is None or w[0] is None or w[1] is None else tuple(w) for w in walls] bv = py.estimate_bladder_volume_6ch(walls_py) if bv: print(f"\nPython BV: {bv.volume_ml:.1f} mL lr={bv.lr_ratio:.3f}") else: print("\nPython BV: failed (< 4 center walls or fit failed)") def main(): p = argparse.ArgumentParser( description="VesiScan CSV analyzer", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__) p.add_argument("csv", type=Path, help="App-exported CSV path") p.add_argument("--true", dest="true_vol", type=float, required=True, help="Known true volume (mL)") p.add_argument("--ablation", action="store_true", help="Run 5 algorithm-config comparison") p.add_argument("--plot", type=Path, metavar="DIR", help="Save plots to DIR") p.add_argument("--cycle", type=int, metavar="SID", help="Inspect single scan_id detail") p.add_argument("--appshare", type=str, default=None, help="Path to appshare repo (default: auto-detect)") args = p.parse_args() if not args.csv.exists(): sys.exit(f"CSV not found: {args.csv}") appshare_dir = _find_appshare(args.appshare) sys.path.insert(0, str(appshare_dir)) # Lazy imports so --help works without numpy etc. import numpy as np # noqa import for_app_share as _fas import vesiscan_test.library.method_d as _md class _PyAPI: pass _PyAPI.estimate_bladder_volume_6ch = staticmethod(_fas.estimate_bladder_volume_6ch) _PyAPI.detect_walls_multichannel = staticmethod(_md.detect_walls_multichannel) print(f"Loading {args.csv.name}...") scans = load_scans(args.csv) print(f" {len(scans)} scans\n") if args.cycle is not None: mode_cycle(scans, args.cycle, _PyAPI) return if args.ablation: print("─" * 60); print("ABLATION MODE"); print("─" * 60) mode_ablation(scans, args.true_vol, _PyAPI) print() print("─" * 60); print("COMPARE MODE"); print("─" * 60) valid = mode_compare(scans, args.true_vol, _PyAPI) if args.plot: print() mode_plot(valid, args.true_vol, args.plot) if __name__ == "__main__": main()