{"spec_id":"nyquist-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nnyquist-basic: Nyquist Plot for Control Systems\nLibrary: plotnine 0.15.7 | Python 3.13.13\nQuality: 85/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport sys\n\n\n# This file is named 'plotnine.py' — remove its own directory from sys.path\n# so that 'import plotnine' resolves to the installed library, not this file.\n_here = os.path.realpath(os.path.dirname(os.path.abspath(__file__)))\nsys.path = [p for p in sys.path if os.path.realpath(p or \".\") != _here]\ndel _here\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    arrow,\n    coord_fixed,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_path,\n    geom_point,\n    geom_ribbon,\n    geom_segment,\n    ggplot,\n    guide_legend,\n    guides,\n    labs,\n    scale_alpha_manual,\n    scale_color_manual,\n    scale_linetype_manual,\n    scale_size_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\n\n\n# Theme-adaptive chrome tokens (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 order, theme-independent\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Transfer function G(s) = 5 / [(s+1)(0.5s+1)(0.2s+1)]\n# Poles at s = -1, -2, -5 (stable minimum-phase system)\nomega = np.concatenate(\n    [np.logspace(-2, -0.5, 150), np.logspace(-0.5, 0.5, 300), np.logspace(0.5, 1.5, 200), np.logspace(1.5, 3, 100)]\n)\nK = 5.0\njw = 1j * omega\nG = K / ((jw + 1) * (0.5 * jw + 1) * (0.2 * jw + 1))\nreal_part = G.real\nimag_part = G.imag\n\n# Positive and negative frequency curves (conjugate pair)\ndf_pos = pd.DataFrame({\"real\": real_part, \"imaginary\": imag_part, \"curve\": \"Positive freq (ω > 0)\"})\ndf_neg = pd.DataFrame({\"real\": real_part[::-1], \"imaginary\": -imag_part[::-1], \"curve\": \"Negative freq (ω < 0)\"})\ndf_curves = pd.concat([df_pos, df_neg], ignore_index=True)\n\n# Unit circle reference\ntheta = np.linspace(0, 2 * np.pi, 200)\nunit_circle_df = pd.DataFrame({\"real\": np.cos(theta), \"imaginary\": np.sin(theta)})\n\n# Stability region shading (interior of unit circle)\ntheta_fill = np.linspace(0, 2 * np.pi, 100)\ncos_vals = np.cos(theta_fill)\nsin_vals = np.sin(np.arccos(np.clip(cos_vals, -1, 1)))\nstability_df = pd.DataFrame({\"x\": cos_vals, \"ymin\": -sin_vals, \"ymax\": sin_vals})\n\n# Stability margin calculations\nmag = np.abs(G)\nphase = np.degrees(np.angle(G))\ngc_idx = np.argmin(np.abs(mag - 1.0))\ngc_omega = omega[gc_idx]\nphase_margin = 180 + phase[gc_idx]\npc_idx = np.argmin(np.abs(phase + 180))\npc_omega = omega[pc_idx]\ngain_margin_db = -20 * np.log10(mag[pc_idx])\n\n# Phase margin indicator: origin → gain crossover point\ngc_seg_df = pd.DataFrame({\"x\": [0.0], \"y\": [0.0], \"xend\": [G[gc_idx].real], \"yend\": [G[gc_idx].imag]})\npm_label_x = float(G[gc_idx].real) * 0.55 + 0.25\npm_label_y = float(G[gc_idx].imag) * 0.55 - 0.35\n\n# Frequency annotations with offsets — ω=3 pushed higher to avoid phase crossover crowding\nfreq_annotations = [(0.1, 0.4, -0.5), (0.3, 0.6, -0.55), (0.7, 0.5, -0.55), (1.5, -0.7, 0.55), (3.0, 0.85, 1.15)]\nannot_rows = []\nfor wf, ox, oy in freq_annotations:\n    idx = np.argmin(np.abs(omega - wf))\n    rx, ry = real_part[idx], imag_part[idx]\n    annot_rows.append({\"real\": rx, \"imaginary\": ry, \"label\": f\"ω={wf:g}\", \"lx\": rx + ox, \"ly\": ry + oy})\nannot_df = pd.DataFrame(annot_rows)\n\n# Direction arrows along both curves\narrow_data = []\nfor frac in [0.06, 0.3, 0.65]:\n    idx = int(frac * len(df_pos))\n    step = max(2, int(0.015 * len(df_pos)))\n    if 0 < idx < len(df_pos) - step:\n        arrow_data.append(\n            {\n                \"x\": df_pos.iloc[idx][\"real\"],\n                \"y\": df_pos.iloc[idx][\"imaginary\"],\n                \"xend\": df_pos.iloc[idx + step][\"real\"],\n                \"yend\": df_pos.iloc[idx + step][\"imaginary\"],\n            }\n        )\nfor frac in [0.35, 0.75]:\n    idx = int(frac * len(df_neg))\n    step = max(2, int(0.015 * len(df_neg)))\n    if 0 < idx < len(df_neg) - step:\n        arrow_data.append(\n            {\n                \"x\": df_neg.iloc[idx][\"real\"],\n                \"y\": df_neg.iloc[idx][\"imaginary\"],\n                \"xend\": df_neg.iloc[idx + step][\"real\"],\n                \"yend\": df_neg.iloc[idx + step][\"imaginary\"],\n            }\n        )\narrow_df = pd.DataFrame(arrow_data)\n\nplot = (\n    ggplot()\n    # Stability region (subtle blue fill inside unit circle)\n    + geom_ribbon(stability_df, aes(x=\"x\", ymin=\"ymin\", ymax=\"ymax\"), fill=IMPRINT[2], alpha=0.07)\n    # Unit circle (theme-adaptive dashed reference)\n    + geom_path(unit_circle_df, aes(x=\"real\", y=\"imaginary\"), color=INK_SOFT, size=0.5, linetype=\"dashed\")\n    # Nyquist curves: Imprint pos 0 (green) for positive, pos 1 (lavender) for negative\n    + geom_path(df_curves, aes(x=\"real\", y=\"imaginary\", color=\"curve\", linetype=\"curve\", size=\"curve\", alpha=\"curve\"))\n    + scale_color_manual(\n        values={\"Positive freq (ω > 0)\": IMPRINT[0], \"Negative freq (ω < 0)\": IMPRINT[1]}, name=\"Frequency Response\"\n    )\n    + scale_linetype_manual(\n        values={\"Positive freq (ω > 0)\": \"solid\", \"Negative freq (ω < 0)\": \"dashed\"}, name=\"Frequency Response\"\n    )\n    + scale_size_manual(values={\"Positive freq (ω > 0)\": 1.2, \"Negative freq (ω < 0)\": 0.8}, name=\"Frequency Response\")\n    + scale_alpha_manual(\n        values={\"Positive freq (ω > 0)\": 0.95, \"Negative freq (ω < 0)\": 0.6}, name=\"Frequency Response\"\n    )\n    # Direction arrows (theme-adaptive)\n    + geom_segment(\n        arrow_df, aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), color=INK_SOFT, size=0.8, arrow=arrow(length=0.12)\n    )\n    # Phase margin indicator: dotted line origin → gain crossover\n    + geom_segment(\n        gc_seg_df, aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), color=IMPRINT[2], size=0.8, linetype=\"dotted\", alpha=0.8\n    )\n    + annotate(\n        \"text\", x=pm_label_x, y=pm_label_y, label=\"Phase\\nMargin\", color=IMPRINT[2], size=3.0, ha=\"center\", alpha=0.85\n    )\n    # Critical point: Imprint matte red — semantic anchor for critical/danger\n    + geom_point(\n        pd.DataFrame({\"real\": [-1.0], \"imaginary\": [0.0]}),\n        aes(x=\"real\", y=\"imaginary\"),\n        color=IMPRINT[4],\n        size=5,\n        shape=\"x\",\n        stroke=2.0,\n    )\n    + annotate(\n        \"text\", x=-1.85, y=0.75, label=\"Critical\\n(−1, 0)\", color=IMPRINT[4], size=3.0, fontweight=\"bold\", ha=\"center\"\n    )\n    + annotate(\"segment\", x=-1.5, y=0.52, xend=-1.05, yend=0.08, color=IMPRINT[4], size=0.4, alpha=0.5)\n    # Gain crossover marker: Imprint blue\n    + geom_point(\n        pd.DataFrame({\"real\": [G[gc_idx].real], \"imaginary\": [G[gc_idx].imag]}),\n        aes(x=\"real\", y=\"imaginary\"),\n        color=IMPRINT[2],\n        size=4.5,\n        shape=\"o\",\n        stroke=1.5,\n    )\n    # Phase crossover marker: Imprint ochre\n    + geom_point(\n        pd.DataFrame({\"real\": [G[pc_idx].real], \"imaginary\": [G[pc_idx].imag]}),\n        aes(x=\"real\", y=\"imaginary\"),\n        color=IMPRINT[3],\n        size=4.5,\n        shape=\"s\",\n        stroke=1.5,\n        fill=IMPRINT[3],\n    )\n    # Gain crossover annotation\n    + annotate(\n        \"text\",\n        x=-3.2,\n        y=-2.6,\n        label=f\"Gain crossover\\nω = {gc_omega:.1f} rad/s · PM = {phase_margin:.0f}°\",\n        color=IMPRINT[2],\n        size=3.0,\n        ha=\"left\",\n        fontweight=\"bold\",\n    )\n    + annotate(\n        \"segment\",\n        x=-2.2,\n        y=-2.1,\n        xend=G[gc_idx].real - 0.08,\n        yend=G[gc_idx].imag - 0.08,\n        color=IMPRINT[2],\n        size=0.5,\n        alpha=0.6,\n    )\n    # Phase crossover annotation\n    + annotate(\n        \"text\",\n        x=-2.8,\n        y=2.5,\n        label=f\"Phase crossover\\nω = {pc_omega:.1f} rad/s · GM = {gain_margin_db:.1f} dB\",\n        color=IMPRINT[3],\n        size=3.0,\n        ha=\"left\",\n        fontweight=\"bold\",\n    )\n    + annotate(\n        \"segment\",\n        x=-1.9,\n        y=2.1,\n        xend=G[pc_idx].real + 0.05,\n        yend=G[pc_idx].imag + 0.1,\n        color=IMPRINT[3],\n        size=0.5,\n        alpha=0.6,\n    )\n    # Frequency annotation dots\n    + geom_point(annot_df, aes(x=\"real\", y=\"imaginary\"), color=IMPRINT[0], size=2.5, shape=\"o\", fill=IMPRINT[0])\n)\n\n# Frequency labels (theme-adaptive muted ink, size in mm per plotnine convention)\nfor _, row in annot_df.iterrows():\n    plot = plot + annotate(\"text\", x=row[\"lx\"], y=row[\"ly\"], label=row[\"label\"], color=INK_MUTED, size=3.5)\n\n# Axes, scales, and theme — square canvas 2400×2400 (6in × 400dpi)\n# ylim matches x-range (9.6 units each) so coord_fixed fills the panel with no wasted space\nplot = (\n    plot\n    + scale_x_continuous(breaks=[-3, -2, -1, 0, 1, 2, 3, 4, 5])\n    + scale_y_continuous(breaks=[-3, -2, -1, 0, 1, 2, 3])\n    + coord_fixed(ratio=1, xlim=(-3.8, 5.8), ylim=(-4.8, 4.8))\n    + labs(\n        title=\"nyquist-basic · python · plotnine · anyplot.ai\",\n        x=\"Real Axis [dimensionless]\",\n        y=\"Imaginary Axis [dimensionless]\",\n    )\n    + guides(color=guide_legend(title=\"Frequency Response\", override_aes={\"size\": 2}))\n    + theme_minimal()\n    + theme(\n        figure_size=(6, 6),\n        plot_title=element_text(size=12, weight=\"bold\", ha=\"center\", color=INK),\n        axis_title=element_text(size=10, color=INK),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        panel_grid_major=element_line(color=INK, size=0.2, alpha=0.15),\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        panel_border=element_rect(color=INK_SOFT, fill=None),\n        legend_position=\"bottom\",\n        legend_title=element_text(size=8, weight=\"bold\", color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    )\n)\n\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=6, height=6, units=\"in\", verbose=False)\n"}