{"spec_id":"nyquist-basic","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nnyquist-basic: Nyquist Plot for Control Systems\nLibrary: plotly 6.8.0 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme tokens\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\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Imprint categorical palette — positions used\nBRAND = \"#009E73\"  # position 1 — ALWAYS first series / main curve\nIMPRINT_BLUE = \"#4467A3\"  # position 3 — gain crossover marker\nIMPRINT_OCHRE = \"#BD8233\"  # position 4 — phase crossover marker\nCRITICAL_RED = \"#AE3030\"  # position 5 — semantic red for critical point\n\n# Data: Transfer function G(s) = 2 / (s+1)^3\n# Three identical poles at s = -1, no zeros\n# DC gain = 2, phase crossover at ω = √3 ≈ 1.73 rad/s\n# Gain margin ≈ 12 dB, phase margin ≈ 67.5°\nomega = np.logspace(-2, 2, 800)\ns = 1j * omega\nG = 2.0 / (s + 1) ** 3\n\nreal_part = G.real\nimag_part = G.imag\nmagnitude = np.abs(G)\nphase_deg = np.degrees(np.angle(G))\n\n# Phase crossover: where imag(G) crosses zero and real(G) < 0\nsign_changes = np.where(np.diff(np.sign(imag_part)) != 0)[0]\nphase_crossover_idx = None\nfor idx in sign_changes:\n    if real_part[idx] < 0:\n        phase_crossover_idx = idx\n        break\n\n# Gain crossover: where |G| = 1\ngain_crossover_idx = np.argmin(np.abs(magnitude - 1.0))\n\n# Plot\nfig = go.Figure()\n\n# Unit circle — idiomatic Plotly built-in shape (no coordinate trace needed)\nfig.add_shape(\n    type=\"circle\",\n    x0=-1,\n    y0=-1,\n    x1=1,\n    y1=1,\n    line={\"color\": INK_MUTED, \"dash\": \"dash\", \"width\": 2},\n    fillcolor=\"rgba(0,0,0,0)\",\n    opacity=0.5,\n)\n\n# Nyquist curve (positive frequencies ω ≥ 0)\nfig.add_trace(\n    go.Scatter(\n        x=real_part,\n        y=imag_part,\n        mode=\"lines\",\n        line={\"width\": 3.5, \"color\": BRAND},\n        name=\"G(jω), ω ≥ 0\",\n        customdata=np.column_stack([omega, magnitude, phase_deg]),\n        hovertemplate=(\n            \"<b>Nyquist Curve</b><br>\"\n            \"Re = %{x:.3f}<br>\"\n            \"Im = %{y:.3f}<br>\"\n            \"ω = %{customdata[0]:.3f} rad/s<br>\"\n            \"|G| = %{customdata[1]:.3f}<br>\"\n            \"∠G = %{customdata[2]:.1f}°\"\n            \"<extra></extra>\"\n        ),\n    )\n)\n\n# Mirror curve (negative frequencies)\nfig.add_trace(\n    go.Scatter(\n        x=real_part,\n        y=-imag_part,\n        mode=\"lines\",\n        line={\"width\": 2.0, \"color\": BRAND, \"dash\": \"dot\"},\n        name=\"G(jω), ω < 0\",\n        opacity=0.4,\n        hoverinfo=\"skip\",\n    )\n)\n\n# Direction arrows along the curve showing increasing frequency\nn = len(real_part)\nfor frac in [0.12, 0.32, 0.52]:\n    idx = int(n * frac)\n    if idx < n - 5:\n        dx = real_part[idx + 5] - real_part[idx]\n        dy = imag_part[idx + 5] - imag_part[idx]\n        norm = np.sqrt(dx**2 + dy**2)\n        if norm > 1e-8:\n            fig.add_annotation(\n                x=real_part[idx],\n                y=imag_part[idx],\n                ax=-dx / norm * 30,\n                ay=dy / norm * 30,\n                xref=\"x\",\n                yref=\"y\",\n                axref=\"pixel\",\n                ayref=\"pixel\",\n                showarrow=True,\n                arrowhead=3,\n                arrowsize=2.0,\n                arrowwidth=2.5,\n                arrowcolor=BRAND,\n                text=\"\",\n            )\n\n# Critical point (-1, 0)\nfig.add_trace(\n    go.Scatter(\n        x=[-1],\n        y=[0],\n        mode=\"markers\",\n        marker={\"symbol\": \"x-thin\", \"size\": 24, \"color\": CRITICAL_RED, \"line\": {\"width\": 4}},\n        name=\"Critical point (−1, 0)\",\n        hovertemplate=\"Critical point<br>(−1, 0)<extra></extra>\",\n    )\n)\n\n# Gain crossover frequency marker\ngc_re = real_part[gain_crossover_idx]\ngc_im = imag_part[gain_crossover_idx]\ngc_omega = omega[gain_crossover_idx]\ngc_phase = phase_deg[gain_crossover_idx]\nphase_margin = 180.0 + gc_phase\nfig.add_trace(\n    go.Scatter(\n        x=[gc_re],\n        y=[gc_im],\n        mode=\"markers\",\n        marker={\"symbol\": \"circle\", \"size\": 15, \"color\": IMPRINT_BLUE, \"line\": {\"width\": 2, \"color\": PAGE_BG}},\n        name=f\"Gain crossover (ω≈{gc_omega:.2f})\",\n        hovertemplate=(\n            f\"Gain crossover<br>ω = {gc_omega:.2f} rad/s<br>|G| = 1<br>PM = {phase_margin:.1f}°<extra></extra>\"\n        ),\n    )\n)\nfig.add_annotation(\n    x=gc_re,\n    y=gc_im,\n    text=f\"<b>ω={gc_omega:.2f}</b>  PM={phase_margin:.0f}°\",\n    showarrow=True,\n    arrowhead=0,\n    arrowwidth=1.2,\n    arrowcolor=IMPRINT_BLUE,\n    ax=90,\n    ay=45,\n    font={\"size\": 12, \"color\": IMPRINT_BLUE, \"family\": \"Arial, sans-serif\"},\n)\n\n# Phase crossover frequency marker\nif phase_crossover_idx is not None:\n    pc_re = real_part[phase_crossover_idx]\n    pc_im = imag_part[phase_crossover_idx]\n    pc_omega = omega[phase_crossover_idx]\n    gain_margin_db = -20 * np.log10(abs(pc_re))\n    fig.add_trace(\n        go.Scatter(\n            x=[pc_re],\n            y=[pc_im],\n            mode=\"markers\",\n            marker={\"symbol\": \"diamond\", \"size\": 17, \"color\": IMPRINT_OCHRE, \"line\": {\"width\": 2, \"color\": PAGE_BG}},\n            name=f\"Phase crossover (ω≈{pc_omega:.2f})\",\n            hovertemplate=(\n                f\"Phase crossover<br>ω = {pc_omega:.2f} rad/s<br>GM = {gain_margin_db:.1f} dB<extra></extra>\"\n            ),\n        )\n    )\n    fig.add_annotation(\n        x=pc_re,\n        y=pc_im,\n        text=f\"<b>ω={pc_omega:.2f}</b>  GM={gain_margin_db:.1f} dB\",\n        showarrow=True,\n        arrowhead=0,\n        arrowwidth=1.2,\n        arrowcolor=IMPRINT_OCHRE,\n        ax=-95,\n        ay=-50,\n        font={\"size\": 12, \"color\": IMPRINT_OCHRE, \"family\": \"Arial, sans-serif\"},\n    )\n\n# Frequency annotations at selected points — spread away from the crowded origin region\nfreq_label_config = [(0.1, 45, -30), (0.5, -50, -35), (1.0, -55, 30), (3.0, 35, 32)]\nfor f_label, ax_off, ay_off in freq_label_config:\n    idx = np.argmin(np.abs(omega - f_label))\n    fig.add_annotation(\n        x=real_part[idx],\n        y=imag_part[idx],\n        text=f\"ω={f_label}\",\n        showarrow=True,\n        arrowhead=0,\n        arrowwidth=0.8,\n        arrowcolor=INK_MUTED,\n        ax=ax_off,\n        ay=ay_off,\n        font={\"size\": 11, \"color\": INK_MUTED, \"family\": \"Arial, sans-serif\"},\n    )\n\n# Layout — square canvas (2400×2400) suits the symmetric Nyquist geometry\ntitle_text = \"nyquist-basic · python · plotly · anyplot.ai\"\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": title_text,\n        \"font\": {\"size\": 16, \"color\": INK, \"family\": \"Arial, sans-serif\"},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n    },\n    xaxis={\n        \"title\": {\n            \"text\": \"Real Axis\",\n            \"font\": {\"size\": 12, \"color\": INK, \"family\": \"Arial, sans-serif\"},\n            \"standoff\": 12,\n        },\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"zeroline\": True,\n        \"zerolinewidth\": 1.5,\n        \"zerolinecolor\": INK_SOFT,\n        \"showgrid\": True,\n        \"gridwidth\": 1,\n        \"gridcolor\": GRID,\n        \"linecolor\": INK_SOFT,\n        \"constrain\": \"domain\",\n        \"range\": [-1.8, 2.5],\n    },\n    yaxis={\n        \"title\": {\n            \"text\": \"Imaginary Axis\",\n            \"font\": {\"size\": 12, \"color\": INK, \"family\": \"Arial, sans-serif\"},\n            \"standoff\": 12,\n        },\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"zeroline\": True,\n        \"zerolinewidth\": 1.5,\n        \"zerolinecolor\": INK_SOFT,\n        \"showgrid\": True,\n        \"gridwidth\": 1,\n        \"gridcolor\": GRID,\n        \"linecolor\": INK_SOFT,\n        \"scaleanchor\": \"x\",\n        \"scaleratio\": 1,\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    legend={\n        \"font\": {\"size\": 10, \"color\": INK_SOFT, \"family\": \"Arial, sans-serif\"},\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"x\": 0.01,\n        \"y\": 0.99,\n        \"itemsizing\": \"constant\",\n    },\n    margin={\"l\": 80, \"r\": 40, \"t\": 80, \"b\": 60},\n    hoverlabel={\"bgcolor\": ELEVATED_BG, \"font_size\": 12, \"bordercolor\": INK_SOFT},\n)\n\n# Subtitle with transfer function\nfig.add_annotation(\n    text=\"G(s) = 2 / (s+1)³\",\n    xref=\"paper\",\n    yref=\"paper\",\n    x=0.5,\n    y=1.055,\n    showarrow=False,\n    font={\"size\": 12, \"color\": INK_SOFT, \"family\": \"Courier New, monospace\"},\n)\n\n# Save — square 2400×2400 (suitable for symmetric Nyquist geometry)\nfig.write_image(f\"plot-{THEME}.png\", width=600, height=600, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}