{"spec_id":"nyquist-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nnyquist-basic: Nyquist Plot for Control Systems\nLibrary: pygal 3.1.0 | Python 3.13.14\nQuality: 82/100 | Updated: 2026-06-17\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — canonical order; semantic exceptions noted\n# Critical point gets semantic red (#AE3030) — valid danger/critical exception\nCHART_COLORS = (\n    \"#009E73\",  # Imprint pos 1 — positive frequency curve\n    \"#C475FD\",  # Imprint pos 2 — negative frequency curve (canonical order)\n    \"#AE3030\",  # Semantic red — critical point (danger marker exception)\n    INK_MUTED,  # Muted anchor — unit circle (reference element, not categorical)\n    \"#BD8233\",  # Imprint pos 4 — frequency annotation markers\n    \"#4467A3\",  # Imprint pos 3 — direction arrows\n)\n\n# Data — Transfer function G(s) = 2 / [s(s+1)(s+2)]\nomega = np.logspace(-2, 2, 800)\ns = 1j * omega\nG = 2.0 / (s * (s + 1) * (s + 2))\n\nreal_part = G.real\nimag_part = G.imag\n\n# Mirror for negative frequencies (Nyquist contour reflection)\nreal_mirror = real_part[::-1]\nimag_mirror = -imag_part[::-1]\n\n# Title\ntitle = \"nyquist-basic · python · pygal · anyplot.ai\"\ntitle_len = len(title)\nbase_fontsize = 66\ntitle_fontsize = round(base_fontsize * 67 / title_len) if title_len > 67 else base_fontsize\n\n# Style — canonical pygal sizing for 2400×2400 square canvas\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=CHART_COLORS,\n    title_font_size=title_fontsize,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=2.5,\n    opacity=0.9,\n    opacity_hover=0.95,\n)\n\n# Chart — square 2400×2400 (spec requires 1:1 aspect ratio for unit circle)\nchart = pygal.XY(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=title,\n    x_title=\"Real\",\n    y_title=\"Imaginary\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_box_size=36,\n    dots_size=3,\n    stroke=True,\n    show_x_guides=True,\n    show_y_guides=True,\n    explicit_size=True,\n    range=(-2.5, 2.5),\n    xrange=(-2.5, 2.5),\n)\n\n# Positive frequency curve (main)\nstep = 4\nnyquist_positive = [\n    {\"value\": (float(real_part[i]), float(imag_part[i])), \"label\": f\"ω = {omega[i]:.3f} rad/s\"}\n    for i in range(0, len(omega), step)\n]\nchart.add(\"G(jω), ω ≥ 0\", nyquist_positive, show_dots=False, stroke_style={\"width\": 6})\n\n# Negative frequency curve (mirror, dashed)\nnyquist_negative = [\n    {\"value\": (float(real_mirror[i]), float(imag_mirror[i])), \"label\": f\"ω = -{omega[len(omega) - 1 - i]:.3f} rad/s\"}\n    for i in range(0, len(omega), step)\n]\nchart.add(\"G(jω), ω < 0\", nyquist_negative, show_dots=False, stroke_style={\"width\": 4, \"dasharray\": \"12,6\"})\n\n# Critical point (-1, 0) — semantic red, large marker\nchart.add(\n    \"Critical Point (−1, 0)\", [{\"value\": (-1.0, 0.0), \"label\": \"Critical Point: (−1, 0)\"}], stroke=False, dots_size=28\n)\n\n# Unit circle (reference, dashed muted)\ncircle_points = [\n    {\"value\": (math.cos(math.radians(a)), math.sin(math.radians(a))), \"label\": f\"{a}°\"} for a in range(0, 361, 3)\n]\nchart.add(\"Unit Circle\", circle_points, stroke=True, show_dots=False, stroke_style={\"width\": 2, \"dasharray\": \"8,6\"})\n\n# Frequency annotations at key points along the positive-freq curve\nfreq_targets = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]\nfreq_annotations = [\n    {\"value\": (float(real_part[idx]), float(imag_part[idx])), \"label\": f\"ω = {ft} rad/s\"}\n    for ft in freq_targets\n    for idx in [int(np.argmin(np.abs(omega - ft)))]\n]\nchart.add(\"Frequency ω (rad/s)\", freq_annotations, stroke=False, dots_size=14)\n\n# Direction arrows — V-chevron markers showing increasing-ω traversal direction\n# Three dots per arrow form a triangular arrowhead: tip on curve, wings back-left and back-right\narrow_omegas = [0.4, 0.7, 1.2, 2.0]\narrow_scale = 0.10  # back-leg length (data units)\narrow_wing = 0.06  # half-width of arrowhead (data units)\ndirection_markers = []\n\nfor ao in arrow_omegas:\n    idx = int(np.argmin(np.abs(omega - ao)))\n    i0 = max(0, idx - 10)\n    i1 = min(len(omega) - 1, idx + 10)\n    dx = float(real_part[i1] - real_part[i0])\n    dy = float(imag_part[i1] - imag_part[i0])\n    length = math.sqrt(dx**2 + dy**2)\n    if length < 1e-10:\n        continue\n    ux, uy = dx / length, dy / length  # unit forward vector\n    px, py = -uy, ux  # perpendicular (90° CCW)\n    tx = float(real_part[idx])\n    ty = float(imag_part[idx])\n    direction_markers.extend(\n        [\n            {\"value\": (tx, ty), \"label\": f\"→ ω = {ao} rad/s\"},\n            {\n                \"value\": (tx - arrow_scale * ux + arrow_wing * px, ty - arrow_scale * uy + arrow_wing * py),\n                \"label\": None,\n            },\n            {\n                \"value\": (tx - arrow_scale * ux - arrow_wing * px, ty - arrow_scale * uy - arrow_wing * py),\n                \"label\": None,\n            },\n        ]\n    )\n\nchart.add(\"→ ω direction\", direction_markers, stroke=False, dots_size=12)\n\n# Save\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}