{"spec_id":"bar-tornado-sensitivity","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nbar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\n\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    flavor_high_contrast_dark,\n    flavor_high_contrast_light,\n    geom_bar,\n    geom_text,\n    geom_vline,\n    ggplot,\n    ggsave,\n    ggsize,\n    labs,\n    layer_tooltips,\n    scale_fill_manual,\n    scale_x_continuous,\n    theme,\n    theme_minimal,\n)\n\n\nLetsPlot.setup_html()\n\n# Theme tokens — Imprint palette, theme-adaptive 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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — semantic exception: gain/loss for financial sensitivity\n# High Scenario (upper NPV bound) → #009E73 brand green (profit/gain)\n# Low Scenario  (lower NPV bound) → #AE3030 matte red  (loss/downside)\nCOLOR_HIGH = \"#009E73\"\nCOLOR_LOW = \"#AE3030\"\n\n# Data — NPV sensitivity analysis for a capital investment project\nbase_npv = 12.5  # Base case NPV in $M\n\nparameters = [\n    \"Discount Rate\",\n    \"Revenue Growth\",\n    \"Material Cost\",\n    \"Labor Cost\",\n    \"Sales Volume\",\n    \"Tax Rate\",\n    \"Salvage Value\",\n    \"Operating Expenses\",\n    \"Inflation Rate\",\n    \"Capacity Utilization\",\n]\n\nlow_values = [16.8, 8.2, 14.9, 13.8, 9.1, 14.2, 11.6, 13.9, 13.4, 10.8]\nhigh_values = [9.1, 17.3, 10.4, 11.0, 16.2, 10.9, 13.5, 11.2, 11.7, 14.1]\n\ndf = pd.DataFrame({\"parameter\": parameters, \"low_value\": low_values, \"high_value\": high_values})\n\n# Sort by total range (widest bar at top)\ndf[\"total_range\"] = abs(df[\"high_value\"] - df[\"low_value\"])\ndf = df.sort_values(\"total_range\", ascending=True).reset_index(drop=True)\n\n# Build long-form data: each parameter gets two bars (low side and high side)\nrows = []\nfor _, row in df.iterrows():\n    low_side = min(row[\"low_value\"], row[\"high_value\"])\n    high_side = max(row[\"low_value\"], row[\"high_value\"])\n    low_delta = low_side - base_npv\n    high_delta = high_side - base_npv\n\n    low_nudge = low_delta - 0.25\n    high_nudge = high_delta + 0.25\n\n    rows.append(\n        {\n            \"parameter\": row[\"parameter\"],\n            \"value\": low_delta,\n            \"scenario\": \"Low Scenario\",\n            \"label\": f\"{low_delta:+.1f}\",\n            \"npv\": f\"${low_side:.1f}M\",\n            \"label_x\": low_nudge,\n        }\n    )\n    rows.append(\n        {\n            \"parameter\": row[\"parameter\"],\n            \"value\": high_delta,\n            \"scenario\": \"High Scenario\",\n            \"label\": f\"{high_delta:+.1f}\",\n            \"npv\": f\"${high_side:.1f}M\",\n            \"label_x\": high_nudge,\n        }\n    )\n\nplot_df = pd.DataFrame(rows)\n\n# Preserve sorted order (ascending range = narrowest at bottom, widest at top)\nparam_order = df[\"parameter\"].tolist()\nplot_df[\"parameter\"] = pd.Categorical(plot_df[\"parameter\"], categories=param_order, ordered=True)\n\n# Title — scale fontsize for 83-char title (default 16, floor 11)\nTITLE = \"NPV Sensitivity Analysis · bar-tornado-sensitivity · python · letsplot · anyplot.ai\"\ntitle_fs = max(11, round(16 * 67 / len(TITLE)))\n\nflavor = flavor_high_contrast_light() if THEME == \"light\" else flavor_high_contrast_dark()\n\n# Plot\nplot = (\n    ggplot(plot_df, aes(x=\"value\", y=\"parameter\", fill=\"scenario\"))\n    + geom_bar(\n        stat=\"identity\",\n        width=0.7,\n        alpha=0.92,\n        position=\"identity\",\n        tooltips=layer_tooltips()\n        .line(\"@parameter\")\n        .line(\"Scenario: @scenario\")\n        .line(\"NPV Impact: @label $M\")\n        .line(\"Resulting NPV: @npv\"),\n    )\n    + geom_vline(xintercept=0, color=INK, size=1.4, linetype=\"solid\")\n    + geom_text(\n        aes(x=\"label_x\", label=\"label\"),\n        position=\"identity\",\n        hjust=1.1,\n        size=4,\n        color=INK,\n        fontface=\"bold\",\n        data=plot_df[plot_df[\"scenario\"] == \"Low Scenario\"],\n    )\n    + geom_text(\n        aes(x=\"label_x\", label=\"label\"),\n        position=\"identity\",\n        hjust=-0.1,\n        size=4,\n        color=INK,\n        fontface=\"bold\",\n        data=plot_df[plot_df[\"scenario\"] == \"High Scenario\"],\n    )\n    + scale_fill_manual(\n        values=[COLOR_LOW, COLOR_HIGH],\n        breaks=[\"Low Scenario\", \"High Scenario\"],\n        labels=[\"◀ Low Scenario\", \"High Scenario ▶\"],\n    )\n    + scale_x_continuous(expand=[0.18, 0], format=\"{.1f}\", breaks=[-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5])\n    + labs(\n        x=\"Change in NPV ($M)\",\n        y=\"Input Parameter\",\n        title=TITLE,\n        subtitle=\"One-at-a-time parameter variation from base case (NPV = $12.5M)\",\n        caption=\"Revenue Growth and Discount Rate account for over 50% of total NPV sensitivity\",\n        fill=\"\",\n    )\n    + theme_minimal()\n    + flavor\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        plot_title=element_text(size=title_fs, face=\"bold\", hjust=0.5, color=INK),\n        plot_subtitle=element_text(size=10, hjust=0.5, color=INK_SOFT),\n        axis_title_x=element_text(size=12, margin=[10, 0, 0, 0], color=INK),\n        axis_title_y=element_text(size=12, color=INK),\n        axis_text_x=element_text(size=10, color=INK_SOFT),\n        axis_text_y=element_text(size=10, color=INK_SOFT),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_title=element_blank(),\n        legend_position=\"top\",\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        panel_grid_major_y=element_blank(),\n        panel_grid_minor=element_blank(),\n        panel_grid_major_x=element_line(color=INK_SOFT, size=0.3),\n        plot_caption=element_text(size=8, color=INK_MUTED, face=\"italic\", hjust=0.5),\n        plot_margin=[20, 30, 20, 10],\n    )\n    + ggsize(800, 450)  # 800 × 450 × scale=4 = 3200 × 1800 px (landscape)\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}