{"spec_id":"line-load-duration","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nline-load-duration: Load Duration Curve for Energy Systems\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-10\n\"\"\"\n\nimport os\nimport shutil\n\nimport numpy as np\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    geom_hline,\n    geom_line,\n    geom_ribbon,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\n    layer_tooltips,\n    scale_fill_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — semantic exception applies for load regions:\n# base load (reliable/always-on) → green, intermediate → ochre, peak (costly/intensive) → matte red\nCOLOR_BASE = \"#009E73\"  # Imprint position 1 — reliable, continuous generation\nCOLOR_INTER = \"#BD8233\"  # Imprint position 4 — variable middle-tier generation\nCOLOR_PEAK = \"#AE3030\"  # Imprint position 5 — semantic red for high-cost peak demand\n\n# Data: Synthetic annual hourly load profile for a mid-sized utility\nnp.random.seed(42)\nhours = np.arange(8760)\nhour_of_day = hours % 24\nday_of_year = hours // 24\n\nbase_load = 400\nseasonal_component = 450 * np.sin(2 * np.pi * (day_of_year - 30) / 365) ** 2\ndaily_pattern = 250 * np.sin(np.pi * (hour_of_day - 6) / 16) ** 2\ndaily_pattern[hour_of_day < 6] = 0\nnoise = np.random.normal(0, 40, 8760)\n\nhourly_load = base_load + seasonal_component + daily_pattern + noise\nhourly_load = np.clip(hourly_load, 350, 1250)\n\n# Sort descending to create the load duration curve\nload_sorted = np.sort(hourly_load)[::-1]\nrank_hours = np.arange(8760)\n\npeak_threshold = 900\nintermediate_threshold = 550\n\ntotal_energy_gwh = np.sum(load_sorted) / 1000\n\n# Non-overlapping ribbon regions\npeak_ymin = np.full(8760, peak_threshold, dtype=float)\npeak_ymax = np.where(load_sorted > peak_threshold, load_sorted, peak_threshold)\n\ninter_ymin = np.full(8760, intermediate_threshold, dtype=float)\ninter_ymax = np.where(\n    load_sorted > intermediate_threshold, np.minimum(load_sorted, peak_threshold), intermediate_threshold\n)\n\nbase_ymin = np.zeros(8760)\nbase_ymax = np.minimum(load_sorted, intermediate_threshold)\n\ndf_peak = pd.DataFrame(\n    {\"hour\": rank_hours, \"ymin\": peak_ymin, \"ymax\": peak_ymax, \"load_mw\": load_sorted, \"region\": \"Peak Load\"}\n)\ndf_inter = pd.DataFrame(\n    {\"hour\": rank_hours, \"ymin\": inter_ymin, \"ymax\": inter_ymax, \"load_mw\": load_sorted, \"region\": \"Intermediate Load\"}\n)\ndf_base = pd.DataFrame(\n    {\"hour\": rank_hours, \"ymin\": base_ymin, \"ymax\": base_ymax, \"load_mw\": load_sorted, \"region\": \"Base Load\"}\n)\ndf_line = pd.DataFrame({\"hour\": rank_hours, \"load_mw\": load_sorted})\n\npeak_hours_count = int(np.sum(load_sorted > peak_threshold))\nintermediate_hours_count = int(np.sum(load_sorted > intermediate_threshold))\nload_factor = np.mean(load_sorted) / np.max(load_sorted) * 100\n\n# Region label positions (center of each shaded band)\ndf_labels = pd.DataFrame(\n    {\n        \"hour\": [\n            peak_hours_count / 2,\n            (peak_hours_count + intermediate_hours_count) / 2,\n            (intermediate_hours_count + 8760) / 2,\n        ],\n        \"load_mw\": [\n            (np.max(load_sorted) + peak_threshold) / 2,\n            (peak_threshold + intermediate_threshold) / 2,\n            intermediate_threshold / 2 + 20,\n        ],\n        \"label\": [\"Peak\\nLoad\", \"Intermediate\\nLoad\", \"Base\\nLoad\"],\n    }\n)\n\n# Energy summary annotation in upper-right empty space\ndf_energy = pd.DataFrame(\n    {\n        \"hour\": [5800],\n        \"load_mw\": [1170],\n        \"label\": [f\"Total energy: {total_energy_gwh:,.0f} GWh/yr\\nLoad factor: {load_factor:.0f}%\"],\n    }\n)\n\n# Capacity threshold labels\ndf_peak_cap = pd.DataFrame(\n    {\"hour\": [6200], \"load_mw\": [peak_threshold + 38], \"label\": [f\"Peak capacity: {peak_threshold} MW\"]}\n)\ndf_inter_cap = pd.DataFrame(\n    {\n        \"hour\": [6200],\n        \"load_mw\": [intermediate_threshold + 38],\n        \"label\": [f\"Intermediate capacity: {intermediate_threshold} MW\"],\n    }\n)\n\nREGION_COLORS = {\"Peak Load\": COLOR_PEAK, \"Intermediate Load\": COLOR_INTER, \"Base Load\": COLOR_BASE}\n\ntitle = \"line-load-duration · python · letsplot · anyplot.ai\"\nn = len(title)\ntitle_fontsize = round(16 * 67 / n) if n > 67 else 16\n\nplot = (\n    ggplot()\n    + geom_ribbon(\n        data=df_base,\n        mapping=aes(x=\"hour\", ymin=\"ymin\", ymax=\"ymax\", fill=\"region\"),\n        alpha=0.72,\n        tooltips=layer_tooltips()\n        .format(\"ymax\", \".0f\")\n        .format(\"@hour\", \".0f\")\n        .line(\"Hour rank: @hour\")\n        .line(\"Load: @ymax MW\")\n        .line(\"Region: @region\"),\n    )\n    + geom_ribbon(\n        data=df_inter,\n        mapping=aes(x=\"hour\", ymin=\"ymin\", ymax=\"ymax\", fill=\"region\"),\n        alpha=0.72,\n        tooltips=layer_tooltips()\n        .format(\"@load_mw\", \".0f\")\n        .format(\"@hour\", \".0f\")\n        .line(\"Hour rank: @hour\")\n        .line(\"Load: @load_mw MW\")\n        .line(\"Region: @region\"),\n    )\n    + geom_ribbon(\n        data=df_peak,\n        mapping=aes(x=\"hour\", ymin=\"ymin\", ymax=\"ymax\", fill=\"region\"),\n        alpha=0.72,\n        tooltips=layer_tooltips()\n        .format(\"@load_mw\", \".0f\")\n        .format(\"@hour\", \".0f\")\n        .line(\"Hour rank: @hour\")\n        .line(\"Load: @load_mw MW\")\n        .line(\"Region: @region\"),\n    )\n    + geom_line(\n        data=df_line,\n        mapping=aes(x=\"hour\", y=\"load_mw\"),\n        color=INK,\n        size=1.0,\n        tooltips=layer_tooltips()\n        .format(\"@load_mw\", \".0f\")\n        .format(\"@hour\", \",d\")\n        .line(\"Hour: @hour\")\n        .line(\"Demand: @load_mw MW\"),\n    )\n    + geom_hline(yintercept=peak_threshold, linetype=\"dashed\", color=COLOR_PEAK, size=0.8)\n    + geom_hline(yintercept=intermediate_threshold, linetype=\"dashed\", color=COLOR_INTER, size=0.8)\n    + geom_text(data=df_labels, mapping=aes(x=\"hour\", y=\"load_mw\", label=\"label\"), size=4, color=INK, fontface=\"bold\")\n    + geom_text(\n        data=df_energy, mapping=aes(x=\"hour\", y=\"load_mw\", label=\"label\"), size=3.5, color=INK_MUTED, fontface=\"italic\"\n    )\n    + geom_text(\n        data=df_peak_cap, mapping=aes(x=\"hour\", y=\"load_mw\", label=\"label\"), size=3.5, color=COLOR_PEAK, fontface=\"bold\"\n    )\n    + geom_text(\n        data=df_inter_cap,\n        mapping=aes(x=\"hour\", y=\"load_mw\", label=\"label\"),\n        size=3.5,\n        color=COLOR_INTER,\n        fontface=\"bold\",\n    )\n    + scale_fill_manual(values=REGION_COLORS)\n    + scale_x_continuous(\n        name=\"Hours of Year (Ranked by Demand)\",\n        breaks=[0, 2000, 4000, 6000, 8000],\n        labels=[\"0\", \"2,000\", \"4,000\", \"6,000\", \"8,000\"],\n    )\n    + scale_y_continuous(name=\"Power Demand (MW)\", breaks=[0, 200, 400, 600, 800, 1000, 1200])\n    + labs(\n        title=title,\n        subtitle=f\"Mid-sized utility · Peak {np.max(load_sorted):.0f} MW · {peak_hours_count:,} hours above peak threshold\",\n    )\n    + theme_minimal()\n    + theme(\n        plot_title=element_text(size=title_fontsize, face=\"bold\", color=INK),\n        plot_subtitle=element_text(size=10, color=INK_SOFT),\n        axis_title=element_text(size=12, color=INK),\n        axis_text=element_text(size=10, color=INK_SOFT),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor=element_blank(),\n        panel_grid_major_y=element_line(color=INK_SOFT, size=0.15),\n        legend_position=\"none\",\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_text=element_text(color=INK_SOFT),\n    )\n    + ggsize(800, 450)\n)\n\n# Save PNG and HTML for both themes\n# ggsave writes into lets-plot-images/ by default; move to the working directory\nggsave(plot, f\"plot-{THEME}.png\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\")\nfor _ext in (\"png\", \"html\"):\n    _src = os.path.join(\"lets-plot-images\", f\"plot-{THEME}.{_ext}\")\n    if os.path.exists(_src):\n        shutil.move(_src, f\"plot-{THEME}.{_ext}\")\n"}