{"spec_id":"bar-tornado-sensitivity","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nbar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis\nLibrary: matplotlib 3.10.9 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\n\nimport matplotlib.patheffects as pe\nimport matplotlib.pyplot as plt\nimport matplotlib.ticker as mticker\nimport numpy as np\n\n\n# Theme tokens — Imprint palette chrome\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 categorical palette — positions 1 and 2 for dual-series tornado\nCOLOR_LOW = \"#009E73\"  # Imprint position 1 — brand green (Low Scenario)\nCOLOR_HIGH = \"#C475FD\"  # Imprint position 2 — lavender   (High Scenario)\n\n# Data — NPV sensitivity analysis for a capital investment project ($M)\nbase_npv = 12.5\n\nparameters = [\n    \"Discount Rate\",\n    \"Revenue Growth\",\n    \"Initial CapEx\",\n    \"Operating Costs\",\n    \"Tax Rate\",\n    \"Terminal Value\",\n    \"Working Capital\",\n    \"Inflation Rate\",\n    \"Project Duration\",\n    \"Salvage Value\",\n]\nlow_npv = np.array([17.0, 7.6, 14.7, 15.2, 14.0, 10.3, 13.2, 13.4, 11.4, 11.9])\nhigh_npv = np.array([8.9, 18.4, 10.8, 10.1, 11.1, 15.6, 11.6, 11.7, 13.9, 13.1])\n\n# Sort by total range — widest bar at top\ntotal_range = np.abs(high_npv - low_npv)\nsort_idx = np.argsort(total_range)\nparameters = [parameters[i] for i in sort_idx]\nlow_npv = low_npv[sort_idx]\nhigh_npv = high_npv[sort_idx]\n\nlow_delta = low_npv - base_npv\nhigh_delta = high_npv - base_npv\n\ny_pos = np.arange(len(parameters))\nn = len(parameters)\ntop_k = 3\n\n# Intensity gradient — wider bars are more saturated; floor at 0.55 keeps narrow bars visible\nsorted_range = np.abs(high_npv - low_npv)\nrange_norm = sorted_range / sorted_range.max()\nalphas = 0.55 + 0.45 * range_norm\n\n# Plot\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\nfor i in range(n):\n    ax.barh(\n        y_pos[i],\n        low_delta[i],\n        left=base_npv,\n        height=0.62,\n        color=COLOR_LOW,\n        alpha=alphas[i],\n        label=\"Low Scenario\" if i == n - 1 else None,\n        edgecolor=PAGE_BG,\n        linewidth=0.5,\n    )\n    ax.barh(\n        y_pos[i],\n        high_delta[i],\n        left=base_npv,\n        height=0.62,\n        color=COLOR_HIGH,\n        alpha=alphas[i],\n        label=\"High Scenario\" if i == n - 1 else None,\n        edgecolor=PAGE_BG,\n        linewidth=0.5,\n    )\n\n# Bar-end value labels with PathEffects halos for legibility\nlabel_halo = [pe.withStroke(linewidth=2.5, foreground=PAGE_BG)]\n\nfor i in range(n):\n    is_top = i >= n - top_k\n    lsize = 9 if is_top else 8\n    lweight = \"bold\" if is_top else \"medium\"\n\n    # Wider nudge for narrow bars so label pairs don't crowd together\n    nudge = 0.30 if sorted_range[i] < 1.5 else 0.12\n\n    lx = low_npv[i]\n    lo = -nudge if low_delta[i] < 0 else nudge\n    lh = \"right\" if low_delta[i] < 0 else \"left\"\n    ax.text(\n        lx + lo,\n        y_pos[i],\n        f\"${lx:.1f}M\",\n        va=\"center\",\n        ha=lh,\n        fontsize=lsize,\n        fontweight=lweight,\n        color=COLOR_LOW,\n        path_effects=label_halo,\n    )\n\n    hx = high_npv[i]\n    ho = nudge if high_delta[i] > 0 else -nudge\n    hh = \"left\" if high_delta[i] > 0 else \"right\"\n    ax.text(\n        hx + ho,\n        y_pos[i],\n        f\"${hx:.1f}M\",\n        va=\"center\",\n        ha=hh,\n        fontsize=lsize,\n        fontweight=lweight,\n        color=COLOR_HIGH,\n        path_effects=label_halo,\n    )\n\n# Base case reference line with styled annotation box (FancyBboxPatch via bbox kwarg)\nax.axvline(x=base_npv, color=INK_SOFT, linewidth=1.5, linestyle=\"-\", zorder=3)\nax.text(\n    base_npv + 0.10,\n    n - 0.65,\n    f\"Base: ${base_npv:.1f}M\",\n    fontsize=8,\n    fontweight=\"bold\",\n    color=INK,\n    ha=\"left\",\n    va=\"center\",\n    bbox={\"facecolor\": ELEVATED_BG, \"edgecolor\": INK_SOFT, \"alpha\": 0.9, \"boxstyle\": \"round,pad=0.3\"},\n)\n\n# X-axis dollar tick formatting\nax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f\"${x:.0f}M\"))\n\n# Style — bold y-tick labels for top drivers\nax.set_yticks(y_pos)\nytick_labels = ax.set_yticklabels(parameters, fontsize=8, color=INK_SOFT)\nfor i in range(n):\n    if i >= n - top_k:\n        ytick_labels[i].set_fontweight(\"bold\")\n        ytick_labels[i].set_fontsize(9)\n        ytick_labels[i].set_color(INK)\n\ntitle = \"bar-tornado-sensitivity · python · matplotlib · anyplot.ai\"\ntitle_fs = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12\n\nax.set_xlabel(\"Net Present Value ($M)\", fontsize=10, color=INK)\nax.set_title(title, fontsize=title_fs, fontweight=\"medium\", color=INK)\nax.tick_params(axis=\"x\", labelsize=8, colors=INK_SOFT, labelcolor=INK_SOFT)\nax.tick_params(axis=\"y\", length=0, colors=INK_SOFT, labelcolor=INK_SOFT)\n\nleg = ax.legend(fontsize=8, loc=\"lower right\", frameon=True)\nif leg:\n    leg.get_frame().set_facecolor(ELEVATED_BG)\n    leg.get_frame().set_edgecolor(INK_SOFT)\n    plt.setp(leg.get_texts(), color=INK_SOFT)\n\nax.spines[\"top\"].set_visible(False)\nax.spines[\"right\"].set_visible(False)\nax.spines[\"left\"].set_visible(False)\nax.spines[\"bottom\"].set_color(INK_SOFT)\nax.xaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK)\n\n# Tight x-limits — minimize excess padding while leaving room for bar-end labels\nx_min = min(low_npv.min(), high_npv.min())\nx_max = max(low_npv.max(), high_npv.max())\nx_span = x_max - x_min\nax.set_xlim(x_min - 0.14 * x_span, x_max + 0.14 * x_span)\n\nfig.subplots_adjust(left=0.20, right=0.96, top=0.93, bottom=0.12)\n\n# Save\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}