"""Pure SVG waveform plot generation -- no matplotlib dependency. Generates complete XML strings for time-domain, Bode, and spectrum plots suitable for embedding in HTML or saving as standalone .svg files. """ from __future__ import annotations import math from html import escape as _html_escape import numpy as np # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _ENG_PREFIXES = [ (1e18, "E"), (1e15, "P"), (1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k"), (1e0, ""), (1e-3, "m"), (1e-6, "\u00b5"), # micro sign (1e-9, "n"), (1e-12, "p"), (1e-15, "f"), (1e-18, "a"), ] _FREQ_PREFIXES = [ (1e9, "G"), (1e6, "M"), (1e3, "k"), (1e0, ""), (1e-3, "m"), ] def _svg_escape(text: str) -> str: """Escape special characters for embedding inside SVG XML.""" return _html_escape(str(text), quote=True) def _format_eng(value: float, unit: str = "") -> str: """Format *value* with an engineering prefix and optional *unit*.""" if value == 0: return f"0{unit}" abs_val = abs(value) for threshold, prefix in _ENG_PREFIXES: if abs_val >= threshold * 0.9999: scaled = value / threshold # Trim trailing zeros but keep at least one digit txt = f"{scaled:.3g}" return f"{txt}{prefix}{unit}" # Extremely small -- fall back to scientific return f"{value:.2e}{unit}" def _format_freq(hz: float) -> str: """Format a frequency value for axis labels (1, 10, 1k, 1M, etc.).""" if hz == 0: return "0" abs_hz = abs(hz) for threshold, prefix in _FREQ_PREFIXES: if abs_hz >= threshold * 0.9999: scaled = hz / threshold txt = f"{scaled:.4g}" # Strip unnecessary trailing zeros after decimal if "." in txt: txt = txt.rstrip("0").rstrip(".") return f"{txt}{prefix}" return f"{hz:.2e}" def _nice_ticks(vmin: float, vmax: float, n_ticks: int = 5) -> list[float]: """Compute human-friendly tick values spanning [vmin, vmax]. Returns a list of round numbers that cover the data range. """ if vmin == vmax: return [vmin] if not math.isfinite(vmin) or not math.isfinite(vmax): return [0.0] raw_step = (vmax - vmin) / max(n_ticks - 1, 1) if raw_step == 0: return [vmin] magnitude = 10 ** math.floor(math.log10(abs(raw_step))) residual = raw_step / magnitude # Snap to a "nice" step: 1, 2, 2.5, 5, 10 if residual <= 1.0: nice_step = magnitude elif residual <= 2.0: nice_step = 2 * magnitude elif residual <= 2.5: nice_step = 2.5 * magnitude elif residual <= 5.0: nice_step = 5 * magnitude else: nice_step = 10 * magnitude tick_min = math.floor(vmin / nice_step) * nice_step tick_max = math.ceil(vmax / nice_step) * nice_step ticks: list[float] = [] t = tick_min while t <= tick_max + nice_step * 0.001: ticks.append(round(t, 12)) t += nice_step return ticks def _log_ticks(vmin: float, vmax: float) -> list[float]: """Generate tick values at powers of 10 spanning [vmin, vmax] (linear values).""" if vmin <= 0: vmin = 1.0 if vmax <= vmin: vmax = vmin * 10 low = math.floor(math.log10(vmin)) high = math.ceil(math.log10(vmax)) ticks = [10.0**i for i in range(low, high + 1)] # Filter to range return [t for t in ticks if vmin * 0.9999 <= t <= vmax * 1.0001] def _data_extent(arr: np.ndarray, pad_frac: float = 0.05) -> tuple[float, float]: """Return (min, max) of *arr* with *pad_frac* padding on each side.""" if len(arr) == 0: return (0.0, 1.0) lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr)) if lo == hi: lo -= 1.0 hi += 1.0 span = hi - lo return (lo - span * pad_frac, hi + span * pad_frac) # --------------------------------------------------------------------------- # Core SVG building blocks # --------------------------------------------------------------------------- _FONT = "system-ui, -apple-system, sans-serif" def _build_path_d( xs: np.ndarray, ys: np.ndarray, x_min: float, x_max: float, y_min: float, y_max: float, plot_x: float, plot_y: float, plot_w: float, plot_h: float, log_x: bool = False, ) -> str: """Convert data arrays into an SVG path *d* attribute string.""" if len(xs) == 0: return "" # Map data -> pixel coords if log_x: safe_xs = np.clip(xs, max(x_min, 1e-30), None) lx = np.log10(safe_xs) lx_min = math.log10(max(x_min, 1e-30)) lx_max = math.log10(max(x_max, 1e-30)) denom_x = lx_max - lx_min if lx_max != lx_min else 1.0 px = plot_x + (lx - lx_min) / denom_x * plot_w else: denom_x = x_max - x_min if x_max != x_min else 1.0 px = plot_x + (xs - x_min) / denom_x * plot_w denom_y = y_max - y_min if y_max != y_min else 1.0 # Y axis is inverted in SVG (top = 0) py = plot_y + plot_h - (ys - y_min) / denom_y * plot_h parts = [f"M{px[0]:.2f},{py[0]:.2f}"] for i in range(1, len(px)): parts.append(f"L{px[i]:.2f},{py[i]:.2f}") return "".join(parts) def _render_subplot( *, xs: np.ndarray, ys: np.ndarray, x_min: float, x_max: float, y_min: float, y_max: float, plot_x: float, plot_y: float, plot_w: float, plot_h: float, log_x: bool, xlabel: str, ylabel: str, title: str | None, stroke: str, x_ticks: list[float] | None = None, y_ticks: list[float] | None = None, show_x_labels: bool = True, ) -> str: """Render a single subplot region as a block of SVG elements.""" lines: list[str] = [] # Compute ticks if y_ticks is None: y_ticks = _nice_ticks(y_min, y_max, n_ticks=6) if x_ticks is None: if log_x: x_ticks = _log_ticks(x_min, x_max) else: x_ticks = _nice_ticks(x_min, x_max, n_ticks=6) # Background lines.append( f'' ) # Grid + Y tick labels denom_y = y_max - y_min if y_max != y_min else 1.0 for tv in y_ticks: if tv < y_min or tv > y_max: continue py = plot_y + plot_h - (tv - y_min) / denom_y * plot_h lines.append( f'' ) label = _format_eng(tv) lines.append( f'{_svg_escape(label)}' ) # Grid + X tick labels if log_x: lx_min = math.log10(max(x_min, 1e-30)) lx_max = math.log10(max(x_max, 1e-30)) denom_x = lx_max - lx_min if lx_max != lx_min else 1.0 else: denom_x = x_max - x_min if x_max != x_min else 1.0 for tv in x_ticks: if log_x: if tv <= 0: continue frac = (math.log10(tv) - lx_min) / denom_x else: frac = (tv - x_min) / denom_x if frac < -0.001 or frac > 1.001: continue px = plot_x + frac * plot_w lines.append( f'' ) if show_x_labels: if log_x: label = _format_freq(tv) else: label = _format_eng(tv) lines.append( f'' f'{_svg_escape(label)}' ) # Data path d = _build_path_d(xs, ys, x_min, x_max, y_min, y_max, plot_x, plot_y, plot_w, plot_h, log_x) if d: lines.append( f'' ) # Title if title: lines.append( f'' f'{_svg_escape(title)}' ) # Axis labels if ylabel: mid_y = plot_y + plot_h / 2 lines.append( f'' f'{_svg_escape(ylabel)}' ) if xlabel and show_x_labels: lines.append( f'' f'{_svg_escape(xlabel)}' ) return "\n".join(lines) def _wrap_svg(inner: str, width: int, height: int) -> str: """Wrap inner SVG elements in a root tag with white background.""" return ( f'\n' f'\n' f'{inner}\n' f'' ) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def plot_timeseries( time: list[float] | np.ndarray, values: list[float] | np.ndarray, title: str = "Time Domain", ylabel: str = "Voltage (V)", width: int = 800, height: int = 400, ) -> str: """Plot a time-domain signal. Linear X axis (time), linear Y axis. Returns a complete ```` XML string. """ t = np.asarray(time, dtype=float).ravel() v = np.asarray(values, dtype=float).ravel() n = min(len(t), len(v)) t, v = t[:n], v[:n] margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60 plot_x = float(margin_l) plot_y = float(margin_t) plot_w = float(width - margin_l - margin_r) plot_h = float(height - margin_t - margin_b) x_min, x_max = _data_extent(t) y_min, y_max = _data_extent(v) inner = _render_subplot( xs=t, ys=v, x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, plot_x=plot_x, plot_y=plot_y, plot_w=plot_w, plot_h=plot_h, log_x=False, xlabel="Time (s)", ylabel=ylabel, title=title, stroke="#2563eb", ) return _wrap_svg(inner, width, height) def plot_bode( freq: list[float] | np.ndarray, mag_db: list[float] | np.ndarray, phase_deg: list[float] | np.ndarray | None = None, title: str = "Bode Plot", width: int = 800, height: int = 500, ) -> str: """Plot frequency response (Bode plot). Log10 X, linear Y (dB). If *phase_deg* is provided, the SVG contains two stacked subplots: magnitude on top, phase on the bottom. Returns a complete ```` XML string. """ f = np.asarray(freq, dtype=float).ravel() m = np.asarray(mag_db, dtype=float).ravel() n = min(len(f), len(m)) f, m = f[:n], m[:n] has_phase = phase_deg is not None if has_phase: p = np.asarray(phase_deg, dtype=float).ravel() n = min(len(f), len(p)) f, m, p = f[:n], m[:n], p[:n] margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60 plot_x = float(margin_l) plot_w = float(width - margin_l - margin_r) # Frequency range (shared) f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0) if f_min <= 0: f_min = 1.0 freq_ticks = _log_ticks(f_min, f_max) if has_phase: # Split into two subplots with a gap gap = 30 available_h = height - margin_t - margin_b - gap mag_h = available_h * 0.55 phase_h = available_h * 0.45 mag_y = float(margin_t) phase_y = float(margin_t + mag_h + gap) m_min, m_max = _data_extent(m) p_min, p_max = _data_extent(p) mag_svg = _render_subplot( xs=f, ys=m, x_min=f_min, x_max=f_max, y_min=m_min, y_max=m_max, plot_x=plot_x, plot_y=mag_y, plot_w=plot_w, plot_h=mag_h, log_x=True, xlabel="", ylabel="Magnitude (dB)", title=title, stroke="#2563eb", x_ticks=freq_ticks, show_x_labels=False, ) phase_svg = _render_subplot( xs=f, ys=p, x_min=f_min, x_max=f_max, y_min=p_min, y_max=p_max, plot_x=plot_x, plot_y=phase_y, plot_w=plot_w, plot_h=phase_h, log_x=True, xlabel="Frequency (Hz)", ylabel="Phase (deg)", title=None, stroke="#dc2626", x_ticks=freq_ticks, ) return _wrap_svg(mag_svg + "\n" + phase_svg, width, height) else: plot_y = float(margin_t) plot_h = float(height - margin_t - margin_b) m_min, m_max = _data_extent(m) inner = _render_subplot( xs=f, ys=m, x_min=f_min, x_max=f_max, y_min=m_min, y_max=m_max, plot_x=plot_x, plot_y=plot_y, plot_w=plot_w, plot_h=plot_h, log_x=True, xlabel="Frequency (Hz)", ylabel="Magnitude (dB)", title=title, stroke="#2563eb", x_ticks=freq_ticks, ) return _wrap_svg(inner, width, height) def plot_spectrum( freq: list[float] | np.ndarray, mag_db: list[float] | np.ndarray, title: str = "FFT Spectrum", width: int = 800, height: int = 400, ) -> str: """Plot an FFT spectrum. Log10 X axis, linear Y axis (dB). Returns a complete ```` XML string. """ f = np.asarray(freq, dtype=float).ravel() m = np.asarray(mag_db, dtype=float).ravel() n = min(len(f), len(m)) f, m = f[:n], m[:n] margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60 plot_x = float(margin_l) plot_y = float(margin_t) plot_w = float(width - margin_l - margin_r) plot_h = float(height - margin_t - margin_b) f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0) if f_min <= 0: f_min = 1.0 m_min, m_max = _data_extent(m) inner = _render_subplot( xs=f, ys=m, x_min=f_min, x_max=f_max, y_min=m_min, y_max=m_max, plot_x=plot_x, plot_y=plot_y, plot_w=plot_w, plot_h=plot_h, log_x=True, xlabel="Frequency (Hz)", ylabel="Magnitude (dB)", title=title, stroke="#2563eb", ) return _wrap_svg(inner, width, height)