{"spec_id":"nyquist-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nnyquist-basic: Nyquist Plot for Control Systems\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 92/100 | Updated: 2026-06-17\n\"\"\"\n# ruff: noqa: F405\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom scipy import signal\n\n\nLetsPlot.setup_html()\n\n# Theme-adaptive chrome — Imprint palette\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette (hybrid-v3 sort)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\nCOLOR_MAIN = IMPRINT_PALETTE[0]  # brand green — main Nyquist curve\nCOLOR_MIRROR = IMPRINT_PALETTE[2]  # blue — negative-frequency mirror\nCOLOR_CRITICAL = IMPRINT_PALETTE[4]  # matte red — critical point (semantic: danger)\nCOLOR_PM = IMPRINT_PALETTE[2]  # blue — phase margin marker\nPM_LABEL_TEXT = COLOR_PM if THEME == \"light\" else INK_SOFT  # dark mode: blue on #242420 fails WCAG AA\n\n# Data — third-order system: G(s) = 2 / ((s+1)(0.5s+1)(0.2s+1))\nnum = [2.0]\nden = np.polymul(np.polymul([1, 1], [0.5, 1]), [0.2, 1])\nsystem = signal.TransferFunction(num, den)\n\nomega = np.logspace(-2, 2, 500)\n_, H = signal.freqresp(system, omega)\n\nreal_part = H.real\nimag_part = H.imag\n\ndf = pd.DataFrame({\"real\": real_part, \"imaginary\": imag_part, \"frequency\": omega})\ndf_mirror = pd.DataFrame({\"real\": real_part, \"imaginary\": -imag_part, \"frequency\": -omega})\n\n# Unit circle reference\ntheta = np.linspace(0, 2 * np.pi, 200)\ndf_circle = pd.DataFrame({\"real\": np.cos(theta), \"imaginary\": np.sin(theta)})\n\n# Critical point (-1, 0)\ndf_critical = pd.DataFrame({\"real\": [-1.0], \"imaginary\": [0.0]})\n\n# Gain margin: phase crossover — where imaginary=0 and real<0\nreal_cross_mask = (imag_part[1:] * imag_part[:-1] < 0) & (real_part[:-1] < 0)\ncross_indices = np.where(real_cross_mask)[0]\ncross_idx = cross_indices[0] if len(cross_indices) > 0 else np.argmin(np.abs(imag_part[200:])) + 200\ndf_gain_margin = pd.DataFrame({\"x\": [-1.0], \"y\": [0.0], \"xend\": [real_part[cross_idx]], \"yend\": [0.0]})\n\ngain_margin_db = 20 * np.log10(1.0 / abs(real_part[cross_idx]))\ngm_mid_x = (-1.0 + real_part[cross_idx]) / 2\ndf_gm_label = pd.DataFrame(\n    {\n        \"real\": [gm_mid_x],\n        \"imaginary\": [0.26],  # above the line to avoid crowding with critical point label\n        \"label\": [f\"GM = {gain_margin_db:.1f} dB\"],\n    }\n)\n\n# Phase margin: gain crossover — where |G(jω)| = 1\nmagnitudes = np.abs(H)\ncross_gain_mask = (magnitudes[1:] - 1) * (magnitudes[:-1] - 1) < 0\ngain_cross_indices = np.where(cross_gain_mask)[0]\nif len(gain_cross_indices) > 0:\n    gc_idx = gain_cross_indices[0]\n    gc_angle = np.angle(H[gc_idx], deg=True)\n    phase_margin = 180 + gc_angle\n    df_pm_point = pd.DataFrame({\"real\": [H[gc_idx].real], \"imaginary\": [H[gc_idx].imag]})\n    df_pm_label = pd.DataFrame(\n        {\"real\": [H[gc_idx].real + 0.18], \"imaginary\": [H[gc_idx].imag - 0.20], \"label\": [f\"PM = {phase_margin:.1f}°\"]}\n    )\nelse:\n    df_pm_point = None\n    df_pm_label = None\n\n# Direction arrows showing increasing frequency\narrow_indices = [50, 150, 300]\ndf_arrows = df.iloc[arrow_indices].copy()\ndf_arrows_next = df.iloc[[i + 5 for i in arrow_indices]].copy()\ndf_segments = pd.DataFrame(\n    {\n        \"x\": df_arrows[\"real\"].values,\n        \"y\": df_arrows[\"imaginary\"].values,\n        \"xend\": df_arrows_next[\"real\"].values,\n        \"yend\": df_arrows_next[\"imaginary\"].values,\n    }\n)\n\n# Frequency labels at key points along the curve\nlabel_indices = [0, 80, 200, 350]\ndf_labels_base = df.iloc[label_indices].copy()\nfreq_labels = [f\"ω={omega[i]:.2f}\" if omega[i] < 10 else f\"ω={omega[i]:.0f}\" for i in label_indices]\nnudge_x = [0.18, -0.22, -0.18, 0.18]\nnudge_y = [0.14, -0.18, 0.16, 0.14]\ndf_freq_labels = pd.DataFrame(\n    {\n        \"real\": df_labels_base[\"real\"].values + np.array(nudge_x),\n        \"imaginary\": df_labels_base[\"imaginary\"].values + np.array(nudge_y),\n        \"label\": freq_labels,\n    }\n)\n\n# Stability zone highlight around (-1, 0)\nstab_theta = np.linspace(0, 2 * np.pi, 100)\ndf_stability_zone = pd.DataFrame({\"real\": -1.0 + 0.15 * np.cos(stab_theta), \"imaginary\": 0.15 * np.sin(stab_theta)})\n\n# Build plot\nplot = (\n    ggplot()\n    # Subtle stability-danger zone around critical point\n    + geom_polygon(aes(x=\"real\", y=\"imaginary\"), data=df_stability_zone, fill=COLOR_CRITICAL, alpha=0.08)\n    # Unit circle reference\n    + geom_path(aes(x=\"real\", y=\"imaginary\"), data=df_circle, color=INK_MUTED, size=0.5, linetype=\"dashed\")\n    # Gain margin indicator line\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=df_gain_margin,\n        color=COLOR_CRITICAL,\n        size=0.7,\n        linetype=\"dotted\",\n    )\n    # GM label positioned above the line to avoid crowding\n    + geom_label(\n        aes(x=\"real\", y=\"imaginary\", label=\"label\"),\n        data=df_gm_label,\n        size=4,\n        color=COLOR_CRITICAL,\n        fill=ELEVATED_BG,\n        alpha=0.93,\n        label_padding=0.3,\n        label_r=0.15,\n        label_size=0.5,\n        fontface=\"bold\",\n    )\n    # Mirror curve (negative frequencies) — dashed, less prominent\n    + geom_path(\n        aes(x=\"real\", y=\"imaginary\"), data=df_mirror, color=COLOR_MIRROR, size=0.8, alpha=0.38, linetype=\"dashed\"\n    )\n    # Main Nyquist curve with interactive tooltips\n    + geom_path(\n        aes(x=\"real\", y=\"imaginary\"),\n        data=df,\n        color=COLOR_MAIN,\n        size=2.0,\n        tooltips=layer_tooltips()\n        .format(\"real\", \".3f\")\n        .format(\"imaginary\", \".3f\")\n        .format(\"frequency\", \".3f\")\n        .line(\"Re: @real\")\n        .line(\"Im: @imaginary\")\n        .line(\"ω: @frequency rad/s\"),\n    )\n    # Direction arrows\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=df_segments,\n        color=COLOR_MAIN,\n        size=1.4,\n        arrow=arrow(length=12, type=\"closed\"),\n    )\n    # Frequency labels along the curve\n    + geom_label(\n        aes(x=\"real\", y=\"imaginary\", label=\"label\"),\n        data=df_freq_labels,\n        size=3,\n        color=INK_SOFT,\n        fill=ELEVATED_BG,\n        alpha=0.9,\n        label_padding=0.3,\n        label_r=0.2,\n        label_size=0.2,\n    )\n    # Critical point (-1, 0) — prominent X marker\n    + geom_point(aes(x=\"real\", y=\"imaginary\"), data=df_critical, color=COLOR_CRITICAL, size=10, shape=4, stroke=3.0)\n    # Critical point label — placed below to separate from GM label above\n    + geom_text(\n        aes(x=\"real\", y=\"imaginary\"),\n        data=pd.DataFrame({\"real\": [-0.80], \"imaginary\": [-0.24]}),\n        label=\"(−1, 0)\",\n        size=4,\n        color=COLOR_CRITICAL,\n        fontface=\"bold\",\n    )\n    # Origin marker\n    + geom_point(\n        aes(x=\"real\", y=\"imaginary\"),\n        data=pd.DataFrame({\"real\": [0.0], \"imaginary\": [0.0]}),\n        color=INK_MUTED,\n        size=3,\n        shape=3,\n        stroke=1.5,\n    )\n    + labs(\n        x=\"Re{G(jω)}\",\n        y=\"Im{G(jω)}\",\n        title=\"nyquist-basic · python · letsplot · anyplot.ai\",\n        subtitle=\"G(s) = 2 / [(s+1)(0.5s+1)(0.2s+1)]  —  Stable: curve does not encircle (−1, 0)\",\n    )\n    + coord_fixed(ratio=1)\n    + ggsize(600, 600)\n    + theme_minimal()\n    + theme(\n        axis_text=element_text(size=10, color=INK_SOFT),\n        axis_title=element_text(size=12, color=INK),\n        plot_title=element_text(size=16, color=INK),\n        plot_subtitle=element_text(size=10, color=INK_SOFT),\n        panel_grid_major=element_line(color=INK_SOFT, size=0.12),\n        panel_grid_minor=element_blank(),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        axis_ticks=element_blank(),\n        axis_ticks_length=0,\n        plot_margin=[10, 20, 15, 15],\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_text=element_text(color=INK_SOFT),\n        legend_title=element_text(color=INK),\n    )\n)\n\n# Phase margin annotation (adds content to balance the canvas)\nif df_pm_point is not None:\n    plot = (\n        plot\n        + geom_point(aes(x=\"real\", y=\"imaginary\"), data=df_pm_point, color=COLOR_PM, size=7, shape=1, stroke=2.0)\n        + geom_label(\n            aes(x=\"real\", y=\"imaginary\", label=\"label\"),\n            data=df_pm_label,\n            size=3.8,\n            color=PM_LABEL_TEXT,\n            fill=ELEVATED_BG,\n            alpha=0.93,\n            label_padding=0.3,\n            label_r=0.15,\n            label_size=0.4,\n            fontface=\"bold\",\n        )\n    )\n\n# Save — theme-suffixed PNG + interactive HTML\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}