{"spec_id":"line-load-duration","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nline-load-duration: Load Duration Curve for Energy Systems\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-10\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Prevent this script from shadowing the installed bokeh package on sys.path\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nif _script_dir in sys.path:\n    sys.path.remove(_script_dir)\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Legend, NumeralTickFormatter, Span\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens (Imprint style guide)\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 assignments for load regions\n# Base Load  → brand green #009E73 (Imprint pos 1 — steady, reliable generation)\n# Intermediate → blue #4467A3 (Imprint pos 3)\n# Peak Load  → amber #DDCC77 (semantic anchor: caution/warning — scarce high-demand hours)\nCOLOR_BASE = \"#009E73\"  # Imprint pos 1 — brand green\nCOLOR_INTER = \"#4467A3\"  # Imprint pos 3 — blue\nCOLOR_PEAK = \"#DDCC77\"  # amber — Imprint warning/caution anchor\n\n# Data — synthetic annual hourly load profile for a mid-sized utility\nnp.random.seed(42)\nhours_in_year = 8760\n\nbase_load = 400\npeak_load = 1200\ntime_idx = np.arange(hours_in_year)\n\n# Seasonal pattern (summer/winter peaks)\nseasonal = 150 * np.sin(2 * np.pi * time_idx / hours_in_year - np.pi / 3)\n# Daily pattern (daytime peaks)\ndaily = 100 * np.sin(2 * np.pi * time_idx / 24 - np.pi / 2)\n# Random variation\nnoise = np.random.normal(0, 40, hours_in_year)\n\n# Combine and sort descending for load duration curve\nload_raw = base_load + 300 + seasonal + daily + noise\nload_raw = np.clip(load_raw, base_load, peak_load)\nload_mw = np.sort(load_raw)[::-1]\nhour = np.arange(hours_in_year)\n\n# Capacity thresholds defining region boundaries\nbase_capacity = 500\nintermediate_capacity = 900\n\npeak_end = int(np.searchsorted(-load_mw, -intermediate_capacity))\nintermediate_end = int(np.searchsorted(-load_mw, -base_capacity))\n\nregion_labels = np.array([\"Peak\"] * hours_in_year, dtype=\"U16\")\nregion_labels[peak_end:intermediate_end] = \"Intermediate\"\nregion_labels[intermediate_end:] = \"Base\"\n\ncumulative_energy = np.cumsum(load_mw) / 1000  # GWh\ntotal_energy_gwh = np.trapezoid(load_mw) / 1000\nload_factor = total_energy_gwh * 1000 / (peak_load * hours_in_year) * 100\npct_hours = (hour / hours_in_year * 100).astype(int)\n\n# Figure — 3200×1800 landscape (hard canvas contract, no deviation)\np = figure(\n    width=3200,\n    height=1800,\n    title=\"line-load-duration · bokeh · anyplot.ai\",\n    x_axis_label=\"Hours of the Year\",\n    y_axis_label=\"Power Demand (MW)\",\n    x_range=(-100, hours_in_year + 100),\n    y_range=(0, peak_load * 1.06),\n    toolbar_location=None,  # required: default toolbar adds ~30-50px, breaking 1800px height\n    min_border_bottom=160,  # room for 34pt tick labels + 42pt axis label\n    min_border_left=180,  # room for 34pt tick labels + 42pt axis label\n    min_border_top=110,  # room for 50pt title\n    min_border_right=50,\n)\n\n# Shaded fill regions under the curve\npeak_source = ColumnDataSource(\n    data={\"x\": hour[: peak_end + 1], \"y\": load_mw[: peak_end + 1], \"zero\": np.zeros(peak_end + 1)}\n)\nr_peak = p.varea(x=\"x\", y1=\"zero\", y2=\"y\", source=peak_source, fill_color=COLOR_PEAK, fill_alpha=0.30)\n\ninter_source = ColumnDataSource(\n    data={\n        \"x\": hour[peak_end : intermediate_end + 1],\n        \"y\": load_mw[peak_end : intermediate_end + 1],\n        \"zero\": np.zeros(intermediate_end - peak_end + 1),\n    }\n)\nr_inter = p.varea(x=\"x\", y1=\"zero\", y2=\"y\", source=inter_source, fill_color=COLOR_INTER, fill_alpha=0.22)\n\nbase_source = ColumnDataSource(\n    data={\n        \"x\": hour[intermediate_end:],\n        \"y\": load_mw[intermediate_end:],\n        \"zero\": np.zeros(hours_in_year - intermediate_end),\n    }\n)\nr_base = p.varea(x=\"x\", y1=\"zero\", y2=\"y\", source=base_source, fill_color=COLOR_BASE, fill_alpha=0.28)\n\n# Main load duration curve (INK = theme-adaptive neutral — structural reference element)\ncurve_source = ColumnDataSource(\n    data={\n        \"x\": hour,\n        \"y\": load_mw,\n        \"region\": region_labels,\n        \"cumulative_gwh\": np.round(cumulative_energy, 1),\n        \"pct\": pct_hours,\n    }\n)\ncurve_line = p.line(x=\"x\", y=\"y\", source=curve_source, line_width=4.0, color=INK)\n\n# HoverTool — Bokeh-native interactive feature (active in HTML artifact)\nhover = HoverTool(\n    renderers=[curve_line],\n    tooltips=[\n        (\"Hour Rank\", \"@x{0,0}\"),\n        (\"Load\", \"@y{0,0} MW\"),\n        (\"Region\", \"@region\"),\n        (\"Cumulative Energy\", \"@cumulative_gwh{0,0.0} GWh\"),\n        (\"Duration\", \"@pct% of year\"),\n    ],\n    mode=\"vline\",\n    line_policy=\"nearest\",\n)\np.add_tools(hover)\n\n# Horizontal dashed lines at capacity tiers\np.add_layout(Span(location=peak_load, dimension=\"width\", line_color=COLOR_PEAK, line_dash=\"dashed\", line_width=2.5))\np.add_layout(\n    Span(location=intermediate_capacity, dimension=\"width\", line_color=COLOR_INTER, line_dash=\"dashed\", line_width=2.5)\n)\np.add_layout(Span(location=base_capacity, dimension=\"width\", line_color=COLOR_BASE, line_dash=\"dashed\", line_width=2.5))\n\n# Capacity tier labels — left-anchored to keep clear of right legend panel\nlabel_x = 300\np.add_layout(\n    Label(\n        x=label_x,\n        y=peak_load + 14,\n        text=f\"Peak Capacity: {peak_load:,} MW\",\n        text_font_size=\"22pt\",\n        text_color=COLOR_PEAK,\n        text_font_style=\"bold\",\n    )\n)\np.add_layout(\n    Label(\n        x=label_x,\n        y=intermediate_capacity + 14,\n        text=f\"Intermediate Capacity: {intermediate_capacity} MW\",\n        text_font_size=\"22pt\",\n        text_color=COLOR_INTER,\n        text_font_style=\"bold\",\n    )\n)\np.add_layout(\n    Label(\n        x=label_x,\n        y=base_capacity + 14,\n        text=f\"Base Load Capacity: {base_capacity} MW\",\n        text_font_size=\"22pt\",\n        text_color=COLOR_BASE,\n        text_font_style=\"bold\",\n    )\n)\n\n# Region labels positioned within each shaded area\np.add_layout(\n    Label(\n        x=peak_end // 2,\n        y=load_mw[0] * 0.68,  # between 900 MW and 500 MW capacity lines\n        text=\"Peak\\nLoad\",\n        text_font_size=\"28pt\",\n        text_color=COLOR_PEAK,\n        text_font_style=\"bold\",\n        text_align=\"center\",\n    )\n)\np.add_layout(\n    Label(\n        x=(peak_end + intermediate_end) // 2,\n        y=load_mw[0] * 0.36,\n        text=\"Intermediate\\nLoad\",\n        text_font_size=\"28pt\",\n        text_color=COLOR_INTER,\n        text_font_style=\"bold\",\n        text_align=\"center\",\n    )\n)\np.add_layout(\n    Label(\n        x=(intermediate_end + hours_in_year) // 2,\n        y=load_mw[intermediate_end] * 0.42,\n        text=\"Base Load\",\n        text_font_size=\"28pt\",\n        text_color=COLOR_BASE,\n        text_font_style=\"bold\",\n        text_align=\"center\",\n    )\n)\n\n# Total energy and load factor annotations\np.add_layout(\n    Label(\n        x=hours_in_year // 2,\n        y=peak_load * 0.90,\n        text=f\"Total Energy: {total_energy_gwh:,.0f} GWh/year\",\n        text_font_size=\"26pt\",\n        text_color=INK,\n        text_font_style=\"bold\",\n        text_align=\"center\",\n    )\n)\np.add_layout(\n    Label(\n        x=hours_in_year // 2,\n        y=peak_load * 0.82,\n        text=f\"Load Factor: {load_factor:.1f}%\",\n        text_font_size=\"22pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n        text_align=\"center\",\n    )\n)\n\n# Legend — right panel\nlegend = Legend(\n    items=[(\"Peak Load\", [r_peak]), (\"Intermediate Load\", [r_inter]), (\"Base Load\", [r_base])], location=\"top_right\"\n)\nlegend.label_text_font_size = \"28pt\"\nlegend.label_text_color = INK_SOFT\nlegend.glyph_height = 32\nlegend.glyph_width = 32\nlegend.spacing = 14\nlegend.padding = 18\nlegend.background_fill_color = ELEVATED_BG\nlegend.border_line_color = INK_SOFT\np.add_layout(legend, \"right\")\n\n# Typography — bokeh sizing: 50pt title ≈ 67 source-px (same as matplotlib 12pt @ 400dpi)\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"normal\"\np.title.text_color = INK\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\np.xaxis.formatter = NumeralTickFormatter(format=\"0,0\")\np.yaxis.formatter = NumeralTickFormatter(format=\"0,0\")\n\n# Chrome — theme-adaptive\np.outline_line_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\n# Grid — y-axis only, subtle (15% opacity)\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.15\n\n# Background — theme-adaptive, never pure white/black\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\n# Save HTML artifact (interactive — HoverTool active)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with Selenium — export_png is not used (chromedriver snap shim fails).\n# CDP setDeviceMetricsOverride makes the inner viewport authoritative:\n# --window-size alone is eaten by Chrome chrome in headless mode (gives ~1661 instead of 1800).\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)  # allow bokeh JS canvas to render\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}