{"spec_id":"bar-tornado-sensitivity","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nbar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Prevent this file (bokeh.py) from shadowing the installed bokeh package\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if not (p and os.path.abspath(p) == _this_dir)]\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, LabelSet, Span\nfrom bokeh.plotting import figure\nfrom PIL import Image\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme 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\"\n\n# Imprint categorical palette — positions 1 and 2 for two series\nCOLOR_LOW = \"#009E73\"  # Imprint position 1 — brand green (first series, low scenario)\nCOLOR_HIGH = \"#C475FD\"  # Imprint position 2 — lavender (second series, high scenario)\n\n# Data — NPV sensitivity analysis for a renewable energy project\nbase_npv = 12.5  # Base case NPV ($M)\n\nparameters = [\n    \"Electricity Price ($/MWh)\",\n    \"Discount Rate (%)\",\n    \"Construction Cost ($M)\",\n    \"Capacity Factor (%)\",\n    \"Equipment Lifetime (yrs)\",\n    \"O&M Cost ($/MWh)\",\n    \"Tax Credit Rate (%)\",\n    \"Inflation Rate (%)\",\n    \"Salvage Value ($M)\",\n    \"Insurance Cost ($M/yr)\",\n]\n\n# Asymmetric low/high NPV values — more upside on price, more downside on cost\nlow_values = np.array([5.8, 7.5, 8.9, 8.0, 10.5, 10.8, 10.6, 11.0, 11.8, 11.7])\nhigh_values = np.array([19.5, 16.8, 15.8, 17.2, 14.3, 13.8, 14.0, 13.5, 13.2, 13.4])\n\n# Sort by total range — widest bar at the top of the tornado\ntotal_range = high_values - low_values\nsort_idx = np.argsort(total_range)\nparameters_sorted = [parameters[i] for i in sort_idx]\nlow_sorted = low_values[sort_idx]\nhigh_sorted = high_values[sort_idx]\nranges_sorted = total_range[sort_idx]\nn = len(parameters_sorted)\n\n# Alpha gradient: more opaque for higher-influence (wider) bars\nlow_alphas = [0.5 + 0.5 * (i / (n - 1)) for i in range(n)]\nhigh_alphas = [0.5 + 0.5 * (i / (n - 1)) for i in range(n)]\n\n# Bar extents relative to base case\nlow_left = np.where(low_sorted < base_npv, low_sorted, base_npv)\nlow_right = np.where(low_sorted < base_npv, base_npv, low_sorted)\nhigh_left = np.where(high_sorted > base_npv, base_npv, high_sorted)\nhigh_right = np.where(high_sorted > base_npv, high_sorted, base_npv)\n\nlow_val_fmt = [f\"${v:.1f}M\" for v in low_sorted]\nhigh_val_fmt = [f\"${v:.1f}M\" for v in high_sorted]\nrange_val_fmt = [f\"${r:.1f}M\" for r in ranges_sorted]\n\nsource_low = ColumnDataSource(\n    data={\n        \"parameter\": parameters_sorted,\n        \"left\": low_left,\n        \"right\": low_right,\n        \"low_val\": low_val_fmt,\n        \"high_val\": high_val_fmt,\n        \"range_val\": range_val_fmt,\n        \"alpha\": low_alphas,\n    }\n)\n\nsource_high = ColumnDataSource(\n    data={\n        \"parameter\": parameters_sorted,\n        \"left\": high_left,\n        \"right\": high_right,\n        \"low_val\": low_val_fmt,\n        \"high_val\": high_val_fmt,\n        \"range_val\": range_val_fmt,\n        \"alpha\": high_alphas,\n    }\n)\n\nlabel_low_src = ColumnDataSource(data={\"parameter\": parameters_sorted, \"x\": low_sorted, \"text\": low_val_fmt})\n\nlabel_high_src = ColumnDataSource(data={\"parameter\": parameters_sorted, \"x\": high_sorted, \"text\": high_val_fmt})\n\n# Title with font scaling for length > 67 chars\ntitle = \"NPV Sensitivity · bar-tornado-sensitivity · python · bokeh · anyplot.ai\"\ntitle_fontsize = max(34, round(50 * 67 / len(title)))  # 47pt for 71 chars\n\n# Plot — 3200×1800 landscape canvas per hard contract\np = figure(\n    width=3200,\n    height=1800,\n    y_range=parameters_sorted,\n    x_range=(3.0, 22.0),\n    title=title,\n    x_axis_label=\"Net Present Value ($M)\",\n    toolbar_location=None,  # prevents toolbar from shrinking canvas height\n    min_border_bottom=160,  # room for 34pt x-ticks + 42pt x-axis label\n    min_border_left=480,  # extra room for long categorical y-axis labels\n    min_border_top=110,  # room for title\n    min_border_right=60,\n)\n\n# Low-scenario bars\np.hbar(\n    y=\"parameter\",\n    left=\"left\",\n    right=\"right\",\n    height=0.6,\n    color=COLOR_LOW,\n    alpha=\"alpha\",\n    source=source_low,\n    legend_label=\"Low Scenario\",\n    line_color=COLOR_LOW,\n    line_width=0.5,\n)\n\n# High-scenario bars\np.hbar(\n    y=\"parameter\",\n    left=\"left\",\n    right=\"right\",\n    height=0.6,\n    color=COLOR_HIGH,\n    alpha=\"alpha\",\n    source=source_high,\n    legend_label=\"High Scenario\",\n    line_color=COLOR_HIGH,\n    line_width=0.5,\n)\n\n# Value annotations at bar ends\nlow_labels = LabelSet(\n    x=\"x\",\n    y=\"parameter\",\n    text=\"text\",\n    source=label_low_src,\n    text_font_size=\"22pt\",\n    text_color=INK_SOFT,\n    text_align=\"right\",\n    x_offset=-10,\n    y_offset=-3,\n)\np.add_layout(low_labels)\n\nhigh_labels = LabelSet(\n    x=\"x\",\n    y=\"parameter\",\n    text=\"text\",\n    source=label_high_src,\n    text_font_size=\"22pt\",\n    text_color=INK_SOFT,\n    text_align=\"left\",\n    x_offset=10,\n    y_offset=-3,\n)\np.add_layout(high_labels)\n\n# Base case vertical reference line\nbaseline = Span(location=base_npv, dimension=\"height\", line_color=INK, line_width=2.5, line_dash=\"solid\")\np.add_layout(baseline)\n\n# Base case label above top bar\nbase_label = Label(\n    x=base_npv,\n    y=n - 0.5,\n    text=f\"Base: ${base_npv}M\",\n    text_font_size=\"26pt\",\n    text_color=INK,\n    text_font_style=\"bold\",\n    text_align=\"center\",\n    y_units=\"data\",\n    y_offset=55,\n)\np.add_layout(base_label)\n\n# HoverTool for interactive HTML exploration\nhover = HoverTool(\n    tooltips=[\n        (\"Parameter\", \"@parameter\"),\n        (\"Low NPV\", \"@low_val\"),\n        (\"High NPV\", \"@high_val\"),\n        (\"Impact Range\", \"@range_val\"),\n    ]\n)\np.add_tools(hover)\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\np.title.text_font_size = f\"{title_fontsize}pt\"\np.title.text_color = INK\np.title.text_font_style = \"normal\"\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\n\np.yaxis.major_label_text_font_size = \"22pt\"  # smaller for long categorical labels\np.yaxis.major_label_text_color = INK_SOFT\np.yaxis.axis_line_color = None\np.yaxis.major_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\np.xgrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.12\np.ygrid.grid_line_alpha = 0.0\n\np.legend.label_text_font_size = \"28pt\"\np.legend.label_text_color = INK_SOFT\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.location = \"bottom_left\"\np.legend.padding = 15\np.legend.margin = 20\np.legend.glyph_height = 30\np.legend.glyph_width = 50\n\n# Save interactive HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome — CDP override is authoritative:\n# --window-size alone loses ~139 px to Chrome chrome in headless mode\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n\n# PIL safety net: pin to exact 3200×1800 in case of sub-pixel rounding\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\nif _img.size != (W, H):\n    _norm = Image.new(\"RGB\", (W, H), PAGE_BG)\n    _norm.paste(_img, ((W - _img.size[0]) // 2, (H - _img.size[1]) // 2))\n    _norm.save(f\"plot-{THEME}.png\")\n"}