From dd712d74a076800a216fcae158d3fe5ef0f08a5f Mon Sep 17 00:00:00 2001 From: Runze Date: Tue, 28 Apr 2026 13:54:16 -0400 Subject: [PATCH] add dockerized web GUI for AutoEIS plus auto-posterior-labeling --- gui/.dockerignore | 27 + gui/Dockerfile | 45 ++ gui/app.py | 66 +++ gui/assets/style.css | 47 ++ gui/docker-compose.yml | 35 ++ gui/eis_posterior_quality.py | 405 +++++++++++++ gui/fly.toml | 35 ++ gui/gunicorn_conf.py | 32 ++ gui/nginx/default.conf | 40 ++ gui/pages/__init__.py | 0 gui/pages/data.py | 1030 ++++++++++++++++++++++++++++++++++ gui/pages/fitting.py | 351 ++++++++++++ gui/pages/mode.py | 438 +++++++++++++++ gui/pages/results.py | 682 ++++++++++++++++++++++ gui/requirements.txt | 9 + gui/task_manager.py | 246 ++++++++ gui/utils.py | 324 +++++++++++ 17 files changed, 3812 insertions(+) create mode 100644 gui/.dockerignore create mode 100644 gui/Dockerfile create mode 100644 gui/app.py create mode 100644 gui/assets/style.css create mode 100644 gui/docker-compose.yml create mode 100644 gui/eis_posterior_quality.py create mode 100644 gui/fly.toml create mode 100644 gui/gunicorn_conf.py create mode 100644 gui/nginx/default.conf create mode 100644 gui/pages/__init__.py create mode 100644 gui/pages/data.py create mode 100644 gui/pages/fitting.py create mode 100644 gui/pages/mode.py create mode 100644 gui/pages/results.py create mode 100644 gui/requirements.txt create mode 100644 gui/task_manager.py create mode 100644 gui/utils.py diff --git a/gui/.dockerignore b/gui/.dockerignore new file mode 100644 index 00000000..26f235d9 --- /dev/null +++ b/gui/.dockerignore @@ -0,0 +1,27 @@ +__pycache__ +**/__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.git +.gitignore +.env +*.env +*.log + +# Don't copy local Julia depot into the image — it gets rebuilt during build +julia_depot/ + +# Editor / OS noise +.DS_Store +*.swp +*.swo +.vscode/ +.idea/ + +# Test / notebook artefacts +*.ipynb +.ipynb_checkpoints/ +htmlcov/ +.coverage diff --git a/gui/Dockerfile b/gui/Dockerfile new file mode 100644 index 00000000..2211ef8f --- /dev/null +++ b/gui/Dockerfile @@ -0,0 +1,45 @@ +# ── Stage 1: build ─────────────────────────────────────────────────────────── +FROM python:3.11-slim AS base + +# System tools needed to compile Python extensions and download Julia +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget ca-certificates git gcc g++ make \ + && rm -rf /var/lib/apt/lists/* + +# ── Install Julia ───────────────────────────────────────────────────────────── +ARG JULIA_VERSION=1.10.3 +RUN wget -qO /tmp/julia.tar.gz \ + "https://julialang-s3.julialang.org/bin/linux/x64/1.10/julia-${JULIA_VERSION}-linux-x86_64.tar.gz" \ + && mkdir -p /opt/julia \ + && tar -xzf /tmp/julia.tar.gz -C /opt/julia --strip-components=1 \ + && ln -s /opt/julia/bin/julia /usr/local/bin/julia \ + && rm /tmp/julia.tar.gz + +# Julia stores downloaded packages and precompiled .ji files here. +# Putting it outside /app means it survives COPY . . and can be a named volume. +ENV JULIA_DEPOT_PATH=/julia_depot +RUN mkdir -p /julia_depot + +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# ── Python dependencies ─────────────────────────────────────────────────────── +# Copy only requirements first so pip layer is cached on code-only changes +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# ── App source ──────────────────────────────────────────────────────────────── +COPY . . + +# ── Pre-install and precompile Julia packages ───────────────────────────────── +# Running this during build bakes the compiled .ji files into the image so +# container startup is fast (~5 s) instead of requiring a 5-10 min cold-compile. +RUN python3 -c "\ +import autoeis as ae; \ +import numpy as np; \ +print('AutoEIS + Julia packages installed and precompiled.')" + +EXPOSE 8050 + +CMD ["gunicorn", "-c", "gunicorn_conf.py", "app:server"] diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 00000000..311b0cb5 --- /dev/null +++ b/gui/app.py @@ -0,0 +1,66 @@ +import dash +from dash import Dash, dcc, html +import dash_bootstrap_components as dbc + +app = Dash( + __name__, + use_pages=True, + external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.icons.FONT_AWESOME], + suppress_callback_exceptions=True, + title="AutoEIS GUI", +) + +STEPS = [ + ("① Data", "/"), + ("② Mode", "/mode"), + ("③ Fitting", "/fitting"), + ("④ Results", "/results"), +] + +navbar = dbc.Navbar( + dbc.Container( + [ + dbc.NavbarBrand( + html.Span( + [html.I(className="fa fa-bolt me-2 text-warning"), "AutoEIS GUI"], + className="fw-bold fs-5", + ) + ), + dbc.Nav( + [ + dbc.NavItem(dbc.NavLink(label, href=href, active="exact", className="fw-semibold")) + for label, href in STEPS + ], + className="ms-auto gap-1", + navbar=True, + ), + ], + fluid=True, + ), + color="dark", + dark=True, + sticky="top", + className="mb-4 shadow", +) + +app.layout = html.Div( + [ + # Shared state stores — all in app layout so they survive page navigation + dcc.Store(id="store-data", storage_type="memory"), + dcc.Store(id="store-mode", storage_type="memory"), + dcc.Store(id="store-task-id", storage_type="memory"), + dcc.Store(id="store-results", storage_type="memory"), + # Data-page stores (moved here so data page state survives navigation) + dcc.Store(id="store-raw", storage_type="memory"), + dcc.Store(id="store-deleted", data=[]), + dcc.Store(id="store-pending-delete", data=[]), + dcc.Store(id="store-df-cols", storage_type="memory"), + navbar, + dbc.Container(dash.page_container, fluid=True, className="pb-5"), + ] +) + +server = app.server # expose for gunicorn/deployment + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=8050) diff --git a/gui/assets/style.css b/gui/assets/style.css new file mode 100644 index 00000000..b2870d46 --- /dev/null +++ b/gui/assets/style.css @@ -0,0 +1,47 @@ +/* AutoEIS GUI – custom styles */ + +body { + background-color: #f8f9fa; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; +} + +/* Navbar brand */ +.navbar-brand { + font-size: 1.3rem; +} + +/* Card hover effect for Quick Mode selection */ +[id^="{"type":"quick-card"]:hover { + border-color: #0d6efd !important; + box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +/* DataTable tweaks */ +.dash-table-container .dash-spreadsheet-container .dash-spreadsheet-inner td { + font-family: "Courier New", monospace; +} + +/* Plotly chart border */ +.js-plotly-plot { + border-radius: 4px; +} + +/* Progress bar label */ +#progress-stage { + font-size: 0.9rem; +} + +/* Summary badges spacing */ +.badge { + font-size: 0.78rem; +} + +/* Collapse card header button */ +#pp-toggle { + text-decoration: none; + font-size: 1rem; +} +#pp-toggle:focus { + box-shadow: none; +} diff --git a/gui/docker-compose.yml b/gui/docker-compose.yml new file mode 100644 index 00000000..acc7ad93 --- /dev/null +++ b/gui/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3.9" + +services: + autoeis: + build: . + ports: + - "8050:8050" + environment: + - PORT=8050 + - MAX_CONCURRENT_TASKS=3 # cap simultaneous analyses + - LOG_LEVEL=info + restart: unless-stopped + # Persist the Julia depot so a re-deploy doesn't re-precompile from scratch + volumes: + - julia_depot:/julia_depot + + # Optional nginx reverse proxy with HTTPS. + # Remove this block if you're using a cloud platform (Fly, Railway, Render) + # that handles SSL termination for you. + nginx: + image: nginx:1.25-alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/certs:/etc/nginx/certs:ro # put your SSL certs here + depends_on: + - autoeis + restart: unless-stopped + profiles: + - with-nginx # run with: docker compose --profile with-nginx up + +volumes: + julia_depot: diff --git a/gui/eis_posterior_quality.py b/gui/eis_posterior_quality.py new file mode 100644 index 00000000..0dc79b29 --- /dev/null +++ b/gui/eis_posterior_quality.py @@ -0,0 +1,405 @@ +""" +Posterior distribution quality assessment for AutoEIS ECM selection. + +Implements a three-layer check to classify each circuit parameter's posterior as: + NORMAL — approximately Gaussian (ideal) + LOGNORMAL — log-space Gaussian; physically natural for positive params (pass with flag) + UNCERTAIN — borderline shape; not clearly failing (pass with warning) + MULTIMODAL — two or more distinct peaks (fail) + BOUNDARY_HIT — mode or mass pile-up near sample boundary (fail) + UNINFORMATIVE — posterior too wide to constrain the parameter (fail) + UNIFORM — flat distribution; prior not updated (fail) + +Usage +----- + from eis_posterior_quality import assess_posterior_quality + + samples = BI_results[0].mcmc.get_samples() + variables = ae.parser.get_parameter_labels(test_circuit) + + quality = assess_posterior_quality( + samples, + variables, + num_divergences=BI_results[0].num_divergences, + num_samples=num_samples, + verbose=True, + ) + + if not quality['overall_pass']: + print("Posterior invalid:", quality['summary']) +""" + +import numpy as np +from scipy import stats +from scipy.signal import find_peaks +from scipy.stats import gaussian_kde as sp_kde + + +# --------------------------------------------------------------------------- +# Tunable defaults (override via keyword arguments) +# --------------------------------------------------------------------------- +_DEFAULTS = dict( + div_threshold = 0.20, # max acceptable divergence ratio + rel_width_max = 10.0, # (q97.5-q2.5)/|median| → uninformative above this + skew_lognormal = 1.5, # |skewness| threshold to trigger log-normal check + bc_threshold = 0.60, # bimodality coefficient (Pearson); 0.555 is the theoretical cut-off + peak_prominence_frac = 0.25, # secondary KDE peak must exceed this fraction of main peak + # --- Uniform detection --- + flatness_threshold = 0.40, # KDE min/max ratio above this → uniform-like + # --- Boundary / wall-hit detection --- + # Uses KDE edge density (fraction of peak density at the sample extremes). + # High edge density means the posterior is still rising at the boundary (truncated). + # For a long-tailed but valid distribution, KDE decays to near-zero at the sample + # extremes, so edge density is low even if the mode sits close to the boundary. + edge_density_threshold = 0.25, # KDE at sample edge / peak > this → wall hit + edge_n_pts = 20, # number of KDE grid points averaged at each edge (out of 512) +) + + +def _bimodality_coefficient(skewness: float, kurt: float, n: int) -> float: + """Pearson bimodality coefficient with finite-sample correction.""" + if n > 3: + g1 = skewness * np.sqrt(n * (n - 1)) / (n - 2) + # Corrected excess kurtosis (Fisher) + g2 = kurt * (n - 1) * (n + 1) / ((n - 2) * (n - 3)) + else: + g1, g2 = skewness, kurt + denom = g2 + 3 + return (g1 ** 2 + 1) / denom if denom != 0 else 1.0 + + +def _kde_analysis(s: np.ndarray, peak_prominence_frac: float, edge_n_pts: int = 20): + """ + Compute KDE on samples and return a dict of KDE-derived statistics. + + Returned keys + ------------- + kde_vals : np.ndarray or None + x_grid : np.ndarray or None + n_peaks : int + kde_flatness : float — min(KDE) / max(KDE) over the grid. + Near 1 → uniform-like; near 0 → peaked. + left_edge_density : float — mean KDE in first ``edge_n_pts`` grid points / max(KDE). + High → posterior is still rising at the left boundary (wall hit). + Low → posterior naturally decays at the left extreme. + right_edge_density: float — same for the right boundary. + """ + s_min, s_max = s.min(), s.max() + s_range = s_max - s_min + + _null = dict( + kde_vals=None, x_grid=None, n_peaks=1, + kde_flatness=0.0, left_edge_density=0.0, right_edge_density=0.0, + ) + + if s_range < 1e-14: + return _null # degenerate: all samples identical + + try: + kde_obj = sp_kde(s, bw_method="silverman") + x_grid = np.linspace(s_min, s_max, 512) + kde_vals = kde_obj(x_grid) + except Exception: + return _null + + peak_max = kde_vals.max() + if peak_max < 1e-30: + return _null + + # Peak count + prominence_min = peak_prominence_frac * peak_max + peak_idxs, _ = find_peaks(kde_vals, prominence=prominence_min) + n_peaks = max(1, len(peak_idxs)) + + # Flatness: ratio of minimum to maximum KDE density. + # A true uniform distribution will have min/max close to 1 across the grid. + # A peaked (normal/lognormal) distribution will have min/max close to 0. + kde_flatness = float(kde_vals.min() / peak_max) + + # Edge density: average KDE in the outermost ``edge_n_pts`` grid points, + # normalised by the peak density. + # For a distribution that is TRUNCATED at the boundary, density is still + # substantial at the sample extremes → edge density is high. + # For a distribution with genuine long tails, the KDE decays to near-zero + # at the sample extremes even if the mode is close to the boundary. + n_pts = min(edge_n_pts, len(kde_vals) // 4) + left_edge_density = float(kde_vals[:n_pts].mean() / peak_max) + right_edge_density = float(kde_vals[-n_pts:].mean() / peak_max) + + return dict( + kde_vals=kde_vals, x_grid=x_grid, n_peaks=n_peaks, + kde_flatness=kde_flatness, + left_edge_density=left_edge_density, + right_edge_density=right_edge_density, + ) + + +def _classify_single(s: np.ndarray, cfg: dict) -> dict: + """ + Assess one parameter's posterior sample array. + + Returns a dict with keys: status, passes, reason, and various diagnostics. + """ + n = len(s) + s_min = float(s.min()) + s_max = float(s.max()) + s_range= s_max - s_min + + # ── Descriptive statistics ──────────────────────────────────────────── + median = float(np.median(s)) + q025 = float(np.percentile(s, 2.5)) + q975 = float(np.percentile(s, 97.5)) + skewness = float(stats.skew(s)) + kurt = float(stats.kurtosis(s)) # excess kurtosis; Normal = 0 + bc = _bimodality_coefficient(skewness, kurt, n) + + rel_width = (q975 - q025) / abs(median) if abs(median) > 1e-14 else np.inf + + # ── KDE analysis ───────────────────────────────────────────────────── + kde_info = _kde_analysis( + s, + peak_prominence_frac=cfg["peak_prominence_frac"], + edge_n_pts=cfg["edge_n_pts"], + ) + kde_vals = kde_info["kde_vals"] + n_peaks = kde_info["n_peaks"] + kde_flatness = kde_info["kde_flatness"] + left_edge_density = kde_info["left_edge_density"] + right_edge_density = kde_info["right_edge_density"] + + # ── Layer 1: Boundary / wall-hit detection ──────────────────────────── + # We use KDE edge density instead of mode_pos to avoid false positives on + # long-tailed (e.g. lognormal) distributions whose natural mode sits close + # to the sample minimum but whose density DOES decay at the sample extreme. + # + # True wall-hit: posterior is truncated by the prior boundary → the KDE + # is still high (and rising) at the sample edge. + # Long-tail valid: posterior naturally decays to near-zero at the sample + # extreme even when the mode is close to the boundary. + # + # Degenerate case (zero range) is unconditionally flagged. + if s_range < 1e-14: + wall_hit = True + else: + edt = cfg["edge_density_threshold"] + wall_hit = left_edge_density > edt or right_edge_density > edt + + # ── Layer 2: Multimodality ──────────────────────────────────────────── + is_multimodal = n_peaks > 1 + + # ── Layer 3: Informativeness / shape ───────────────────────────────── + is_too_wide = rel_width > cfg["rel_width_max"] + + # Uniform detection: KDE flatness ratio (min/max) is close to 1, meaning + # density is roughly constant across the support. + # This is more direct than kurtosis-based checks and robust to small samples + # because it uses the shape of the estimated density rather than raw moments. + # Fall back to a kurtosis + width check when KDE is unavailable. + if kde_vals is not None: + is_flat = kde_flatness > cfg["flatness_threshold"] + else: + is_flat = kurt < -0.8 and rel_width > 3.0 + + # Log-normal check: only meaningful when all samples are strictly positive + is_lognormal = False + log_skew_val = None + if s_min > 1e-14 and abs(skewness) > cfg["skew_lognormal"]: + log_s = np.log(s) + log_skew_val = float(abs(stats.skew(log_s))) + # Log-transform must reduce |skewness| by at least 50 % + if log_skew_val < 0.5 * abs(skewness): + is_lognormal = True + + # ── Classification (ordered by severity) ──────────────────────────── + if is_multimodal: + status = "MULTIMODAL" + passes = False + reason = f"KDE shows {n_peaks} significant peaks" + + elif wall_hit: + status = "BOUNDARY_HIT" + passes = False + reason = ( + f"left_edge_density={left_edge_density:.2f}, " + f"right_edge_density={right_edge_density:.2f} " + f"(threshold={cfg['edge_density_threshold']})" + ) + + elif is_too_wide: + status = "UNINFORMATIVE" + passes = False + reason = f"rel_width={rel_width:.1f} > threshold {cfg['rel_width_max']}" + + elif is_flat: + status = "UNIFORM" + passes = False + reason = ( + f"kde_flatness={kde_flatness:.2f} > threshold {cfg['flatness_threshold']}" + if kde_vals is not None + else f"kurt={kurt:.2f}, rel_width={rel_width:.1f}" + ) + + elif is_lognormal: + # Unimodal + constrained in log-space; physically common for EIS params + status = "LOGNORMAL" + passes = True + reason = ( + f"skew reduced from {skewness:.2f} to {log_skew_val:.2f} in log-space" + ) + + elif abs(skewness) < cfg["skew_lognormal"] and abs(kurt) < 3.0: + status = "NORMAL" + passes = True + reason = "" + + else: + # Borderline — not clearly failing under lenient small-sample thresholds + status = "UNCERTAIN" + passes = True + reason = f"skew={skewness:.2f}, kurt={kurt:.2f}, bc={bc:.2f}" + + return { + "status" : status, + "passes" : passes, + "reason" : reason, + "median" : median, + "q2.5" : q025, + "q97.5" : q975, + "rel_width" : rel_width, + "skewness" : skewness, + "kurtosis" : kurt, + "bc" : bc, + "n_peaks" : n_peaks, + "wall_hit" : wall_hit, + "lognormal" : is_lognormal, + "kde_flatness" : kde_flatness, + "left_edge_density" : left_edge_density, + "right_edge_density" : right_edge_density, + } + + +def assess_posterior_quality( + samples, + variables, + num_divergences: int = 0, + num_samples: int = None, + verbose: bool = True, + **kwargs, +) -> dict: + """ + Assess posterior distribution quality for all circuit parameters. + + Parameters + ---------- + samples : dict + Raw MCMC sample dict from ``BI_results[0].mcmc.get_samples()``. + Keys are parameter names; values are 1-D arrays of MCMC draws. + variables : list[str] + Circuit parameter names from ``ae.parser.get_parameter_labels(circuit)``. + Only these keys are evaluated (noise params such as ``sigma.*`` are ignored). + num_divergences : int + Number of MCMC divergences, e.g. ``BI_results[0].num_divergences``. + num_samples : int, optional + Total MCMC sample count. Auto-detected from ``samples`` when omitted. + verbose : bool + Print a per-parameter diagnostic table (default True). + **kwargs + Override any default threshold. Valid keys: + + div_threshold (default 0.20) + rel_width_max (default 10.0) + skew_lognormal (default 1.5) + bc_threshold (default 0.60) + peak_prominence_frac (default 0.25) + flatness_threshold (default 0.40) — KDE min/max ratio; above → uniform + edge_density_threshold (default 0.25) — KDE edge/peak ratio; above → wall hit + edge_n_pts (default 20) — KDE grid points averaged at each edge + + Returns + ------- + dict with keys: + overall_pass : bool — True only when divergence check AND all params pass + divergence_ok : bool + div_ratio : float + per_parameter : dict — keyed by parameter name → diagnostic sub-dict + failed_params : list[str] + failure_reasons: dict — keyed by failed parameter name → reason string + summary : str — human-readable one-liner + """ + cfg = {**_DEFAULTS, **kwargs} + + # ── Divergence check ───────────────────────────────────────────────── + if num_samples is None: + first = next((v for v in variables if v in samples), None) + num_samples = len(samples[first]) if first else 1 + + div_ratio = num_divergences / max(num_samples, 1) + divergence_ok = div_ratio <= cfg["div_threshold"] + + # ── Per-parameter assessment ───────────────────────────────────────── + per_param = {} + failed_params = [] + failure_reasons= {} + + for var in variables: + if var not in samples: + continue + + s = np.asarray(samples[var], dtype=float) + result = _classify_single(s, cfg) + per_param[var] = result + + if not result["passes"]: + failed_params.append(var) + failure_reasons[var] = f"[{result['status']}] {result['reason']}" + + # ── Verbose table ──────────────────────────────────────────────────── + if verbose: + _PASS = "\033[32m✓\033[0m" + _FAIL = "\033[31m✗\033[0m" + print("\n Posterior quality assessment") + print(f" {'Param':<12} {'Status':<15} {'median':>10} " + f"{'rel_w':>6} {'skew':>6} {'kurt':>6} {'peaks':>5} {'wall':>5}") + print(" " + "-" * 76) + for var, r in per_param.items(): + icon = _PASS if r["passes"] else _FAIL + print( + f" {icon} {var:<12} {r['status']:<15} " + f"{r['median']:>10.3e} {r['rel_width']:>6.1f} " + f"{r['skewness']:>+6.2f} {r['kurtosis']:>+6.2f} " + f"{r['n_peaks']:>5d} {str(r['wall_hit']):>5}" + ) + if r["reason"]: + print(f" └─ {r['reason']}") + + div_label = "ok" if divergence_ok else f"FAIL (>{cfg['div_threshold']:.0%})" + print(f"\n Divergence ratio : {div_ratio:.1%} [{div_label}]") + + # ── Overall verdict ────────────────────────────────────────────────── + overall_pass = divergence_ok and (len(failed_params) == 0) + + if overall_pass: + summary = ( + f"PASS — divergence {div_ratio:.1%}, " + f"all {len(per_param)} posteriors acceptable." + ) + else: + parts = [] + if not divergence_ok: + parts.append(f"divergence {div_ratio:.1%} > {cfg['div_threshold']:.0%}") + if failed_params: + parts.append(f"failed params: {failed_params}") + summary = "FAIL — " + "; ".join(parts) + + if verbose: + print(f"\n {summary}\n") + + return { + "overall_pass" : overall_pass, + "divergence_ok" : divergence_ok, + "div_ratio" : div_ratio, + "per_parameter" : per_param, + "failed_params" : failed_params, + "failure_reasons": failure_reasons, + "summary" : summary, + } diff --git a/gui/fly.toml b/gui/fly.toml new file mode 100644 index 00000000..6a92c7a1 --- /dev/null +++ b/gui/fly.toml @@ -0,0 +1,35 @@ +# Fly.io deployment config — https://fly.io/docs/reference/configuration/ +# Deploy with: fly launch (first time) or fly deploy (subsequent) + +app = "autoeis-gui" # change to your chosen app name +primary_region = "sea" # Seattle; pick the region closest to your users + +[build] + # Fly.io builds from the Dockerfile in the repo root + +[env] + PORT = "8050" + MAX_CONCURRENT_TASKS = "3" + LOG_LEVEL = "info" + +[http_service] + internal_port = 8050 + force_https = true + auto_stop_machines = "stop" # stop when idle (saves cost) + auto_start_machines = true + min_machines_running = 0 # set to 1 if you don't want cold-starts + + [http_service.concurrency] + type = "requests" + soft_limit = 10 + hard_limit = 20 + +[[vm]] + size = "shared-cpu-2x" # 2 vCPU, 2 GB RAM — Julia + NumPyro need ~1.5 GB + memory = "2gb" + +# Persist the Julia depot across deploys so precompiled .ji files survive +[mounts] + source = "julia_depot" + destination = "/julia_depot" + initial_size = "5gb" diff --git a/gui/gunicorn_conf.py b/gui/gunicorn_conf.py new file mode 100644 index 00000000..557569a2 --- /dev/null +++ b/gui/gunicorn_conf.py @@ -0,0 +1,32 @@ +"""Gunicorn production config for AutoEIS GUI. + +Single worker is intentional: task_manager stores running jobs in an +in-process dict; multiple workers would each have their own isolated copy +and jobs would appear to vanish on every other poll request. +""" + +import os + +bind = f"0.0.0.0:{os.environ.get('PORT', '8050')}" + +# ── Worker model ────────────────────────────────────────────────────────────── +workers = 1 # single process keeps task_manager state consistent +threads = 8 # serve concurrent HTTP requests within that process +worker_class = "gthread" + +# ── Timeouts ────────────────────────────────────────────────────────────────── +# Gunicorn kills a worker if a *request* takes longer than `timeout` seconds. +# Analysis runs in a background *thread* so individual HTTP requests (poll, +# btn-click) are always short. 120 s is plenty. +timeout = 120 +keepalive = 5 +graceful_timeout = 30 + +# ── Logging ─────────────────────────────────────────────────────────────────── +accesslog = "-" # stdout +errorlog = "-" # stderr +loglevel = os.environ.get("LOG_LEVEL", "info") + +# ── Security ────────────────────────────────────────────────────────────────── +limit_request_line = 8190 +limit_request_fields = 200 diff --git a/gui/nginx/default.conf b/gui/nginx/default.conf new file mode 100644 index 00000000..4c271f65 --- /dev/null +++ b/gui/nginx/default.conf @@ -0,0 +1,40 @@ +# nginx reverse proxy for AutoEIS GUI +# Replace YOUR_DOMAIN with your actual domain name. + +upstream autoeis { + server autoeis:8050; +} + +# Redirect HTTP → HTTPS +server { + listen 80; + server_name YOUR_DOMAIN; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name YOUR_DOMAIN; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Dash uses long-poll; generous timeouts matter + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + client_max_body_size 20M; # allow reasonable EIS file uploads + + location / { + proxy_pass http://autoeis; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gui/pages/data.py b/gui/pages/data.py new file mode 100644 index 00000000..790bfc75 --- /dev/null +++ b/gui/pages/data.py @@ -0,0 +1,1030 @@ +"""Page 1 – Data Upload & Preprocessing.""" + +import json + +import dash +import numpy as np +import pandas as pd +from dash import Input, Output, State, callback, ctx, dcc, html +from dash import dash_table as dt +import dash_bootstrap_components as dbc + +from utils import ( + bode_figure, + guess_columns, + nyquist_compare_figure, + nyquist_figure, + parse_uploaded_file, + store_decode, + store_encode, +) + +dash.register_page(__name__, path="/", name="Data") + +# --------------------------------------------------------------------------- +# Layout helpers +# --------------------------------------------------------------------------- + +def _upload_card(): + return dbc.Card( + dbc.CardBody( + [ + dcc.Upload( + id="upload-eis", + children=html.Div( + [ + html.I(className="fa fa-upload me-2"), + "Drag & drop or ", + html.A("select a file", className="text-primary fw-bold"), + " (CSV / TXT / XLSX)", + ] + ), + style={ + "width": "100%", + "height": "70px", + "lineHeight": "70px", + "borderWidth": "2px", + "borderStyle": "dashed", + "borderRadius": "8px", + "borderColor": "#6c757d", + "textAlign": "center", + "cursor": "pointer", + }, + multiple=False, + ), + html.Div(id="col-mapping-row", className="mt-3"), + ] + ), + className="mb-3", + ) + + +def _col_mapping_row(columns): + opts = [{"label": c, "value": c} for c in columns] + guess = guess_columns(columns) + return dbc.Row( + [ + # Frequency + dbc.Col( + [ + dbc.Label("Frequency", className="fw-semibold"), + dcc.Dropdown(id="col-freq", options=opts, value=guess["freq"], clearable=False), + ], + md=2, + ), + # Column 2: Re(Z) or Ewe_mod + dbc.Col( + [ + dbc.Label(id="label-col2", children="Re(Z)", className="fw-semibold"), + dcc.Dropdown(id="col-zreal", options=opts, value=guess["zreal"], clearable=False), + ], + md=2, + ), + # Column 3: Im(Z) or I_mod + dbc.Col( + [ + dbc.Label(id="label-col3", children="Im(Z)", className="fw-semibold"), + dcc.Dropdown(id="col-zimag", options=opts, value=guess["zimag"], clearable=False), + ], + md=2, + ), + # Column 4: Phase — only visible in magphase mode + html.Div( + dbc.Col( + [ + dbc.Label(id="label-col4", children="Phase", className="fw-semibold"), + dcc.Dropdown(id="col-phase", options=opts, value=None, clearable=False), + ], + md=12, + ), + id="col4-container", + style={"display": "none", "width": "16.6%", "paddingRight": "8px"}, + ), + # Input format + phase unit + dbc.Col( + [ + dbc.Label("Input format", className="fw-semibold"), + dbc.RadioItems( + id="format-select", + options=[ + {"label": "Re(Z) / Im(Z)", "value": "reimag"}, + {"label": "Ewe/I/Phase", "value": "magphase"}, + ], + value="reimag", + inline=True, + className="small", + ), + # Phase unit: visible in magphase mode + html.Div( + dbc.Select( + id="phase-unit", + options=[ + {"label": "Radians", "value": "rad"}, + {"label": "Degrees (°)", "value": "deg"}, + ], + value="rad", + size="sm", + ), + id="phase-unit-container", + style={"display": "none"}, + className="mt-1", + ), + ], + md=2, + ), + # Options + dbc.Col( + [ + dbc.Label("Options", className="fw-semibold"), + dbc.Checklist( + id="check-negate", + options=[{"label": "Negate phase", "value": "negate"}], + value=[], + switch=True, + ), + ], + md=1, + ), + dbc.Col( + dbc.Button("Load →", id="btn-load", color="primary", className="w-100 mt-4"), + md=1, + ), + ], + className="g-2 align-items-end", + ) + + +def _preprocess_card(): + return dbc.Card( + [ + dbc.CardHeader( + dbc.Button( + [html.I(className="fa fa-sliders me-2"), "Data Preprocessing"], + id="pp-toggle", + color="link", + className="text-dark fw-bold p-0", + ) + ), + dbc.Collapse( + dbc.CardBody( + [ + dbc.Row( + [ + # KK Validation + dbc.Col( + [ + html.H6( + [html.I(className="fa fa-check-circle me-1 text-success"), "KK Validation"], + className="fw-semibold", + ), + dbc.Checklist( + id="check-kk", + options=[{"label": "Enable Lin-KK", "value": "enable"}], + value=["enable"], + switch=True, + ), + dbc.Label("Tolerance threshold:", className="mt-2 small"), + dcc.Slider( + id="slider-kk", + min=0.01, max=0.5, step=0.01, value=0.05, + marks={0.01: "0.01", 0.1: "0.1", 0.3: "0.3", 0.5: "0.5"}, + tooltip={"placement": "bottom", "always_visible": True}, + ), + ], + md=4, + ), + # Frequency Range + dbc.Col( + [ + html.H6( + [html.I(className="fa fa-cut me-1 text-warning"), "Frequency Range Crop"], + className="fw-semibold", + ), + dbc.Checklist( + id="check-freq-range", + options=[{"label": "Enable freq crop", "value": "enable"}], + value=[], + switch=True, + ), + dbc.Row( + [ + dbc.Col([ + dbc.Label("Min (Hz)", className="small"), + dbc.Input(id="input-freq-min", type="number", min=0, step=0.001, size="sm", placeholder="auto"), + ], md=6), + dbc.Col([ + dbc.Label("Max (Hz)", className="small"), + dbc.Input(id="input-freq-max", type="number", min=0, step=1, size="sm", placeholder="auto"), + ], md=6), + ], + className="g-1 mt-2", + ), + html.Div(id="freq-range-label", className="text-muted small mt-1"), + ], + md=4, + ), + # Heuristic Filtering + dbc.Col( + [ + html.H6( + [html.I(className="fa fa-magic me-1 text-info"), "Heuristic Filtering"], + className="fw-semibold", + ), + dbc.Checklist( + id="check-heuristic", + options=[{"label": "Enable (H1 + H2)", "value": "enable"}], + value=["enable"], + switch=True, + ), + html.Small( + [ + html.Strong("H1:"), " discard invalid high-freq points (min |Im(Z)|)", + html.Br(), + html.Strong("H2:"), " remove +Im(Z) at high freq", + ], + className="text-muted d-block mt-1", + ), + dbc.Label("High-freq threshold (Hz):", className="mt-2 small"), + dbc.Input( + id="input-hf-threshold", + type="number", + value=1000, + min=1, + step=1, + size="sm", + ), + ], + md=4, + ), + ] + ), + html.Hr(), + dbc.Row( + [ + dbc.Col( + [ + dbc.Button( + [html.I(className="fa fa-play me-2"), "Apply Preprocessing"], + id="btn-preprocess", + color="success", + className="me-2", + ), + dbc.Button( + [html.I(className="fa fa-undo me-2"), "Reset"], + id="btn-reset-pp", + color="secondary", + ), + ] + ) + ] + ), + # Before / After comparison + dbc.Collapse( + [ + html.Hr(), + html.H6("Before vs. After", className="fw-semibold mt-2"), + dcc.Graph(id="compare-graph", style={"height": "400px"}), + html.Div(id="pp-stats", className="text-muted small"), + ], + id="compare-collapse", + is_open=False, + ), + # Lin-KK residuals + dbc.Collapse( + [ + html.Hr(), + html.H6( + [html.I(className="fa fa-check-circle me-1 text-success"), + "Lin-KK Validation Residuals"], + className="fw-semibold mt-2", + ), + html.Small( + "Relative residuals from Kramers-Kronig validation. " + "Points outside ±5% (or your chosen tolerance) were removed.", + className="text-muted d-block mb-2", + ), + dcc.Graph(id="kk-residual-plot", style={"height": "300px"}), + ], + id="kk-residual-collapse", + is_open=False, + ), + ] + ), + id="pp-collapse", + is_open=True, + ), + ], + className="mb-3", + ) + + +# --------------------------------------------------------------------------- +# Page layout +# --------------------------------------------------------------------------- + +layout = dbc.Container( + [ + html.H3( + [html.I(className="fa fa-database me-2 text-primary"), "Step 1 — Load & Preprocess Data"], + className="mb-3", + ), + _upload_card(), + html.Div(id="upload-alert"), + + # Main content (hidden until data is loaded) + html.Div( + id="data-main", + style={"display": "none"}, + children=[ + dbc.Row( + [ + # Left: plot + dbc.Col( + dbc.Card( + [ + dbc.CardHeader( + dbc.Tabs( + [ + dbc.Tab(label="Nyquist", tab_id="nyquist"), + dbc.Tab(label="Bode", tab_id="bode"), + ], + id="plot-tabs", + active_tab="nyquist", + ) + ), + dbc.CardBody( + [ + dcc.Graph( + id="eis-plot", + config={"displayModeBar": True, "modeBarButtonsToAdd": ["select2d", "lasso2d"]}, + style={"height": "460px"}, + ), + dbc.ButtonGroup( + [ + dbc.Button( + [html.I(className="fa fa-trash me-1"), "Remove Selected"], + id="btn-remove-pts", + color="danger", + size="sm", + ), + dbc.Button( + [html.I(className="fa fa-undo me-1"), "Undo Last"], + id="btn-undo", + color="secondary", + size="sm", + ), + dbc.Button( + [html.I(className="fa fa-refresh me-1"), "Reset All"], + id="btn-reset-all", + color="warning", + size="sm", + ), + ], + className="mt-2", + size="sm", + ), + ] + ), + ] + ), + md=7, + ), + # Right: table + dbc.Col( + dbc.Card( + [ + dbc.CardHeader("Data Table"), + dbc.CardBody( + [ + dt.DataTable( + id="data-table", + columns=[ + {"name": "#", "id": "idx", "type": "numeric"}, + {"name": "Freq [Hz]", "id": "freq", "type": "numeric", "format": dt.Format.Format(precision=4, scheme=dt.Format.Scheme.exponent)}, + {"name": "Re(Z) [Ω]", "id": "z_real", "type": "numeric", "format": dt.Format.Format(precision=4, scheme=dt.Format.Scheme.exponent)}, + {"name": "−Im(Z) [Ω]", "id": "z_imag_neg", "type": "numeric", "format": dt.Format.Format(precision=4, scheme=dt.Format.Scheme.exponent)}, + ], + row_selectable="multi", + selected_rows=[], + style_table={"height": "400px", "overflowY": "auto"}, + page_action="none", + style_cell={"fontSize": "12px", "padding": "4px 8px", "fontFamily": "monospace"}, + style_header={"fontWeight": "bold", "backgroundColor": "#f8f9fa"}, + style_data_conditional=[ + { + "if": {"state": "selected"}, + "backgroundColor": "#cfe2ff", + } + ], + filter_action="native", + sort_action="native", + ), + dbc.Button( + [html.I(className="fa fa-trash me-1"), "Remove Selected Rows"], + id="btn-remove-rows", + color="danger", + size="sm", + className="mt-2", + ), + ] + ), + ] + ), + md=5, + ), + ], + className="mb-3 g-3", + ), + + _preprocess_card(), + + # Empirical Rs info card + html.Div(id="rs-display", className="mb-2"), + + # Summary badge + html.Div(id="data-summary", className="mb-3"), + + # Navigation + dbc.Row( + dbc.Col( + dbc.Button( + [html.I(className="fa fa-arrow-right me-2"), "Next: Choose Analysis Mode"], + href="/mode", + color="primary", + className="float-end", + ), + ) + ), + ], + ), + + # One-shot interval fires once after page mount to restore display from app-level stores + dcc.Interval(id="data-page-init", interval=150, max_intervals=1, n_intervals=0), + ], + fluid=True, +) + + +# --------------------------------------------------------------------------- +# Callbacks +# --------------------------------------------------------------------------- + +@callback( + Output("data-main", "style"), + Input("store-raw", "data"), + Input("data-page-init", "n_intervals"), +) +def toggle_main(raw, _): + return {"display": "block"} if raw else {"display": "none"} + + +@callback( + Output("col-mapping-row", "children", allow_duplicate=True), + Input("data-page-init", "n_intervals"), + State("store-df-cols", "data"), + prevent_initial_call=True, +) +def restore_col_mapping(_, df_cols_json): + if not df_cols_json: + return dash.no_update + meta = json.loads(df_cols_json) + return _col_mapping_row(meta["cols"]) + + +@callback( + Output("col-mapping-row", "children"), + Output("store-df-cols", "data"), + Output("upload-alert", "children"), + Input("upload-eis", "contents"), + State("upload-eis", "filename"), + prevent_initial_call=True, +) +def on_upload(contents, filename): + if contents is None: + return dash.no_update, dash.no_update, dash.no_update + df, err = parse_uploaded_file(contents, filename) + if err: + alert = dbc.Alert(err, color="danger", dismissable=True) + return dash.no_update, dash.no_update, alert + cols = df.columns.tolist() + return _col_mapping_row(cols), json.dumps({"cols": cols, "data": df.to_json()}), dash.no_update + + +@callback( + Output("store-raw", "data"), + Output("store-deleted", "data"), + Output("upload-alert", "children", allow_duplicate=True), + Input("btn-load", "n_clicks"), + State("store-df-cols", "data"), + State("col-freq", "value"), + State("col-zreal", "value"), + State("col-zimag", "value"), + State("col-phase", "value"), + State("check-negate", "value"), + State("format-select", "value"), + State("phase-unit", "value"), + prevent_initial_call=True, +) +def on_load(n_clicks, df_cols_json, col_freq, col_ewe, col_i, col_phase, negate, fmt, phase_unit): + if not n_clicks or not df_cols_json: + return dash.no_update, dash.no_update, dash.no_update + + meta = json.loads(df_cols_json) + df = pd.read_json(meta["data"]) + + if fmt == "magphase": + required = [col_freq, col_ewe, col_i, col_phase] + else: + required = [col_freq, col_ewe, col_i] + + missing = [c for c in required if c and c not in df.columns] + none_cols = [name for c, name in zip(required, ["freq", "col2", "col3", "phase"]) if not c] + if missing or none_cols: + msg = (f"Columns not found: {missing}" if missing else "") + ( + f" Please select: {none_cols}" if none_cols else "" + ) + return dash.no_update, dash.no_update, dbc.Alert(msg.strip(), color="danger", dismissable=True) + + freq = df[col_freq].values.astype(float) + + if fmt == "magphase": + # Exact formula from OT2_functions.impedance_convert: + # Zmodulus = Ewe_mod / I_mod + # Zreal = cos(phase) * Zmodulus + # Zimag = sin(phase) * Zmodulus + ewe_mod = df[col_ewe].values.astype(float) + i_mod = df[col_i].values.astype(float) + phase = df[col_phase].values.astype(float) + if "negate" in (negate or []): + phase = -phase + if (phase_unit or "rad") == "deg": + phase = phase * (np.pi / 180.0) + zmod = ewe_mod / i_mod + z_real = np.cos(phase) * zmod + z_imag = np.sin(phase) * zmod + else: + z_real = df[col_ewe].values.astype(float) + z_imag = df[col_i].values.astype(float) + if "negate" in (negate or []): + z_imag = -z_imag + + Z = z_real + 1j * z_imag + + # Drop rows where freq or Z is NaN/inf (same as OT2_functions NaN handling) + valid = np.isfinite(freq) & np.isfinite(Z.real) & np.isfinite(Z.imag) + n_dropped = int((~valid).sum()) + freq, Z = freq[valid], Z[valid] + + if len(freq) == 0: + return dash.no_update, dash.no_update, dbc.Alert( + "All data points are NaN/inf after parsing. Check column selection.", color="danger", dismissable=True + ) + + raw = store_encode(freq, Z) + alert = ( + dbc.Alert(f"Loaded {len(freq)} points ({n_dropped} NaN/inf rows dropped).", color="info", dismissable=True, duration=4000) + if n_dropped else None + ) + return raw, [], alert + + +# --------------------------------------------------------------------------- +# Update column labels and phase-unit visibility when format changes +# --------------------------------------------------------------------------- + +@callback( + Output("label-col2", "children"), + Output("label-col3", "children"), + Output("col4-container", "style"), + Output("phase-unit-container", "style"), + Input("format-select", "value"), + prevent_initial_call=True, +) +def update_col_labels(fmt): + if fmt == "magphase": + return ( + "Ewe_mod (|V|)", + "I_mod (|I|)", + {"display": "block", "width": "16.6%", "paddingRight": "8px"}, + {"display": "block"}, + ) + return "Re(Z)", "Im(Z)", {"display": "none", "width": "16.6%", "paddingRight": "8px"}, {"display": "none"} + + +# --------------------------------------------------------------------------- +# Sync freq-range slider labels and positions from raw data +# --------------------------------------------------------------------------- + +@callback( + Output("input-freq-min", "value"), + Output("input-freq-max", "value"), + Output("freq-range-label", "children"), + Input("store-raw", "data"), + Input("data-page-init", "n_intervals"), + prevent_initial_call=True, +) +def sync_freq_inputs(raw, _): + if raw is None: + return None, None, "" + freq, _ = store_decode(raw) + freq_pos = freq[freq > 0] + if len(freq_pos) == 0: + return None, None, "" + fmin = float(freq_pos.min()) + fmax = float(freq_pos.max()) + label = f"Data range: {fmin:.3g} – {fmax:.3g} Hz" + return fmin, fmax, label + + +# --------------------------------------------------------------------------- +# Toggle preprocessing collapse +# --------------------------------------------------------------------------- + +@callback( + Output("pp-collapse", "is_open"), + Input("pp-toggle", "n_clicks"), + State("pp-collapse", "is_open"), + prevent_initial_call=True, +) +def toggle_pp(n, is_open): + return not is_open + + +# --------------------------------------------------------------------------- +# Build pending-delete set from plot click / lasso-select +# --------------------------------------------------------------------------- + +@callback( + Output("store-pending-delete", "data"), + Input("eis-plot", "clickData"), + Input("eis-plot", "selectedData"), + State("store-pending-delete", "data"), + State("store-deleted", "data"), + prevent_initial_call=True, +) +def update_pending_from_plot(click_data, selected_data, pending, deleted): + pending = list(pending or []) + deleted_set = set(deleted or []) + trigger_prop = ctx.triggered[0]["prop_id"] if ctx.triggered else None + + if trigger_prop == "eis-plot.clickData": + if click_data and click_data.get("points"): + pt = click_data["points"][0] + if "customdata" in pt: + idx = int(pt["customdata"]) + if idx not in deleted_set: + if idx in pending: + pending.remove(idx) # toggle off + else: + pending.append(idx) # toggle on + + elif trigger_prop == "eis-plot.selectedData": + if selected_data and selected_data.get("points"): + for pt in selected_data["points"]: + if "customdata" in pt: + idx = int(pt["customdata"]) + if idx not in deleted_set and idx not in pending: + pending.append(idx) + + return pending + + +# --------------------------------------------------------------------------- +# Manage deleted indices (pending flush / table select / undo / reset) +# --------------------------------------------------------------------------- + +@callback( + Output("store-deleted", "data", allow_duplicate=True), + Output("store-pending-delete", "data", allow_duplicate=True), + Input("btn-remove-pts", "n_clicks"), + Input("btn-remove-rows", "n_clicks"), + Input("btn-undo", "n_clicks"), + Input("btn-reset-all", "n_clicks"), + State("store-pending-delete", "data"), + State("data-table", "selected_rows"), + State("data-table", "data"), + State("store-deleted", "data"), + prevent_initial_call=True, +) +def manage_deletions( + n_remove_pts, n_remove_rows, n_undo, n_reset, + pending, selected_table_rows, table_data, deleted +): + deleted = list(deleted or []) + pending = list(pending or []) + trigger = ctx.triggered_id + + if trigger == "btn-reset-all": + return [], [] + + if trigger == "btn-undo" and deleted: + return deleted[:-1], pending + + if trigger == "btn-remove-pts": + for i in pending: + if i not in deleted: + deleted.append(i) + return deleted, [] # clear pending after committing + + if trigger == "btn-remove-rows" and selected_table_rows and table_data: + for row_num in selected_table_rows: + orig_idx = table_data[row_num]["idx"] + if orig_idx not in deleted: + deleted.append(orig_idx) + + return deleted, pending + + +# --------------------------------------------------------------------------- +# Render main EIS plot +# --------------------------------------------------------------------------- + +@callback( + Output("eis-plot", "figure"), + Input("plot-tabs", "active_tab"), + Input("store-raw", "data"), + Input("store-deleted", "data"), + Input("store-pending-delete", "data"), + Input("data-page-init", "n_intervals"), + prevent_initial_call=True, +) +def render_plot(tab, raw, deleted, pending, _): + if raw is None: + return {} + freq, Z = store_decode(raw) + if tab == "bode": + return bode_figure(freq, Z, deleted=deleted, pending=pending) + return nyquist_figure(freq, Z, deleted=deleted, pending=pending) + + +# --------------------------------------------------------------------------- +# Render data table (reflects deletions) +# --------------------------------------------------------------------------- + +@callback( + Output("data-table", "data"), + Output("data-table", "selected_rows"), + Input("store-raw", "data"), + Input("store-deleted", "data"), + Input("data-page-init", "n_intervals"), + prevent_initial_call=True, +) +def render_table(raw, deleted, _): + if raw is None: + return [], [] + freq, Z = store_decode(raw) + deleted_set = set(deleted or []) + rows = [ + { + "idx": i, + "freq": float(freq[i]), + "z_real": float(Z.real[i]), + "z_imag_neg": float(-Z.imag[i]), + } + for i in range(len(freq)) + if i not in deleted_set + ] + return rows, [] + + +# --------------------------------------------------------------------------- +# Apply preprocessing → update main store + show comparison +# --------------------------------------------------------------------------- + +def _apply_eis_heuristics(freq, Z, high_freq_threshold=1e3): + """Port of OT2_functions.apply_eis_heuristics.""" + freq = np.asarray(freq) + Z = np.asarray(Z) + idx_sort = np.argsort(freq)[::-1] + freq, Z = freq[idx_sort], Z[idx_sort] + + # H1: at very high freq Im(Z)→0; keep from the min-|Im| point downward + high_freq_mask = freq > high_freq_threshold + if np.any(high_freq_mask): + hf_indices = np.where(high_freq_mask)[0] + idx_local = np.argmin(np.abs(Z.imag[high_freq_mask])) + idx_global = hf_indices[idx_local] + freq, Z = freq[idx_global:], Z[idx_global:] + + # H2: remove +Im(Z) points at high freq + high_freq_mask = freq > high_freq_threshold + if np.any(high_freq_mask): + remove = high_freq_mask & (Z.imag > 0) + freq, Z = freq[~remove], Z[~remove] + + return freq, Z + + +def _empirical_rs(freq, Z, n_highfreq=30): + """Return (Rs, freq_at_Rs) using the min-|Im(Z)| heuristic.""" + idx_sorted = np.argsort(freq)[::-1] + idx_high = idx_sorted[: min(n_highfreq, len(freq))] + Z_high = Z[idx_high] + freq_high = freq[idx_high] + idx_min = np.argmin(np.abs(Z_high.imag)) + return float(Z_high.real[idx_min]), float(freq_high[idx_min]) + + +@callback( + Output("store-data", "data"), + Output("compare-graph", "figure"), + Output("compare-collapse", "is_open"), + Output("pp-stats", "children"), + Output("kk-residual-plot", "figure"), + Output("kk-residual-collapse", "is_open"), + Input("btn-preprocess", "n_clicks"), + Input("btn-reset-pp", "n_clicks"), + State("store-raw", "data"), + State("store-deleted", "data"), + State("check-kk", "value"), + State("slider-kk", "value"), + State("check-freq-range", "value"), + State("input-freq-min", "value"), + State("input-freq-max", "value"), + State("check-heuristic", "value"), + State("input-hf-threshold", "value"), + prevent_initial_call=True, +) +def apply_preprocess(n_apply, n_reset, raw, deleted, kk_on, kk_tol, + freq_range_on, freq_min, freq_max, heuristic_on, hf_threshold): + import plotly.graph_objects as go + from autoeis.utils import preprocess_impedance_data + + trigger = ctx.triggered_id + + if raw is None: + return dash.no_update, {}, False, "", {}, False + + freq_raw, Z_raw = store_decode(raw) + + # Apply manual deletions + mask = np.ones(len(freq_raw), dtype=bool) + for i in (deleted or []): + if i < len(mask): + mask[i] = False + freq = freq_raw[mask] + Z = Z_raw[mask] + + if trigger == "btn-reset-pp": + encoded = store_encode(freq, Z) + encoded["freq_raw"] = freq_raw.tolist() + encoded["z_real_raw"] = Z_raw.real.tolist() + encoded["z_imag_raw"] = Z_raw.imag.tolist() + return encoded, {}, False, "Preprocessing reset.", {}, False + + # Step 1: Frequency range crop (direct Hz values) + if "enable" in (freq_range_on or []): + lo = float(freq_min) if freq_min is not None else freq.min() + hi = float(freq_max) if freq_max is not None else freq.max() + m = (freq >= lo) & (freq <= hi) + freq, Z = freq[m], Z[m] + + freq_before, Z_before = freq.copy(), Z.copy() + + # Step 2: Heuristic filtering (H1 + H2) + if "enable" in (heuristic_on or []): + threshold = float(hf_threshold or 1e3) + freq, Z = _apply_eis_heuristics(freq, Z, high_freq_threshold=threshold) + + # Step 3: KK validation — capture residuals when enabled + kk_fig = {} + kk_open = False + if "enable" in (kk_on or []): + try: + hf_thresh = float("inf") if "enable" in (heuristic_on or []) else float(hf_threshold or 1e3) + freq, Z, aux = preprocess_impedance_data( + freq, Z, + tol_linKK=float(kk_tol), + high_freq_threshold=hf_thresh, + return_aux=True, + ) + # Build KK residual plot (Plotly) + try: + res_real = np.array(aux.res.real) * 100 + res_imag = np.array(aux.res.imag) * 100 + kk_freq = freq # residuals correspond to retained points + tol_pct = float(kk_tol) * 100 + fig = go.Figure() + fig.add_trace(go.Scatter( + x=kk_freq, y=res_real, mode="markers+lines", + name="Re residual [%]", + marker=dict(color="#1f77b4", size=5), + line=dict(width=1), + )) + fig.add_trace(go.Scatter( + x=kk_freq, y=res_imag, mode="markers+lines", + name="Im residual [%]", + marker=dict(color="#d62728", size=5), + line=dict(width=1), + )) + fig.add_hline(y=tol_pct, line_dash="dash", line_color="gray", + annotation_text=f"+{tol_pct:.0f}%", annotation_position="top right") + fig.add_hline(y=-tol_pct, line_dash="dash", line_color="gray", + annotation_text=f"−{tol_pct:.0f}%", annotation_position="bottom right") + fig.add_hline(y=0, line_color="black", opacity=0.3, line_width=1) + fig.update_xaxes(type="log", title_text="Frequency [Hz]") + fig.update_yaxes(title_text="Relative residual [%]") + fig.update_layout( + title=dict(text="Lin-KK Validation Residuals", x=0.5), + hovermode="x unified", + height=300, + margin=dict(l=50, r=20, t=40, b=50), + legend=dict(x=0.01, y=0.99, bgcolor="rgba(255,255,255,0.8)"), + ) + kk_fig = fig + kk_open = True + except Exception: + pass + except Exception as exc: + return dash.no_update, {}, False, dbc.Alert(str(exc), color="danger"), {}, False + + n_before = len(freq_before) + n_after = len(freq) + pct_kept = 100 * n_after / max(n_before, 1) + + # Empirical Rs on cleaned data + rs_text = "" + if len(freq) > 0: + try: + rs, f_rs = _empirical_rs(freq, Z) + rs_text = f" | ⚡ Empirical Rs ≈ {rs:.4g} Ω (at {f_rs:.3g} Hz)" + except Exception: + pass + + encoded = store_encode(freq, Z) + encoded["freq_raw"] = freq_raw.tolist() + encoded["z_real_raw"] = Z_raw.real.tolist() + encoded["z_imag_raw"] = Z_raw.imag.tolist() + + compare_fig = nyquist_compare_figure(freq_before, Z_before, freq, Z) + stats_msg = ( + f"Before: {n_before} points → After: {n_after} points " + f"({pct_kept:.0f}% retained){rs_text}" + ) + + return encoded, compare_fig, True, stats_msg, kk_fig, kk_open + + +# --------------------------------------------------------------------------- +# Empirical Rs display (updates on raw load and after preprocessing) +# --------------------------------------------------------------------------- + +@callback( + Output("rs-display", "children"), + Input("store-raw", "data"), + Input("store-data", "data"), + Input("data-page-init", "n_intervals"), + prevent_initial_call=True, +) +def show_rs(raw, processed, _): + source = processed if processed else raw + if source is None: + return "" + try: + freq, Z = store_decode(source) + rs, f_rs = _empirical_rs(freq, Z) + return dbc.Alert( + [ + html.I(className="fa fa-bolt me-2 text-warning"), + html.Strong("Empirical Rs ≈ "), + f"{rs:.4g} Ω", + html.Span(f" (at {f_rs:.3g} Hz, high-freq min |Im(Z)| point)", className="text-muted ms-2 small"), + ], + color="light", + className="py-2 mb-0", + ) + except Exception: + return "" + + +# --------------------------------------------------------------------------- +# Data summary badge +# --------------------------------------------------------------------------- + +@callback( + Output("data-summary", "children"), + Input("store-data", "data"), + Input("store-raw", "data"), + Input("data-page-init", "n_intervals"), + prevent_initial_call=True, +) +def update_summary(processed, raw, _): + source = processed if processed else raw + if source is None: + return "" + freq, Z = store_decode(source) + if len(freq) == 0: + return dbc.Badge("0 points (empty data)", color="danger") + badges = [ + dbc.Badge(f"{len(freq)} points", color="primary", className="me-1"), + dbc.Badge( + f"f: {freq.min():.2e} – {freq.max():.2e} Hz", + color="secondary", + className="me-1", + ), + dbc.Badge( + "Preprocessed ✓" if processed and "freq_raw" in processed else "Raw data", + color="success" if processed and "freq_raw" in processed else "warning", + ), + ] + return html.Div(badges, className="mt-1") diff --git a/gui/pages/fitting.py b/gui/pages/fitting.py new file mode 100644 index 00000000..66dd5cf9 --- /dev/null +++ b/gui/pages/fitting.py @@ -0,0 +1,351 @@ +"""Page 3 – Run Fitting & Bayesian Inference.""" + +import dash +import numpy as np +from dash import Input, Output, State, callback, dcc, html +import dash_bootstrap_components as dbc + +import task_manager +from utils import nyquist_figure, store_decode + +dash.register_page(__name__, path="/fitting", name="Fitting") + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +layout = dbc.Container( + [ + html.H3( + [html.I(className="fa fa-flask me-2 text-primary"), "Step 3 — Run Fitting & Bayesian Inference"], + className="mb-3", + ), + + # Config summary + dbc.Card( + dbc.CardBody(html.Div(id="fit-config-summary")), + className="mb-3", + ), + + # Run / cancel buttons + dbc.Row( + [ + dbc.Col( + dbc.Button( + [html.I(className="fa fa-play me-2"), "Run Analysis"], + id="btn-run", + color="success", + size="lg", + n_clicks=0, + ) + ), + dbc.Col( + dbc.Button( + [html.I(className="fa fa-stop me-2"), "Cancel"], + id="btn-cancel", + color="danger", + size="lg", + outline=True, + disabled=True, + ) + ), + ], + className="mb-4 g-2", + ), + + # Progress area + dbc.Card( + dbc.CardBody( + [ + dbc.Row( + [ + dbc.Col( + html.Div(id="progress-stage", className="fw-semibold text-muted"), + md=9, + ), + dbc.Col( + html.Div(id="progress-pct", className="text-end text-muted"), + md=3, + ), + ] + ), + dbc.Progress(id="progress-bar", value=0, striped=True, animated=False, className="mt-2"), + html.Div(id="progress-alert", className="mt-3"), + ] + ), + id="progress-card", + className="mb-3", + ), + + # Candidate circuits table (populated as search runs) + dbc.Collapse( + [ + html.H5("Candidate Circuits Found", className="fw-semibold mt-3 mb-2"), + html.Div(id="circuits-table"), + ], + id="circuits-collapse", + is_open=False, + ), + + # Live Nyquist preview (best circuit fit after inference) + dbc.Collapse( + [ + html.H5("Fit Preview", className="fw-semibold mt-3 mb-2"), + dcc.Graph(id="fit-preview-graph"), + ], + id="fit-preview-collapse", + is_open=False, + ), + + html.Hr(), + + # Navigation + dbc.Row( + [ + dbc.Col( + dbc.Button( + [html.I(className="fa fa-arrow-left me-2"), "Back"], + href="/mode", + color="secondary", + outline=True, + ) + ), + dbc.Col( + dbc.Button( + [html.I(className="fa fa-chart-bar me-2"), "View Results"], + id="btn-to-results", + href="/results", + color="primary", + className="float-end", + disabled=True, + ) + ), + ] + ), + + # Polling interval — always running (lightweight: returns fast when no task) + dcc.Interval(id="poll-interval", interval=1500, disabled=False, n_intervals=0), + ], + fluid=True, +) + + +# --------------------------------------------------------------------------- +# Callbacks +# --------------------------------------------------------------------------- + +@callback( + Output("fit-config-summary", "children"), + Input("store-data", "data"), + Input("store-mode", "data"), +) +def show_summary(data, mode): + items = [] + if data: + freq, Z = store_decode(data) + items += [ + dbc.Badge(f"Data: {len(freq)} points", color="primary", className="me-2"), + dbc.Badge( + f"Freq: {freq.min():.2e}–{freq.max():.2e} Hz", + color="secondary", + className="me-2", + ), + ] + if mode: + m = mode.get("mode", "auto") + items.append(dbc.Badge(f"Mode: {m}", color="info", className="me-2")) + if m == "auto": + p = mode.get("params", {}) + items += [ + dbc.Badge(f"iters={p.get('iters', '?')}", color="light", text_color="dark", className="me-1"), + dbc.Badge(f"complexity={p.get('complexity', '?')}", color="light", text_color="dark", className="me-1"), + dbc.Badge(f"pop={p.get('population_size', '?')}", color="light", text_color="dark", className="me-1"), + ] + elif m == "expert": + items.append(dbc.Badge(mode.get("circuit_str", "?"), color="light", text_color="dark")) + elif m == "quick": + for c in mode.get("circuits", []): + items.append(dbc.Badge(c, color="light", text_color="dark", className="me-1")) + + if not items: + return html.P("No data or mode configured yet. Go back to complete previous steps.", className="text-muted") + return html.Div(items) + + +@callback( + Output("store-task-id", "data"), + Output("btn-run", "disabled"), + Output("btn-cancel", "disabled"), + Input("btn-run", "n_clicks"), + State("store-data", "data"), + State("store-mode", "data"), + prevent_initial_call=True, +) +def start_run(n_clicks, data, mode): + if not n_clicks or data is None or mode is None: + return dash.no_update, dash.no_update, dash.no_update + + tid = task_manager.create_task() + if tid is None: + tid = task_manager.create_error_task( + f"Server is busy — the maximum number of concurrent analyses " + f"({task_manager.MAX_CONCURRENT}) is already running. " + "Please try again in a few minutes." + ) + return tid, False, True # run btn stays enabled, cancel disabled + + freq, Z = store_decode(data) + task_manager.run_analysis(tid, freq, Z, mode) + return tid, True, False + + +@callback( + Output("poll-interval", "disabled", allow_duplicate=True), + Input("btn-cancel", "n_clicks"), + State("store-task-id", "data"), + prevent_initial_call=True, +) +def cancel_run(n_clicks, tid): + if tid: + task_manager._update(tid, status="error", error="Cancelled by user.") + # Keep interval alive so poll_progress picks up "error" status and updates the UI. + # poll_progress will disable the interval itself when it sees status=="error". + return dash.no_update + + +@callback( + Output("progress-bar", "value"), + Output("progress-bar", "animated"), + Output("progress-bar", "color"), + Output("progress-stage", "children"), + Output("progress-pct", "children"), + Output("progress-alert", "children"), + Output("circuits-table", "children"), + Output("circuits-collapse", "is_open"), + Output("fit-preview-graph", "figure"), + Output("fit-preview-collapse", "is_open"), + Output("btn-to-results", "disabled"), + Output("store-results", "data"), + Output("poll-interval", "disabled", allow_duplicate=True), + Output("btn-run", "disabled", allow_duplicate=True), + Output("btn-cancel", "disabled", allow_duplicate=True), + Input("poll-interval", "n_intervals"), + State("store-task-id", "data"), + State("store-data", "data"), + prevent_initial_call=True, +) +def poll_progress(n, tid, data): + no_change = ( + dash.no_update, dash.no_update, dash.no_update, + dash.no_update, dash.no_update, dash.no_update, + dash.no_update, dash.no_update, dash.no_update, + dash.no_update, dash.no_update, dash.no_update, dash.no_update, + dash.no_update, dash.no_update, + ) + if not tid: + return no_change + + state = task_manager.get_task(tid) + if not state: + return no_change + + progress = state.get("progress", 0) + stage = state.get("stage", "") + status = state.get("status", "pending") + circuits_data = state.get("circuits") + results_data = state.get("results") + + # Progress bar appearance + if status == "done": + bar_color = "success" + animated = False + elif status == "error": + bar_color = "danger" + animated = False + else: + bar_color = "info" + animated = True + + # Alert + alert = dash.no_update + if status == "error": + alert = dbc.Alert( + [html.Strong("Error: "), html.Pre(state.get("error", "Unknown error"), style={"whiteSpace": "pre-wrap"})], + color="danger", + dismissable=True, + ) + + # Circuits table + circuits_table = dash.no_update + circuits_open = dash.no_update + if circuits_data: + rows = [ + {"Circuit": r["circuitstring"], "N params": len(r.get("Parameters", {}))} + for r in circuits_data + ] + circuits_table = _make_circuits_table(rows) + circuits_open = True + + # Fit preview + preview_fig = dash.no_update + preview_open = dash.no_update + store_results = dash.no_update + results_btn_disabled = dash.no_update + + if results_data and data: + store_results = results_data + results_btn_disabled = False + # Show first converged result as preview + freq_d, Z_d = store_decode(data) + for r in results_data: + if r and r.get("converged"): + try: + import autoeis as ae + circuit_fn = ae.utils.generate_circuit_fn(r["circuit"]) + variables = r["variables"] + samples = r["samples"] + p_med = [float(np.median(samples[v])) for v in variables] + Z_fit = circuit_fn(freq_d, p_med) + preview_fig = nyquist_figure( + freq_d, Z_d, Z_fit=Z_fit, title=f"Best fit: {r['circuit']}" + ) + preview_open = True + except Exception: + pass + break + + # Stop polling when done or error + stop_poll = status in ("done", "error") + + return ( + progress, + animated, + bar_color, + stage, + f"{progress}%", + alert, + circuits_table, + circuits_open, + preview_fig, + preview_open, + results_btn_disabled, + store_results, + stop_poll, + status not in ("done", "error"), + status in ("done", "error"), + ) + + +def _make_circuits_table(rows): + from dash import dash_table as dt + return dt.DataTable( + data=rows, + columns=[ + {"name": "Circuit string", "id": "Circuit"}, + {"name": "# Parameters", "id": "N params"}, + ], + style_table={"overflowX": "auto"}, + style_cell={"fontSize": "12px", "fontFamily": "monospace", "padding": "4px 8px"}, + style_header={"fontWeight": "bold", "backgroundColor": "#f8f9fa"}, + page_size=15, + ) diff --git a/gui/pages/mode.py b/gui/pages/mode.py new file mode 100644 index 00000000..3ffbeb25 --- /dev/null +++ b/gui/pages/mode.py @@ -0,0 +1,438 @@ +"""Page 2 – Choose Analysis Mode (Quick / Auto / Expert).""" + +import dash +from dash import Input, Output, State, callback, ctx, dcc, html +import dash_bootstrap_components as dbc + +dash.register_page(__name__, path="/mode", name="Mode") + +# --------------------------------------------------------------------------- +# Common ECM list for Quick mode +# --------------------------------------------------------------------------- + +QUICK_CIRCUITS = [ + { + "id": "R1-[R2,P1]", + "name": "Randles (CPE)", + "formula": "R₁ − (R₂ ‖ CPE₁)", + "desc": "Classic Randles cell with CPE double layer. Solution resistance + parallel charge-transfer / CPE.", + }, + { + "id": "R1-[R2,P1]-[R3,P2]", + "name": "Two Time Constants", + "formula": "R₁ − (R₂ ‖ CPE₁) − (R₃ ‖ CPE₂)", + "desc": "Two RC-like loops — e.g. surface film + charge transfer.", + }, + { + "id": "R1-[R2-P1,R3]", + "name": "CPE Diffusion", + "formula": "R₁ − ((R₂ − CPE₁) ‖ R₃)", + "desc": "Two parallel paths: CPE-resistance branch vs. pure resistance. Models porous or diffusion-limited electrodes.", + }, + { + "id": "R1-[R2-P2,P1]", + "name": "Randles-Warburg", + "formula": "R₁ − ((R₂ − CPE₂) ‖ CPE₁)", + "desc": "Randles with diffusion arm: (Rct + Warburg-CPE) in parallel with double-layer CPE.", + }, + { + "id": "R1-[R2,P1]-[R3,P2]-[R4,P3]", + "name": "Three Time Constants", + "formula": "R₁ − (R₂ ‖ CPE₁) − (R₃ ‖ CPE₂) − (R₄ ‖ CPE₃)", + "desc": "Three relaxation processes — e.g. grain boundary, surface film, and charge transfer.", + }, + { + "id": "R1-P1", + "name": "Series CPE", + "formula": "R₁ − CPE₁", + "desc": "Electrolyte resistance in series with a CPE. Minimal model for supercapacitors / EDLC.", + }, +] + + +_CARD_STYLE_DEFAULT = { + "cursor": "pointer", "height": "100%", + "border": "2px solid #dee2e6", "borderRadius": "0.375rem", +} +_CARD_STYLE_SELECTED = { + "cursor": "pointer", "height": "100%", + "border": "3px solid #198754", "borderRadius": "0.375rem", + "backgroundColor": "#d1e7dd", +} + + +def _quick_card(circuit): + return html.Div( + dbc.Card( + [ + dbc.CardHeader(html.Strong(circuit["name"])), + dbc.CardBody( + [ + html.Code(circuit["id"], className="text-primary d-block mb-1"), + html.Span(circuit["formula"], className="text-muted small d-block mb-1"), + html.P(circuit["desc"], className="small mb-0"), + ] + ), + ], + className="h-100 border-0 bg-transparent", + ), + id={"type": "quick-card", "index": circuit["id"]}, + n_clicks=0, + style=_CARD_STYLE_DEFAULT, + ) + + +def _auto_controls(): + return dbc.Card( + dbc.CardBody( + [ + html.P( + "AutoEIS will use a genetic/evolutionary algorithm to search for " + "candidate equivalent circuit models. Tune the search hyperparameters below.", + className="text-muted small", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label("Iterations"), + dbc.Input(id="auto-iters", type="number", value=100, min=1, max=500, step=1), + dbc.FormText("Number of independent runs"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label("Complexity"), + dbc.Input(id="auto-complexity", type="number", value=12, min=2, max=20, step=1), + dbc.FormText("Max circuit elements"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label("Population size"), + dbc.Input(id="auto-pop", type="number", value=100, min=5, max=500, step=1), + dbc.FormText("ECMs per generation"), + ], + md=4, + ), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label("Generations"), + dbc.Input(id="auto-gens", type="number", value=30, min=5, max=200, step=1), + dbc.FormText("Evolutionary steps"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label("Tolerance"), + dbc.Select( + id="auto-tol", + options=[ + {"label": "5×10⁻² (very loose)", "value": "5e-2"}, + {"label": "1×10⁻² (default)", "value": "1e-2"}, + {"label": "5×10⁻³", "value": "5e-3"}, + {"label": "1×10⁻³ (strict)", "value": "1e-3"}, + {"label": "1×10⁻⁴ (very strict)", "value": "1e-4"}, + ], + value="1e-2", + ), + dbc.FormText("Convergence threshold"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label("Circuit elements"), + dbc.Checklist( + id="auto-terminals", + options=[ + {"label": "R (resistor)", "value": "R"}, + {"label": "C (capacitor)", "value": "C"}, + {"label": "L (inductor)", "value": "L"}, + {"label": "P (CPE)", "value": "P"}, + ], + value=["R", "L", "P"], + inline=True, + ), + ], + md=4, + ), + ], + className="mb-3", + ), + html.Hr(), + html.H6("Bayesian Inference Settings", className="fw-semibold"), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label("Warmup samples"), + dbc.Input(id="bi-warmup", type="number", value=2500, min=100, step=100), + dbc.FormText("Default: 2500"), + ], + md=4, + ), + dbc.Col( + [ + dbc.Label("Posterior samples"), + dbc.Input(id="bi-samples", type="number", value=1000, min=100, step=100), + dbc.FormText("Default: 1000"), + ], + md=4, + ), + ] + ), + ] + ) + ) + + +def _expert_controls(): + return dbc.Card( + dbc.CardBody( + [ + html.P( + "Enter a circuit string using AutoEIS CDC notation. " + "Use R, C, L, P for elements; − for series; [ , ] for parallel.", + className="text-muted small", + ), + dbc.Row( + [ + dbc.Col( + [ + dbc.Label("Circuit string:", className="fw-semibold"), + dbc.Input( + id="expert-circuit", + placeholder="e.g. R1-[R2,P1]", + debounce=True, + ), + dbc.FormText("Examples: R1 | R1-C1 | R1-[R2,P1] | R1-[R2,P1]-[R3,P2]"), + ], + md=8, + ), + dbc.Col( + dbc.Button( + [html.I(className="fa fa-eye me-1"), "Preview Circuit"], + id="btn-preview", + color="info", + className="mt-4", + ), + md=4, + ), + ] + ), + # Circuit diagram preview + dbc.Collapse( + [ + html.Hr(), + html.H6("Circuit Diagram", className="fw-semibold"), + html.Div(id="circuit-diagram"), + ], + id="diagram-collapse", + is_open=False, + ), + ] + ) + ) + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +layout = dbc.Container( + [ + html.H3( + [html.I(className="fa fa-cogs me-2 text-primary"), "Step 2 — Choose Analysis Mode"], + className="mb-3", + ), + + # Mode selector tabs + dbc.Tabs( + [ + dbc.Tab(label="⚡ Quick Mode", tab_id="quick"), + dbc.Tab(label="🤖 Auto Mode", tab_id="auto"), + dbc.Tab(label="🔬 Expert Mode", tab_id="expert"), + ], + id="mode-tabs", + active_tab="auto", + className="mb-3", + ), + + # Quick mode content + dbc.Collapse( + [ + html.P( + "Select one or more common equivalent circuit models to fit directly.", + className="text-muted", + ), + dbc.Row( + [dbc.Col(_quick_card(c), md=4, className="mb-3") for c in QUICK_CIRCUITS], + ), + html.Div(id="quick-selected-label", className="text-muted small mb-2"), + dcc.Store(id="store-quick-selected", data=[]), + ], + id="collapse-quick", + is_open=False, + ), + + # Auto mode content + dbc.Collapse(_auto_controls(), id="collapse-auto", is_open=True), + + # Expert mode content + dbc.Collapse(_expert_controls(), id="collapse-expert", is_open=False), + + html.Hr(), + + # Navigation + dbc.Row( + [ + dbc.Col( + dbc.Button( + [html.I(className="fa fa-arrow-left me-2"), "Back"], + href="/", + color="secondary", + outline=True, + ) + ), + dbc.Col( + dbc.Button( + [html.I(className="fa fa-play me-2"), "Next: Run Fitting"], + id="btn-next-mode", + href="/fitting", + color="primary", + className="float-end", + ) + ), + ] + ), + ], + fluid=True, +) + + +# --------------------------------------------------------------------------- +# Callbacks +# --------------------------------------------------------------------------- + +@callback( + Output("collapse-quick", "is_open"), + Output("collapse-auto", "is_open"), + Output("collapse-expert", "is_open"), + Input("mode-tabs", "active_tab"), +) +def switch_mode(tab): + return tab == "quick", tab == "auto", tab == "expert" + + +@callback( + Output("store-quick-selected", "data"), + Output("quick-selected-label", "children"), + Output({"type": "quick-card", "index": dash.ALL}, "style"), + Input({"type": "quick-card", "index": dash.ALL}, "n_clicks"), + State("store-quick-selected", "data"), + prevent_initial_call=True, +) +def toggle_quick_selection(n_clicks_list, selected): + triggered = ctx.triggered_id + # Guard: only process real clicks (triggered id must be a dict with an index) + if not triggered or not isinstance(triggered, dict) or "index" not in triggered: + return dash.no_update, dash.no_update, dash.no_update + # Guard: ensure the triggering card actually has a click (not a re-render artifact) + triggered_nc = next( + (nc for c, nc in zip(QUICK_CIRCUITS, n_clicks_list or []) if c["id"] == triggered["index"]), + None, + ) + if not triggered_nc: + return dash.no_update, dash.no_update, dash.no_update + + selected = list(selected or []) + cid = triggered["index"] + if cid in selected: + selected.remove(cid) + else: + selected.append(cid) + + if selected: + label = dbc.Alert( + [html.I(className="fa fa-check-circle me-2"), f"Selected: {', '.join(selected)}"], + color="success", className="py-2 mb-0", + ) + else: + label = html.Span("No circuits selected.", className="text-muted") + + styles = [ + _CARD_STYLE_SELECTED if c["id"] in selected else _CARD_STYLE_DEFAULT + for c in QUICK_CIRCUITS + ] + return selected, label, styles + + +@callback( + Output("circuit-diagram", "children"), + Output("diagram-collapse", "is_open"), + Input("btn-preview", "n_clicks"), + State("expert-circuit", "value"), + prevent_initial_call=True, +) +def preview_circuit(n, circuit_str): + if not circuit_str or not circuit_str.strip(): + return dbc.Alert("Please enter a circuit string first.", color="warning"), False + try: + from autoeis.visualization import draw_circuit + from utils import mpl_fig_to_src + fig = draw_circuit(circuit_str.strip()) + if fig is None: + raise ValueError("draw_circuit returned None (lcapy may not be installed).") + src = mpl_fig_to_src(fig) + return html.Img(src=src, style={"maxWidth": "100%"}), True + except Exception as exc: + return dbc.Alert(f"Could not render: {exc}", color="warning"), True + + +# Auto-save mode config to shared store whenever any setting changes +@callback( + Output("store-mode", "data"), + Input("mode-tabs", "active_tab"), + Input("store-quick-selected", "data"), + Input("expert-circuit", "value"), + Input("auto-iters", "value"), + Input("auto-complexity", "value"), + Input("auto-pop", "value"), + Input("auto-gens", "value"), + Input("auto-tol", "value"), + Input("auto-terminals", "value"), + Input("bi-warmup", "value"), + Input("bi-samples", "value"), +) +def save_mode_config( + active_tab, + quick_selected, + expert_circuit, + iters, complexity, pop, gens, tol, terminals, + bi_warmup, bi_samples, +): + params = { + "iters": int(iters or 100), + "complexity": int(complexity or 12), + "population_size": int(pop or 100), + "generations": int(gens or 30), + "tol": float(tol or 1e-2), + "terminals": "".join(terminals or ["R", "L", "P"]), + "num_warmup": int(bi_warmup or 2500), + "num_samples": int(bi_samples or 1000), + } + + if active_tab == "quick": + return {"mode": "quick", "circuits": quick_selected or [], "params": params} + elif active_tab == "expert": + return {"mode": "expert", "circuit_str": (expert_circuit or "").strip(), "params": params} + return {"mode": "auto", "params": params} diff --git a/gui/pages/results.py b/gui/pages/results.py new file mode 100644 index 00000000..b94b06e3 --- /dev/null +++ b/gui/pages/results.py @@ -0,0 +1,682 @@ +"""Page 4 – Results: ranking, parameter posteriors, plots, export.""" + +import base64 +import io +import json + +import dash +import numpy as np +import pandas as pd +from dash import Input, Output, State, callback, dcc, html +from dash import dash_table as dt +import dash_bootstrap_components as dbc +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from utils import bode_figure, mpl_fig_to_src, nyquist_figure, residual_figure, store_decode +from eis_posterior_quality import assess_posterior_quality + +dash.register_page(__name__, path="/results", name="Results") + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +layout = dbc.Container( + [ + html.H3( + [html.I(className="fa fa-chart-bar me-2 text-primary"), "Step 4 — Results & Export"], + className="mb-3", + ), + + html.Div(id="results-alert"), + + # Ranking table + dbc.Card( + [ + dbc.CardHeader(html.H5("ECM Ranking (converged circuits)", className="mb-0")), + dbc.CardBody(html.Div(id="ranking-table")), + ], + className="mb-3", + ), + + # Detailed view for selected circuit + dbc.Card( + [ + dbc.CardHeader( + dbc.Row( + [ + dbc.Col(html.H5("Circuit Detail", className="mb-0"), md=6), + dbc.Col( + dcc.Dropdown( + id="circuit-selector", + placeholder="Select a circuit…", + clearable=False, + style={"fontSize": "13px"}, + ), + md=6, + ), + ], + align="center", + ) + ), + dbc.CardBody( + [ + dbc.Row( + [ + # Circuit diagram + dbc.Col( + [ + html.H6("Circuit Diagram", className="fw-semibold"), + html.Div(id="detail-diagram"), + ], + md=4, + ), + # Parameter table with plausibility + dbc.Col( + [ + html.H6("Parameter Posterior Summary", className="fw-semibold"), + html.Div(id="detail-params"), + ], + md=8, + ), + ], + className="mb-3", + ), + dbc.Row( + [ + dbc.Col( + [ + dcc.Tabs( + [ + dcc.Tab( + dcc.Graph(id="detail-nyquist", style={"height": "420px"}), + label="Nyquist", + ), + dcc.Tab( + dcc.Graph(id="detail-bode", style={"height": "420px"}), + label="Bode", + ), + dcc.Tab( + dcc.Graph(id="detail-residual", style={"height": "320px"}), + label="Residuals", + ), + dcc.Tab( + dcc.Graph(id="detail-posterior-pred", style={"height": "420px"}), + label="Posterior Prediction", + ), + dcc.Tab( + html.Div(id="detail-posterior"), + label="Parameter Posteriors", + ), + ] + ) + ] + ) + ] + ), + ] + ), + ], + className="mb-3", + ), + + # Export section + dbc.Card( + [ + dbc.CardHeader(html.H5("Export", className="mb-0")), + dbc.CardBody( + [ + dbc.Row( + [ + dbc.Col( + dbc.Button( + [html.I(className="fa fa-file-csv me-2"), "Export Parameters CSV"], + id="btn-export-csv", + color="success", + outline=True, + ), + md=3, + ), + dbc.Col( + dbc.Button( + [html.I(className="fa fa-file-code me-2"), "Export Results JSON"], + id="btn-export-json", + color="info", + outline=True, + ), + md=3, + ), + ], + className="g-2", + ), + dcc.Download(id="download-csv"), + dcc.Download(id="download-json"), + ] + ), + ], + className="mb-3", + ), + + dbc.Button( + [html.I(className="fa fa-arrow-left me-2"), "Back to Fitting"], + href="/fitting", + color="secondary", + outline=True, + ), + ], + fluid=True, +) + + +# --------------------------------------------------------------------------- +# Status styling for plausibility +# --------------------------------------------------------------------------- + +_STATUS_ICON = { + "NORMAL": "✓", + "LOGNORMAL": "✓", + "UNCERTAIN": "~", + "MULTIMODAL": "✗", + "BOUNDARY_HIT": "✗", + "UNINFORMATIVE":"✗", + "UNIFORM": "✗", +} + + +# --------------------------------------------------------------------------- +# Helper: compute fit quality (RMSE) using median posterior parameters +# --------------------------------------------------------------------------- + +def _compute_fit(result: dict, freq: np.ndarray, Z: np.ndarray): + import autoeis as ae + variables = result["variables"] + samples = result["samples"] + p_med = [float(np.median(samples[v])) for v in variables] + circuit_fn = ae.utils.generate_circuit_fn(result["circuit"]) + Z_fit = circuit_fn(freq, p_med) + rmse = float(np.sqrt(np.mean(np.abs(Z - Z_fit) ** 2))) + return Z_fit, rmse, p_med + + +# --------------------------------------------------------------------------- +# Helper: parameter summary table with plausibility +# --------------------------------------------------------------------------- + +def _param_summary_table(result: dict, quality: dict | None = None): + variables = result["variables"] + samples = result["samples"] + per_param = (quality or {}).get("per_parameter", {}) + + rows = [] + for v in variables: + s = np.array(samples[v]) + pq = per_param.get(v, {}) + status = pq.get("status", "") + passes = pq.get("passes", None) + + if passes is True: + plaus = "PASS" + elif passes is False: + plaus = "FAIL" + else: + plaus = "" + + rows.append( + { + "Parameter": v, + "Plausible": plaus, + "Distribution": status, + "Median": f"{np.median(s):.4e}", + "Mean": f"{np.mean(s):.4e}", + "Std": f"{np.std(s):.4e}", + "95% CI": f"[{np.percentile(s, 2.5):.3e}, {np.percentile(s, 97.5):.3e}]", + } + ) + + style_cond = [ + { + "if": {"filter_query": '{Plausible} = "PASS"', "column_id": "Plausible"}, + "color": "#198754", + "fontWeight": "bold", + }, + { + "if": {"filter_query": '{Plausible} = "FAIL"', "column_id": "Plausible"}, + "color": "#dc3545", + "fontWeight": "bold", + }, + { + "if": {"filter_query": '{Plausible} = "FAIL"', "column_id": "Distribution"}, + "color": "#dc3545", + }, + ] + + legend = html.Div( + [ + html.Small(html.Strong("Plausibility legend:"), className="text-muted"), + html.Ul( + [ + html.Li([ + html.Span("PASS", style={"color": "#198754", "fontWeight": "bold"}), + html.Span(" — posterior well-constrained: NORMAL (symmetric bell curve), " + "LOGNORMAL (right-skewed, typical for R/C), " + "UNCERTAIN (wide but unimodal)"), + ], className="small text-muted"), + html.Li([ + html.Span("FAIL", style={"color": "#dc3545", "fontWeight": "bold"}), + html.Span(" — MULTIMODAL: multiple peaks — parameter is non-identifiable"), + ], className="small text-muted"), + html.Li([ + html.Span("FAIL", style={"color": "#dc3545", "fontWeight": "bold"}), + html.Span(" — BOUNDARY_HIT: chain hit the prior boundary (value at its limit)"), + ], className="small text-muted"), + html.Li([ + html.Span("FAIL", style={"color": "#dc3545", "fontWeight": "bold"}), + html.Span(" — UNINFORMATIVE / UNIFORM: posterior is flat — data carries no information about this parameter"), + ], className="small text-muted"), + ], + className="mb-1 ps-3", + ), + ], + className="mb-2", + ) + + table = dt.DataTable( + data=rows, + columns=[{"name": k, "id": k} for k in + ["Parameter", "Plausible", "Distribution", "Median", "Mean", "Std", "95% CI"]], + style_table={"overflowX": "auto"}, + style_cell={"fontSize": "12px", "fontFamily": "monospace", "padding": "4px 8px"}, + style_header={"fontWeight": "bold", "backgroundColor": "#f8f9fa"}, + style_data_conditional=style_cond, + ) + return html.Div([legend, table]) + + +# --------------------------------------------------------------------------- +# Helper: posterior prediction Nyquist (grey envelope + measured data) +# --------------------------------------------------------------------------- + +def _posterior_prediction_figure(result: dict) -> go.Figure: + import autoeis as ae + + variables = result["variables"] + samples = result["samples"] + freq = np.array(result["freq"]) + Z_meas = np.array(result["z_real"]) + 1j * np.array(result["z_imag"]) + + circuit_fn = ae.utils.generate_circuit_fn(result["circuit"]) + + n_total = len(samples[variables[0]]) + n_draw = min(150, n_total) + indices = np.linspace(0, n_total - 1, n_draw, dtype=int) + + x_pred, y_pred = [], [] + for i in indices: + params = [float(samples[v][i]) for v in variables] + try: + Z_sim = circuit_fn(freq, params) + x_pred.extend(list(Z_sim.real) + [None]) + y_pred.extend(list(-Z_sim.imag) + [None]) + except Exception: + pass + + fig = go.Figure() + + if x_pred: + fig.add_trace( + go.Scatter( + x=x_pred, + y=y_pred, + mode="lines", + name=f"Posterior samples ({n_draw})", + line=dict(color="lightgray", width=1), + opacity=0.6, + hoverinfo="skip", + ) + ) + + fig.add_trace( + go.Scatter( + x=Z_meas.real, + y=-Z_meas.imag, + mode="markers", + name="Measured", + marker=dict(color="#1f77b4", size=8, opacity=0.9), + ) + ) + + fig.update_layout( + title=dict(text=f"Posterior Prediction — {result['circuit']}", x=0.5), + xaxis_title="Re(Z) [Ω]", + yaxis_title="−Im(Z) [Ω]", + yaxis=dict(scaleanchor="x", scaleratio=1), + hovermode="closest", + height=420, + margin=dict(l=50, r=20, t=50, b=50), + legend=dict(x=0.01, y=0.99, bgcolor="rgba(255,255,255,0.8)"), + ) + return fig + + +# --------------------------------------------------------------------------- +# Helper: per-parameter posterior histograms with plausibility colouring +# --------------------------------------------------------------------------- + +def _posterior_plots(result: dict, quality: dict | None = None): + variables = result["variables"] + samples = result["samples"] + per_param = (quality or {}).get("per_parameter", {}) + + n = len(variables) + cols = min(n, 3) + rows = (n + cols - 1) // cols + + fig = make_subplots( + rows=rows, cols=cols, + subplot_titles=variables, + vertical_spacing=0.18, + horizontal_spacing=0.1, + ) + + for idx, v in enumerate(variables): + row = idx // cols + 1 + col = idx % cols + 1 + s = np.array(samples[v]) + + pq = per_param.get(v, {}) + status = pq.get("status", "NORMAL") + passes = pq.get("passes", True) + color = "#198754" if passes else "#dc3545" + if status == "UNCERTAIN": + color = "#fd7e14" + + log_scale = bool(np.std(s) / (np.median(s) + 1e-30) > 2) + x = np.log10(s + 1e-30) if log_scale else s + xlabel = f"log₁₀({v})" if log_scale else v + + fig.add_trace( + go.Histogram( + x=x, nbinsx=40, name=xlabel, + showlegend=False, + marker_color=color, opacity=0.75, + ), + row=row, col=col, + ) + + if status and status != "NORMAL": + fig.add_annotation( + text=f"{_STATUS_ICON.get(status, '?')} {status}", + xref=f"x{idx + 1 if idx > 0 else ''} domain", + yref=f"y{idx + 1 if idx > 0 else ''} domain", + x=0.98, y=0.95, + showarrow=False, + font=dict(size=10, color=color), + align="right", + row=row, col=col, + ) + + fig.update_layout( + height=max(260 * rows, 300), + margin=dict(t=50, b=30, l=40, r=20), + ) + return dcc.Graph(figure=fig) + + +# --------------------------------------------------------------------------- +# Callbacks +# --------------------------------------------------------------------------- + +@callback( + Output("ranking-table", "children"), + Output("circuit-selector", "options"), + Output("circuit-selector", "value"), + Output("results-alert", "children"), + Input("store-results", "data"), + State("store-data", "data"), +) +def populate_ranking(results, data): + if not results: + msg = dbc.Alert( + "No results yet. Complete Step 3 first.", + color="warning", + ) + return msg, [], None, None + + if not data: + return dbc.Alert("Data store is empty. Please restart from Step 1.", color="danger"), [], None, None + + freq, Z = store_decode(data) + + rows = [] + for i, r in enumerate(results): + if r is None or not r.get("converged"): + rows.append( + { + "Rank": i + 1, + "Circuit": (r or {}).get("circuit", "N/A"), + "Converged": "✗", + "RMSE [Ω]": "—", + "# Params": "—", + "Bad Params": "—", + } + ) + continue + try: + _, rmse, _ = _compute_fit(r, freq, Z) + except Exception: + rmse = float("nan") + + # Count implausible parameters + n_bad = 0 + try: + q = assess_posterior_quality( + r["samples"], + r["variables"], + num_divergences=r.get("num_divergences", 0), + num_samples=len(r["samples"].get(r["variables"][0], [])), + verbose=False, + ) + n_bad = len(q.get("failed_params", [])) + except Exception: + pass + + rows.append( + { + "Rank": i + 1, + "Circuit": r["circuit"], + "Converged": "✓", + "RMSE [Ω]": f"{rmse:.4e}", + "# Params": len(r["variables"]), + "Bad Params": n_bad, + } + ) + + def sort_key(row): + try: + return float(row["RMSE [Ω]"]) + except Exception: + return float("inf") + + rows.sort(key=sort_key) + for i, r in enumerate(rows): + r["Rank"] = i + 1 + + table = dt.DataTable( + data=rows, + columns=[{"name": k, "id": k} for k in + ["Rank", "Circuit", "Converged", "RMSE [Ω]", "# Params", "Bad Params"]], + style_table={"overflowX": "auto"}, + style_cell={"fontSize": "13px", "fontFamily": "monospace", "padding": "4px 10px"}, + style_header={"fontWeight": "bold", "backgroundColor": "#f8f9fa"}, + style_data_conditional=[ + {"if": {"filter_query": '{Converged} = "✓"', "column_id": "Converged"}, "color": "#198754"}, + {"if": {"filter_query": '{Converged} = "✗"', "column_id": "Converged"}, "color": "#dc3545"}, + {"if": {"row_index": 0, "filter_query": '{Converged} = "✓"'}, "backgroundColor": "#d1e7dd"}, + {"if": {"filter_query": "{Bad Params} > 0", "column_id": "Bad Params"}, + "color": "#dc3545", "fontWeight": "bold"}, + ], + tooltip_header={ + "Bad Params": "Number of parameters with FAIL posterior quality " + "(MULTIMODAL / BOUNDARY_HIT / UNINFORMATIVE / UNIFORM)" + }, + tooltip_delay=0, + tooltip_duration=None, + ) + + opts = [ + {"label": f"[{r['Rank']}] {r['Circuit']}", "value": r["Circuit"]} + for r in rows + if r.get("Converged") == "✓" + ] + default = opts[0]["value"] if opts else None + return table, opts, default, None + + +@callback( + Output("detail-diagram", "children"), + Output("detail-params", "children"), + Output("detail-nyquist", "figure"), + Output("detail-bode", "figure"), + Output("detail-residual", "figure"), + Output("detail-posterior-pred", "figure"), + Output("detail-posterior", "children"), + Input("circuit-selector", "value"), + State("store-results", "data"), + State("store-data", "data"), + prevent_initial_call=True, +) +def show_detail(circuit_str, results, data): + _empty_figs = ({}, {}, {}, {}) + _empty_children = (html.Div(), html.Div(), html.Div()) + empty = _empty_children[:2] + _empty_figs + _empty_children[2:] + if not circuit_str or not results or not data: + return empty + + freq, Z = store_decode(data) + + result = next((r for r in results if r and r.get("circuit") == circuit_str), None) + if result is None or not result.get("converged"): + return (dbc.Alert("No converged result for this circuit.", color="warning"), + html.Div(), {}, {}, {}, {}, html.Div()) + + # ── Posterior quality assessment ───────────────────────────────────── + quality = None + try: + quality = assess_posterior_quality( + result["samples"], + result["variables"], + num_divergences=result.get("num_divergences", 0), + num_samples=len(result["samples"].get(result["variables"][0], [])), + verbose=False, + ) + except Exception: + pass + + # ── Circuit diagram ─────────────────────────────────────────────────── + diagram = html.Div() + try: + from autoeis.visualization import draw_circuit + fig_mpl = draw_circuit(circuit_str) + if fig_mpl is not None: + src = mpl_fig_to_src(fig_mpl) + diagram = html.Img(src=src, style={"maxWidth": "100%"}) + else: + raise ValueError("draw_circuit returned None") + except Exception: + diagram = html.Div([ + html.Small("Circuit diagram (requires lcapy + pdflatex):", className="text-muted"), + html.Br(), + html.Code(circuit_str, className="text-primary fs-6"), + ]) + + # ── Quality badge (overall) ─────────────────────────────────────────── + if quality is not None: + overall = quality.get("overall_pass", True) + div_ratio = quality.get("div_ratio", 0) + failed = quality.get("failed_params", []) + badge_color = "success" if overall else "danger" + badge_text = ( + f"✓ PASS — divergence {div_ratio:.1%}, all params acceptable" + if overall + else f"✗ FAIL — {', '.join(failed) if failed else 'divergence too high'}" + ) + quality_badge = dbc.Alert(badge_text, color=badge_color, className="py-1 small mb-2") + else: + quality_badge = html.Div() + + # ── Fit ─────────────────────────────────────────────────────────────── + try: + Z_fit, rmse, p_med = _compute_fit(result, freq, Z) + except Exception as exc: + return diagram, dbc.Alert(str(exc), color="danger"), {}, {}, {}, {}, html.Div() + + # ── Parameter summary with plausibility ────────────────────────────── + param_table = html.Div([ + quality_badge, + _param_summary_table(result, quality), + ]) + + # ── Plots (Plotly) ──────────────────────────────────────────────────── + nq_fig = nyquist_figure(freq, Z, Z_fit=Z_fit, title=f"Nyquist — {circuit_str}") + bd_fig = bode_figure(freq, Z, Z_fit=Z_fit, freq_fit=freq, title=f"Bode — {circuit_str}") + res_fig = residual_figure(freq, Z, Z_fit) + + try: + pred_fig = _posterior_prediction_figure(result) + except Exception as exc: + pred_fig = go.Figure().add_annotation(text=f"Error: {exc}", showarrow=False) + + posterior = _posterior_plots(result, quality) + + return diagram, param_table, nq_fig, bd_fig, res_fig, pred_fig, posterior + + +# --------------------------------------------------------------------------- +# Export callbacks +# --------------------------------------------------------------------------- + +@callback( + Output("download-csv", "data"), + Input("btn-export-csv", "n_clicks"), + State("store-results", "data"), + State("store-data", "data"), + prevent_initial_call=True, +) +def export_csv(n, results, data): + if not results or not data: + return dash.no_update + freq, Z = store_decode(data) + rows = [] + for r in results: + if not r or not r.get("converged"): + continue + variables = r["variables"] + samples = r["samples"] + row = {"circuit": r["circuit"]} + for v in variables: + s = np.array(samples[v]) + row[f"{v}_median"] = np.median(s) + row[f"{v}_mean"] = np.mean(s) + row[f"{v}_std"] = np.std(s) + try: + _, rmse, _ = _compute_fit(r, freq, Z) + row["rmse"] = rmse + except Exception: + row["rmse"] = float("nan") + rows.append(row) + df = pd.DataFrame(rows) + return dcc.send_data_frame(df.to_csv, "autoeis_results.csv", index=False) + + +@callback( + Output("download-json", "data"), + Input("btn-export-json", "n_clicks"), + State("store-results", "data"), + prevent_initial_call=True, +) +def export_json(n, results): + if not results: + return dash.no_update + content = json.dumps(results, indent=2) + return dict(content=content, filename="autoeis_results.json") diff --git a/gui/requirements.txt b/gui/requirements.txt new file mode 100644 index 00000000..72c0354e --- /dev/null +++ b/gui/requirements.txt @@ -0,0 +1,9 @@ +dash>=4.0.0 +dash-bootstrap-components>=2.0.0 +plotly>=5.18.0 +pandas>=2.0.0 +numpy>=1.24.0 +matplotlib>=3.7.0 +openpyxl>=3.1.0 +autoeis>=0.0.35 +gunicorn>=21.2.0 diff --git a/gui/task_manager.py b/gui/task_manager.py new file mode 100644 index 00000000..83e16b6e --- /dev/null +++ b/gui/task_manager.py @@ -0,0 +1,246 @@ +"""Background task management for long-running AutoEIS computations. + +Single-process design: suitable for one user at a time (local / lab use) or +low-traffic public deployments. State is keyed by task_id so multiple +simultaneous runs don't collide. + +Public-use guards: + - MAX_CONCURRENT_TASKS: cap on simultaneous running analyses (env var). + - Tasks are automatically expired and removed after TASK_TTL_S seconds so + the in-process dict doesn't grow unboundedly. +""" + +import os +import threading +import time +import uuid +from typing import Any + +_tasks: dict[str, dict[str, Any]] = {} +_lock = threading.Lock() + +MAX_CONCURRENT = int(os.environ.get("MAX_CONCURRENT_TASKS", "3")) +TASK_TTL_S = int(os.environ.get("TASK_TTL_S", str(60 * 60))) # 1 hour default +TASK_TIMEOUT_S = int(os.environ.get("TASK_TIMEOUT_S", str(30 * 60))) # 30 min hard kill + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _active_count() -> int: + with _lock: + return sum(1 for t in _tasks.values() if t["status"] == "running") + + +def _expire_old_tasks(): + """Remove tasks older than TASK_TTL_S. Called on each create_task().""" + now = time.time() + with _lock: + stale = [tid for tid, t in _tasks.items() if now - t["created_at"] > TASK_TTL_S] + for tid in stale: + del _tasks[tid] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def create_task() -> str | None: + """Return a new task ID, or None if the server is at capacity.""" + _expire_old_tasks() + if _active_count() >= MAX_CONCURRENT: + return None + tid = str(uuid.uuid4()) + with _lock: + _tasks[tid] = { + "status": "pending", # pending | running | done | error + "progress": 0, + "stage": "", + "circuits": None, # list[dict] for display in fitting page + "results": None, # list[serialised InferenceResult] + "error": "", + "created_at": time.time(), + } + return tid + + +def create_error_task(message: str) -> str: + """Create a task pre-filled with an error message (used for capacity rejection).""" + tid = str(uuid.uuid4()) + with _lock: + _tasks[tid] = { + "status": "error", + "progress": 0, + "stage": "", + "circuits": None, + "results": None, + "error": message, + "created_at": time.time(), + } + return tid + + +def _update(tid: str, **kw): + with _lock: + if tid in _tasks: + _tasks[tid].update(kw) + + +def get_task(tid: str) -> dict: + with _lock: + return dict(_tasks.get(tid, {})) + + +def delete_task(tid: str): + with _lock: + _tasks.pop(tid, None) + + +# --------------------------------------------------------------------------- +# Worker +# --------------------------------------------------------------------------- + +def run_analysis(tid: str, freq, Z, mode_config: dict): + """Launch the AutoEIS pipeline in a daemon thread.""" + + def _worker(): + import numpy as np + import autoeis as ae + + _update(tid, status="running", progress=2, stage="Initialising…") + deadline = time.time() + TASK_TIMEOUT_S + + def _check_timeout(): + if time.time() > deadline: + raise TimeoutError( + f"Analysis exceeded the {TASK_TIMEOUT_S // 60}-minute time limit." + ) + + try: + mode = mode_config.get("mode", "auto") + params = mode_config.get("params", {}) + + # ---------------------------------------------------------------- + # Stage 1: generate / select candidate circuits + # ---------------------------------------------------------------- + if mode == "quick": + _update(tid, progress=10, stage="Using pre-defined circuits…") + circuit_strings = mode_config.get("circuits", []) + if not circuit_strings: + _update(tid, status="error", error="No circuits selected. Go back to Mode and select at least one.") + return + circuits_for_bi = circuit_strings + circuits_records = [ + {"circuitstring": c, "Parameters": dict.fromkeys(ae.parser.get_parameter_labels(c))} + for c in circuit_strings + ] + + elif mode == "expert": + _update(tid, progress=10, stage="Using expert circuit…") + circuit_str = (mode_config.get("circuit_str") or "").strip() + if not circuit_str: + _update(tid, status="error", error="No circuit string provided. Go back to Mode and enter a circuit.") + return + circuits_for_bi = [circuit_str] + circuits_records = [ + {"circuitstring": circuit_str, "Parameters": dict.fromkeys(ae.parser.get_parameter_labels(circuit_str))} + ] + + else: # auto + _check_timeout() + _update(tid, progress=5, stage="Generating candidate circuits (evolutionary search)…") + circuits_unfiltered = ae.core.generate_equivalent_circuits( + freq, Z, + iters=int(params.get("iters", 100)), + complexity=int(params.get("complexity", 12)), + population_size=int(params.get("population_size", 100)), + generations=int(params.get("generations", 30)), + tol=float(params.get("tol", 1e-2)), + terminals=params.get("terminals", "RLP"), + parallel=True, + ) + if circuits_unfiltered is None or len(circuits_unfiltered) == 0: + _update(tid, status="error", error="Circuit generation returned no candidates. Try relaxing tol or increasing iters.") + return + n_unfiltered = len(circuits_unfiltered) + _check_timeout() + _update(tid, progress=45, stage=f"Filtering circuits ({n_unfiltered} candidates)…") + circuits_filtered = ae.core.filter_implausible_circuits(circuits_unfiltered) + if circuits_filtered is None or len(circuits_filtered) == 0: + _update(tid, status="error", error="All generated circuits were filtered as implausible. Try different hyperparameters.") + return + circuits_for_bi = circuits_filtered + circuits_records = circuits_filtered.to_dict("records") + + n_circuits = len(circuits_for_bi) + _update(tid, progress=50, stage=f"{n_circuits} circuit(s) ready for inference", circuits=circuits_records) + + if n_circuits == 0: + _update(tid, status="error", error="No valid circuits found.") + return + + # ---------------------------------------------------------------- + # Stage 2: Bayesian inference + # ---------------------------------------------------------------- + _check_timeout() + _update(tid, progress=55, stage="Running Bayesian inference (this may take several minutes)…") + results = ae.core.perform_bayesian_inference( + circuits_for_bi, + freq, + Z, + num_warmup=int(params.get("num_warmup", 2500)), + num_samples=int(params.get("num_samples", 1000)), + num_chains=1, + refine_p0=True, + parallel=False, + progress_bar=False, + ) + + if not isinstance(results, list): + results = [results] + + # ---------------------------------------------------------------- + # Serialise results for JSON storage in dcc.Store + # ---------------------------------------------------------------- + serialised = [] + for r in results: + if r is None: + serialised.append(None) + continue + if r.converged: + samples = {k: np.array(v).tolist() for k, v in r.samples.items()} + try: + num_div = int(r.num_divergences) + except Exception: + num_div = 0 + else: + samples = {} + num_div = 0 + serialised.append( + { + "circuit": r.circuit, + "converged": bool(r.converged), + "variables": list(r.variables), + "samples": samples, + "num_divergences": num_div, + "freq": np.array(r.freq).tolist(), + "z_real": np.array(r.Z.real).tolist(), + "z_imag": np.array(r.Z.imag).tolist(), + } + ) + + _update( + tid, + status="done", + progress=100, + stage="Analysis complete!", + results=serialised, + ) + + except Exception as exc: + import traceback + _update(tid, status="error", error=f"{exc}\n\n{traceback.format_exc()}") + + t = threading.Thread(target=_worker, daemon=True) + t.start() diff --git a/gui/utils.py b/gui/utils.py new file mode 100644 index 00000000..61dec4d1 --- /dev/null +++ b/gui/utils.py @@ -0,0 +1,324 @@ +"""Helper utilities for AutoEIS GUI: file parsing and Plotly figure builders.""" + +import base64 +import io + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + + +# --------------------------------------------------------------------------- +# File parsing +# --------------------------------------------------------------------------- + +def parse_uploaded_file(contents: str, filename: str) -> tuple[pd.DataFrame | None, str | None]: + """Decode a Dash Upload component payload into a DataFrame.""" + _, content_string = contents.split(",", 1) + decoded = base64.b64decode(content_string) + try: + if filename.lower().endswith(".csv"): + df = pd.read_csv(io.StringIO(decoded.decode("utf-8"))) + elif filename.lower().endswith((".xls", ".xlsx")): + df = pd.read_excel(io.BytesIO(decoded)) + elif filename.lower().endswith(".txt"): + # Try common separators in order + for sep in ["\t", ",", ";", r"\s+"]: + try: + df = pd.read_csv(io.StringIO(decoded.decode("utf-8")), sep=sep, engine="python") + if len(df.columns) >= 3: + break + except Exception: + continue + else: + return None, "Could not parse .txt file — try saving as CSV." + else: + return None, f"Unsupported format: {filename}. Use CSV, TXT, or XLSX." + return df, None + except Exception as exc: + return None, str(exc) + + +def guess_columns(columns: list[str]) -> dict[str, str | None]: + """Heuristically guess which column maps to freq / Zreal / Zimag.""" + mapping = {"freq": None, "zreal": None, "zimag": None} + for col in columns: + c = col.lower().replace(" ", "").replace("(", "").replace(")", "").replace("/", "") + if mapping["freq"] is None and any(k in c for k in ["freq", "hz", "f"]): + mapping["freq"] = col + elif mapping["zreal"] is None and any(k in c for k in ["re", "real", "zre", "z'"]): + mapping["zreal"] = col + elif mapping["zimag"] is None and any(k in c for k in ["im", "imag", "zim", 'z"', "zi"]): + mapping["zimag"] = col + return mapping + + +# --------------------------------------------------------------------------- +# Store serialisation helpers +# --------------------------------------------------------------------------- + +def store_encode(freq: np.ndarray, Z: np.ndarray) -> dict: + return { + "freq": freq.tolist(), + "z_real": Z.real.tolist(), + "z_imag": Z.imag.tolist(), + } + + +def store_decode(d: dict) -> tuple[np.ndarray, np.ndarray]: + # Force float64: JSON null → NaN (not None, which would create object dtype) + freq = np.array(d["freq"], dtype=float) + Z = np.array(d["z_real"], dtype=float) + 1j * np.array(d["z_imag"], dtype=float) + return freq, Z + + +# --------------------------------------------------------------------------- +# Plotly figure builders +# --------------------------------------------------------------------------- + +def _nyquist_layout(fig: go.Figure) -> go.Figure: + fig.update_layout( + xaxis_title="Re(Z) [Ω]", + yaxis_title="−Im(Z) [Ω]", + yaxis=dict(scaleanchor="x", scaleratio=1), + hovermode="closest", + margin=dict(l=50, r=20, t=40, b=50), + legend=dict(x=0.01, y=0.99, bgcolor="rgba(255,255,255,0.8)"), + height=460, + ) + return fig + + +def nyquist_figure( + freq: np.ndarray, + Z: np.ndarray, + deleted: list[int] | None = None, + pending: list[int] | None = None, + Z_fit: np.ndarray | None = None, + title: str = "Nyquist Plot", +) -> go.Figure: + """Interactive Nyquist scatter; pending removal shown orange, deleted shown faded ×.""" + deleted_set = set(deleted or []) + pending_set = set(pending or []) + n = len(Z) + active = [i for i in range(n) if i not in deleted_set and i not in pending_set] + pending_list = [i for i in range(n) if i in pending_set and i not in deleted_set] + removed = [i for i in range(n) if i in deleted_set] + + fig = go.Figure() + + if active: + fig.add_trace( + go.Scatter( + x=Z.real[active], + y=-Z.imag[active], + mode="markers", + name="Data", + marker=dict(color="#1f77b4", size=8, symbol="circle"), + customdata=active, + text=[ + f"idx {i}
f={freq[i]:.3e} Hz
" + f"Re={Z.real[i]:.4g} Ω
Im={Z.imag[i]:.4g} Ω" + for i in active + ], + hovertemplate="%{text}", + ) + ) + + if pending_list: + fig.add_trace( + go.Scatter( + x=Z.real[pending_list], + y=-Z.imag[pending_list], + mode="markers", + name="Pending", + marker=dict(color="#ff7f0e", size=10, symbol="circle", + line=dict(color="white", width=1.5)), + customdata=pending_list, + text=[f"idx {i} [pending removal — click Remove Selected]" for i in pending_list], + hovertemplate="%{text}", + ) + ) + + if removed: + fig.add_trace( + go.Scatter( + x=Z.real[removed], + y=-Z.imag[removed], + mode="markers", + name="Removed", + marker=dict(color="lightgray", size=8, symbol="x", opacity=0.5), + customdata=removed, + text=[f"idx {i} [removed]" for i in removed], + hovertemplate="%{text}", + ) + ) + + if Z_fit is not None: + fig.add_trace( + go.Scatter( + x=Z_fit.real, + y=-Z_fit.imag, + mode="lines", + name="Fit", + line=dict(color="#ff7f0e", width=2), + ) + ) + + fig.update_layout(title=dict(text=title, x=0.5)) + return _nyquist_layout(fig) + + +def nyquist_compare_figure( + freq_raw: np.ndarray, + Z_raw: np.ndarray, + freq_clean: np.ndarray, + Z_clean: np.ndarray, +) -> go.Figure: + """Overlay before/after on the same Nyquist axes.""" + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=Z_raw.real, y=-Z_raw.imag, + mode="markers", name="Before", + marker=dict(color="lightblue", size=7, opacity=0.7), + ) + ) + fig.add_trace( + go.Scatter( + x=Z_clean.real, y=-Z_clean.imag, + mode="markers", name="After", + marker=dict(color="#2ca02c", size=7), + ) + ) + fig.update_layout(title=dict(text="Before vs. After Preprocessing", x=0.5)) + return _nyquist_layout(fig) + + +def bode_figure( + freq: np.ndarray, + Z: np.ndarray, + deleted: list[int] | None = None, + pending: list[int] | None = None, + Z_fit: np.ndarray | None = None, + freq_fit: np.ndarray | None = None, + title: str = "Bode Plot", +) -> go.Figure: + """Two-row Bode plot: |Z| magnitude (top) and phase (bottom).""" + deleted_set = set(deleted or []) + pending_set = set(pending or []) + n = len(Z) + active = [i for i in range(n) if i not in deleted_set and i not in pending_set] + pending_list = [i for i in range(n) if i in pending_set and i not in deleted_set] + + fig = make_subplots( + rows=2, cols=1, + shared_xaxes=True, + vertical_spacing=0.10, + subplot_titles=("|Z| [Ω]", "−Phase [°]"), + ) + + if active: + freq_a = freq[active] + Z_a = Z[active] + fig.add_trace( + go.Scatter( + x=freq_a, y=np.abs(Z_a), + mode="markers", name="|Z|", + marker=dict(color="#1f77b4", size=6), + customdata=active, + showlegend=True, + ), + row=1, col=1, + ) + fig.add_trace( + go.Scatter( + x=freq_a, y=-np.angle(Z_a, deg=True), + mode="markers", name="Phase", + marker=dict(color="#d62728", size=6), + customdata=active, + showlegend=True, + ), + row=2, col=1, + ) + + if pending_list: + freq_p = freq[pending_list] + Z_p = Z[pending_list] + fig.add_trace( + go.Scatter( + x=freq_p, y=np.abs(Z_p), + mode="markers", name="Pending", + marker=dict(color="#ff7f0e", size=8, line=dict(color="white", width=1)), + customdata=pending_list, + showlegend=True, + ), + row=1, col=1, + ) + fig.add_trace( + go.Scatter( + x=freq_p, y=-np.angle(Z_p, deg=True), + mode="markers", name="Pending (phase)", + marker=dict(color="#ff7f0e", size=8, line=dict(color="white", width=1)), + customdata=pending_list, + showlegend=False, + ), + row=2, col=1, + ) + + if Z_fit is not None and freq_fit is not None: + fig.add_trace( + go.Scatter( + x=freq_fit, y=np.abs(Z_fit), + mode="lines", name="|Z| fit", + line=dict(color="#ff7f0e", width=2), + ), + row=1, col=1, + ) + fig.add_trace( + go.Scatter( + x=freq_fit, y=-np.angle(Z_fit, deg=True), + mode="lines", name="Phase fit", + line=dict(color="#ff7f0e", width=2), + ), + row=2, col=1, + ) + + fig.update_xaxes(type="log", title_text="Frequency [Hz]", row=2, col=1) + fig.update_xaxes(type="log", row=1, col=1) + fig.update_yaxes(type="log", row=1, col=1) + fig.update_layout(title=dict(text=title, x=0.5), height=480, margin=dict(l=50, r=20, t=60, b=50)) + return fig + + +def residual_figure(freq: np.ndarray, Z_meas: np.ndarray, Z_fit: np.ndarray) -> go.Figure: + """Plot relative residuals (Re and Im) vs frequency.""" + res_real = (Z_meas.real - Z_fit.real) / np.abs(Z_meas) + res_imag = (Z_meas.imag - Z_fit.imag) / np.abs(Z_meas) + + fig = go.Figure() + fig.add_trace(go.Scatter(x=freq, y=res_real * 100, mode="markers+lines", name="Re residual [%]")) + fig.add_trace(go.Scatter(x=freq, y=res_imag * 100, mode="markers+lines", name="Im residual [%]")) + fig.add_hline(y=0, line_dash="dash", line_color="gray") + fig.update_xaxes(type="log", title_text="Frequency [Hz]") + fig.update_yaxes(title_text="Relative residual [%]") + fig.update_layout( + title=dict(text="Fit Residuals", x=0.5), + hovermode="x unified", + height=320, + margin=dict(l=50, r=20, t=40, b=50), + ) + return fig + + +def mpl_fig_to_src(mpl_fig) -> str: + """Convert a matplotlib Figure to a base64 PNG src string for .""" + import io as _io + buf = _io.BytesIO() + mpl_fig.savefig(buf, format="png", bbox_inches="tight", dpi=120) + buf.seek(0) + encoded = base64.b64encode(buf.read()).decode("utf-8") + import matplotlib.pyplot as plt + plt.close(mpl_fig) + return f"data:image/png;base64,{encoded}"