{"spec_id":"nyquist-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nnyquist-basic: Nyquist Plot for Control Systems\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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\"\n\n# Imprint palette — first series always #009E73\nBRANCH_POS = \"#009E73\"  # positive frequency branch\nBRANCH_NEG = \"#C475FD\"  # negative frequency branch\nCRITICAL_COLOR = \"#AE3030\"  # semantic red for critical point\nCROSS_COLOR = \"#BD8233\"  # ochre for phase crossover marker\n\n# Data — G(s) = 5 / ((s+1)(0.5s+1)(0.1s+1)), a stable third-order system\nomega = np.concatenate(\n    [np.logspace(-2, -0.5, 100), np.logspace(-0.5, 0.5, 250), np.logspace(0.5, 1.5, 200), np.logspace(1.5, 3, 100)]\n)\n\nK = 5.0\ns = 1j * omega\nG = K / ((s + 1.0) * (0.5 * s + 1.0) * (0.1 * s + 1.0))\n\nreal_part = G.real\nimag_part = G.imag\n\n# Positive frequency branch\npos_df = pd.DataFrame(\n    {\n        \"real\": real_part,\n        \"imaginary\": imag_part,\n        \"frequency\": omega,\n        \"branch\": \"ω ≥ 0 (positive)\",\n        \"idx\": np.arange(len(omega)),\n    }\n)\n\n# Negative frequency branch — mirror about the real axis\nneg_df = pd.DataFrame(\n    {\n        \"real\": real_part,\n        \"imaginary\": -imag_part,\n        \"frequency\": -omega[::-1],\n        \"branch\": \"ω ≤ 0 (negative)\",\n        \"idx\": np.arange(len(omega)),\n    }\n)\n\nnyquist_df = pd.concat([pos_df, neg_df], ignore_index=True)\n\n# Unit circle for reference\ntheta = np.linspace(0, 2 * np.pi, 200)\nunit_circle_df = pd.DataFrame({\"ux\": np.cos(theta), \"uy\": np.sin(theta), \"idx\": np.arange(len(theta))})\n\n# Critical point (-1, 0)\ncritical_df = pd.DataFrame({\"real\": [-1.0], \"imaginary\": [0.0], \"label\": [\"Critical Point (−1, 0)\"]})\n\n# Gain crossover: |G(jω)| = 1\nmagnitude = np.abs(G)\ngain_cross_idx = np.argmin(np.abs(magnitude - 1.0))\ngain_cross_omega = omega[gain_cross_idx]\n\n# Phase crossover: imaginary part crosses zero (skip near-DC region)\nsign_changes = np.where(np.diff(np.sign(imag_part[30:])))[0] + 30\nphase_cross_idx = sign_changes[0] if len(sign_changes) > 0 else None\nphase_cross_omega = omega[phase_cross_idx] if phase_cross_idx is not None else None\n\n# Frequency markers — ω=2.0 omitted to avoid overlap with gain crossover at ≈2.0\nfreq_annotations = []\nfor w_mark, dy_sign in [(0.5, -1), (1.0, -1)]:\n    idx = np.argmin(np.abs(omega - w_mark))\n    freq_annotations.append(\n        {\"real\": real_part[idx], \"imaginary\": imag_part[idx], \"label\": f\"ω = {w_mark}\", \"above\": dy_sign < 0}\n    )\n# Gain crossover — placed below the curve point, clear of other labels\nfreq_annotations.append(\n    {\n        \"real\": real_part[gain_cross_idx],\n        \"imaginary\": imag_part[gain_cross_idx],\n        \"label\": f\"ω ≈ {gain_cross_omega:.1f} (|G|=1)\",\n        \"above\": False,\n    }\n)\nfreq_df = pd.DataFrame(freq_annotations)\n\n# Phase crossover annotation — separate layer for independent positioning\nphase_cross_layers = []\nif phase_cross_idx is not None:\n    pc_df = pd.DataFrame(\n        {\n            \"real\": [real_part[phase_cross_idx]],\n            \"imaginary\": [imag_part[phase_cross_idx]],\n            \"label\": [f\"ω ≈ {phase_cross_omega:.1f} (phase ×)\"],\n        }\n    )\n    pc_point = (\n        alt.Chart(pc_df)\n        .mark_point(shape=\"diamond\", size=180, color=CROSS_COLOR, filled=True, opacity=0.85)\n        .encode(x=alt.X(\"real:Q\"), y=alt.Y(\"imaginary:Q\"), tooltip=[alt.Tooltip(\"label:N\", title=\"Phase Crossover\")])\n    )\n    # Label goes below-left of the diamond, away from the critical point label above\n    pc_text = (\n        alt.Chart(pc_df)\n        .mark_text(fontSize=10, color=CROSS_COLOR, fontWeight=\"bold\", align=\"right\", dx=-12, dy=22)\n        .encode(x=alt.X(\"real:Q\"), y=alt.Y(\"imaginary:Q\"), text=\"label:N\")\n    )\n    phase_cross_layers = [pc_point, pc_text]\n\n# Direction arrows showing increasing frequency\narrow_rows = []\nfor target_w in [0.8, 3.0]:\n    idx = np.argmin(np.abs(omega - target_w))\n    im_val = imag_part[idx]\n    if abs(im_val) > 0.05:\n        shape = \"down\" if im_val < 0 else \"up\"\n        arrow_rows.append({\"ax\": real_part[idx], \"ay\": imag_part[idx], \"branch\": \"ω ≥ 0 (positive)\", \"shape\": shape})\n        arrow_rows.append(\n            {\n                \"ax\": real_part[idx],\n                \"ay\": -imag_part[idx],\n                \"branch\": \"ω ≤ 0 (negative)\",\n                \"shape\": \"up\" if shape == \"down\" else \"down\",\n            }\n        )\narrow_df = pd.DataFrame(arrow_rows)\n\n# Axis — 1:1 aspect ratio: equal domain extent, square view\n# Data range: real ∈ [~-1.3, 5.0], imaginary ∈ [~-4.5, 4.5]\n# Use [-5.2, 5.2] on both axes; width=height so unit circle is circular\nPLOT_RANGE = 5.2\nx_scale = alt.Scale(domain=[-PLOT_RANGE, PLOT_RANGE], nice=False)\ny_scale = alt.Scale(domain=[-PLOT_RANGE, PLOT_RANGE], nice=False)\n\nbranch_domain = [\"ω ≥ 0 (positive)\", \"ω ≤ 0 (negative)\"]\nbranch_range = [BRANCH_POS, BRANCH_NEG]\n\nhighlight = alt.selection_point(fields=[\"branch\"], bind=\"legend\")\n\n# Nyquist curve — two branches, interactive legend selection\nnyquist_layer = (\n    alt.Chart(nyquist_df)\n    .mark_line(strokeWidth=2.5)\n    .encode(\n        x=alt.X(\"real:Q\", scale=x_scale, title=\"Real Part — Re[G(jω)]\"),\n        y=alt.Y(\"imaginary:Q\", scale=y_scale, title=\"Imaginary Part — Im[G(jω)]\"),\n        color=alt.Color(\n            \"branch:N\",\n            scale=alt.Scale(domain=branch_domain, range=branch_range),\n            legend=alt.Legend(\n                title=\"Frequency Branch\",\n                titleFontSize=10,\n                labelFontSize=10,\n                symbolSize=150,\n                symbolStrokeWidth=2.5,\n                orient=\"top-right\",\n                offset=4,\n            ),\n        ),\n        opacity=alt.condition(highlight, alt.value(0.9), alt.value(0.2)),\n        order=\"idx:Q\",\n        tooltip=[\n            alt.Tooltip(\"branch:N\", title=\"Branch\"),\n            alt.Tooltip(\"real:Q\", title=\"Re(G)\", format=\".3f\"),\n            alt.Tooltip(\"imaginary:Q\", title=\"Im(G)\", format=\".3f\"),\n            alt.Tooltip(\"frequency:Q\", title=\"ω (rad/s)\", format=\".3f\"),\n        ],\n    )\n    .add_params(highlight)\n)\n\n# Unit circle reference\nunit_circle_layer = (\n    alt.Chart(unit_circle_df)\n    .mark_line(strokeWidth=1.5, strokeDash=[5, 4], color=INK_SOFT, opacity=0.5)\n    .encode(x=alt.X(\"ux:Q\", scale=x_scale), y=alt.Y(\"uy:Q\", scale=y_scale), order=\"idx:Q\")\n)\n\n# Critical point ring (emphasis) + cross marker\ncritical_ring = (\n    alt.Chart(critical_df)\n    .mark_point(shape=\"circle\", size=650, strokeWidth=2, color=CRITICAL_COLOR, filled=False, opacity=0.3)\n    .encode(x=alt.X(\"real:Q\", scale=x_scale), y=alt.Y(\"imaginary:Q\", scale=y_scale))\n)\ncritical_cross = (\n    alt.Chart(critical_df)\n    .mark_point(shape=\"cross\", size=380, strokeWidth=3.5, color=CRITICAL_COLOR, filled=False)\n    .encode(\n        x=alt.X(\"real:Q\", scale=x_scale),\n        y=alt.Y(\"imaginary:Q\", scale=y_scale),\n        tooltip=[alt.Tooltip(\"label:N\", title=\"Critical Point\")],\n    )\n)\n# Label to the upper-right; use INK for contrast on both light/dark themes\ncritical_text = (\n    alt.Chart(critical_df)\n    .mark_text(fontSize=10, fontWeight=\"bold\", color=INK, align=\"left\", dx=16, dy=-22)\n    .encode(x=alt.X(\"real:Q\", scale=x_scale), y=alt.Y(\"imaginary:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Frequency annotation points\nfreq_points = (\n    alt.Chart(freq_df)\n    .mark_point(shape=\"circle\", size=130, color=BRANCH_POS, filled=True, opacity=0.9)\n    .encode(\n        x=alt.X(\"real:Q\", scale=x_scale),\n        y=alt.Y(\"imaginary:Q\", scale=y_scale),\n        tooltip=[alt.Tooltip(\"label:N\", title=\"Frequency\")],\n    )\n)\nfreq_above_df = freq_df[freq_df[\"above\"]].copy()\nfreq_below_df = freq_df[~freq_df[\"above\"]].copy()\nfreq_labels_above = (\n    alt.Chart(freq_above_df)\n    .mark_text(fontSize=10, color=INK_SOFT, fontWeight=\"bold\", align=\"left\", dx=10, dy=-14)\n    .encode(x=alt.X(\"real:Q\", scale=x_scale), y=alt.Y(\"imaginary:Q\", scale=y_scale), text=\"label:N\")\n)\nfreq_labels_below = (\n    alt.Chart(freq_below_df)\n    .mark_text(fontSize=10, color=INK_SOFT, fontWeight=\"bold\", align=\"left\", dx=10, dy=16)\n    .encode(x=alt.X(\"real:Q\", scale=x_scale), y=alt.Y(\"imaginary:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Direction arrows\narrow_up_df = arrow_df[arrow_df[\"shape\"] == \"up\"].copy()\narrow_down_df = arrow_df[arrow_df[\"shape\"] == \"down\"].copy()\narrow_up_layer = (\n    alt.Chart(arrow_up_df)\n    .mark_point(shape=\"triangle-up\", size=180, filled=True, opacity=0.85)\n    .encode(\n        x=alt.X(\"ax:Q\", scale=x_scale),\n        y=alt.Y(\"ay:Q\", scale=y_scale),\n        color=alt.Color(\"branch:N\", scale=alt.Scale(domain=branch_domain, range=branch_range), legend=None),\n    )\n)\narrow_down_layer = (\n    alt.Chart(arrow_down_df)\n    .mark_point(shape=\"triangle-down\", size=180, filled=True, opacity=0.85)\n    .encode(\n        x=alt.X(\"ax:Q\", scale=x_scale),\n        y=alt.Y(\"ay:Q\", scale=y_scale),\n        color=alt.Color(\"branch:N\", scale=alt.Scale(domain=branch_domain, range=branch_range), legend=None),\n    )\n)\n\n# Compose all layers\ncombined = (\n    unit_circle_layer\n    + nyquist_layer\n    + critical_ring\n    + critical_cross\n    + critical_text\n    + freq_points\n    + freq_labels_above\n    + freq_labels_below\n    + arrow_up_layer\n    + arrow_down_layer\n)\nfor layer in phase_cross_layers:\n    combined = combined + layer\n\n# Square view (width=height) enforces 1:1 aspect ratio so the unit circle is circular\nchart = (\n    combined.properties(\n        width=460,\n        height=460,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"nyquist-basic · python · altair · anyplot.ai\",\n            fontSize=16,\n            fontWeight=\"bold\",\n            color=INK,\n            subtitle=\"G(s) = 5 / (s+1)(0.5s+1)(0.1s+1)  ·  Open-Loop Frequency Response\",\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=8,\n            anchor=\"start\",\n            offset=8,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, stroke=\"transparent\")\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.10,\n        labelColor=INK_SOFT,\n        labelFontSize=10,\n        titleColor=INK,\n        titleFontSize=12,\n        titleFontWeight=\"bold\",\n        titlePadding=10,\n    )\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        titleFontSize=10,\n        labelFontSize=10,\n    )\n    .interactive()\n)\n\nTW, TH = 2400, 2400\n\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}