diff --git a/gui/.dockerignore b/gui/.dockerignore
new file mode 100644
index 0000000..26f235d
--- /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 0000000..2211ef8
--- /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 0000000..311b0cb
--- /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 0000000..b2870d4
--- /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 0000000..acc7ad9
--- /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 0000000..0dc79b2
--- /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 0000000..6a92c7a
--- /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 0000000..557569a
--- /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 0000000..4c271f6
--- /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 0000000..e69de29
diff --git a/gui/pages/data.py b/gui/pages/data.py
new file mode 100644
index 0000000..790bfc7
--- /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 0000000..66dd5cf
--- /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 0000000..3ffbeb2
--- /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 0000000..b94b06e
--- /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 0000000..72c0354
--- /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 0000000..83e16b6
--- /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 0000000..61dec4d
--- /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}"