{"spec_id":"line-load-duration","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nline-load-duration: Load Duration Curve for Energy Systems\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-10\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script's own directory from sys.path so that `import altair`\n# finds the installed package, not this file (which shares the library name).\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _this_dir]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme-adaptive chrome — 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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — load duration regions (positions 1–3)\nPEAK_COLOR = \"#009E73\"  # position 1: brand green\nINTER_COLOR = \"#C475FD\"  # position 2: lavender\nBASE_COLOR = \"#4467A3\"  # position 3: blue\n\n# ── Data ──────────────────────────────────────────────────────────────────────\nnp.random.seed(42)\nHOURS = 8760\nt = np.arange(HOURS)\n\nload_mw = (\n    400\n    + 350\n    + 200 * np.sin(2 * np.pi * (t % 24 - 6) / 24)\n    + 250 * np.cos(2 * np.pi * (t / 24 - 30) / 365)\n    + np.where((t // 24) % 7 < 5, 80, 0)\n    + np.random.normal(0, 50, HOURS)\n)\nload_mw = np.clip(load_mw, 380, 1250)\nload_sorted = np.sort(load_mw)[::-1]\n\nPEAK_CAP = 1100\nINTER_CAP = 900\nBASE_CAP = 550\n\npeak_end = int(np.searchsorted(-load_sorted, -PEAK_CAP))\ninter_end = int(np.searchsorted(-load_sorted, -BASE_CAP))\ntotal_gwh = np.trapezoid(load_sorted) / 1000\n\n# Downsample 8760 → ~877 points for Vega performance\nstep = 10\nidx = np.unique(np.append(np.arange(0, HOURS, step), HOURS - 1))\nload_ds = load_sorted[idx]\nhour_ds = idx.astype(float)\n\n# Per-region DataFrames for coloured fills (non-overlapping in x)\npm = hour_ds <= peak_end\npeak_df = pd.DataFrame({\"hour\": hour_ds[pm], \"load_mw\": load_ds[pm]})\n\nim = (hour_ds >= peak_end) & (hour_ds <= inter_end)\ninter_df = pd.DataFrame(\n    {\n        \"hour\": np.concatenate([[float(peak_end)], hour_ds[im]]),\n        \"load_mw\": np.concatenate([[np.interp(peak_end, hour_ds, load_ds)], load_ds[im]]),\n    }\n)\n\nbm = hour_ds >= inter_end\nbase_df = pd.DataFrame(\n    {\n        \"hour\": np.concatenate([[float(inter_end)], hour_ds[bm]]),\n        \"load_mw\": np.concatenate([[np.interp(inter_end, hour_ds, load_ds)], load_ds[bm]]),\n    }\n)\n\nfull_df = pd.DataFrame({\"hour\": hour_ds, \"load_mw\": load_ds})\n\n# ── Chart layers ──────────────────────────────────────────────────────────────\nY_DOM = alt.Scale(domain=[0, 1400])\n\npeak_area = (\n    alt.Chart(peak_df)\n    .mark_area(opacity=0.5, color=PEAK_COLOR)\n    .encode(x=alt.X(\"hour:Q\"), y=alt.Y(\"load_mw:Q\", scale=Y_DOM))\n)\ninter_area = (\n    alt.Chart(inter_df)\n    .mark_area(opacity=0.5, color=INTER_COLOR)\n    .encode(x=alt.X(\"hour:Q\"), y=alt.Y(\"load_mw:Q\", scale=Y_DOM))\n)\nbase_area = (\n    alt.Chart(base_df)\n    .mark_area(opacity=0.5, color=BASE_COLOR)\n    .encode(x=alt.X(\"hour:Q\"), y=alt.Y(\"load_mw:Q\", scale=Y_DOM))\n)\n\nline = (\n    alt.Chart(full_df)\n    .mark_line(color=INK, strokeWidth=2.0)\n    .encode(\n        x=alt.X(\"hour:Q\", title=\"Hours of Year (ranked)\", axis=alt.Axis(format=\",d\")),\n        y=alt.Y(\"load_mw:Q\", title=\"Power Demand (MW)\", scale=Y_DOM),\n        tooltip=[\n            alt.Tooltip(\"hour:Q\", title=\"Hour Rank\", format=\",d\"),\n            alt.Tooltip(\"load_mw:Q\", title=\"Load (MW)\", format=\",.0f\"),\n        ],\n    )\n)\n\n# Horizontal dashed capacity tier lines\ntier_df = pd.DataFrame(\n    {\"y\": [PEAK_CAP, INTER_CAP, BASE_CAP], \"label\": [\"Peak cap. 1,100 MW\", \"Inter. cap. 900 MW\", \"Base cap. 550 MW\"]}\n)\ntier_rules = (\n    alt.Chart(tier_df)\n    .mark_rule(strokeDash=[8, 4], strokeWidth=1.5, opacity=0.6)\n    .encode(y=alt.Y(\"y:Q\", scale=Y_DOM), color=alt.value(INK_SOFT))\n)\ntier_labels = (\n    alt.Chart(tier_df)\n    .mark_text(align=\"right\", dx=-4, dy=-8, fontSize=11, fontWeight=\"bold\", color=INK_SOFT)\n    .encode(x=alt.value(614), y=alt.Y(\"y:Q\", scale=Y_DOM), text=\"label:N\")\n)\n\n# Region identity labels inside each coloured fill\npeak_lbl = (\n    alt.Chart(pd.DataFrame({\"hour\": [peak_end / 2], \"load_mw\": [1150.0], \"t\": [\"Peak\"]}))\n    .mark_text(fontSize=10, fontWeight=\"bold\", fontStyle=\"italic\", color=PEAK_COLOR, opacity=0.9)\n    .encode(x=\"hour:Q\", y=alt.Y(\"load_mw:Q\", scale=Y_DOM), text=\"t:N\")\n)\ninter_lbl = (\n    alt.Chart(pd.DataFrame({\"hour\": [(peak_end + inter_end) / 2], \"load_mw\": [760.0], \"t\": [\"Intermediate\"]}))\n    .mark_text(fontSize=10, fontWeight=\"bold\", fontStyle=\"italic\", color=INTER_COLOR, opacity=0.9)\n    .encode(x=\"hour:Q\", y=alt.Y(\"load_mw:Q\", scale=Y_DOM), text=\"t:N\")\n)\nbase_lbl = (\n    alt.Chart(pd.DataFrame({\"hour\": [(inter_end + HOURS) / 2], \"load_mw\": [480.0], \"t\": [\"Base Load\"]}))\n    .mark_text(fontSize=10, fontWeight=\"bold\", fontStyle=\"italic\", color=BASE_COLOR, opacity=0.9)\n    .encode(x=\"hour:Q\", y=alt.Y(\"load_mw:Q\", scale=Y_DOM), text=\"t:N\")\n)\nenergy_lbl = (\n    alt.Chart(pd.DataFrame({\"hour\": [5200.0], \"load_mw\": [220.0], \"t\": [f\"Total energy: {total_gwh:,.0f} GWh/yr\"]}))\n    .mark_text(fontSize=11, color=INK_SOFT)\n    .encode(x=\"hour:Q\", y=alt.Y(\"load_mw:Q\", scale=Y_DOM), text=\"t:N\")\n)\n\n# Crosshair selection for interactive HTML\nnearest = alt.selection_point(nearest=True, on=\"pointerover\", fields=[\"hour\"], empty=False)\nxhair_pt = (\n    alt.Chart(full_df)\n    .mark_point(size=80, color=INK, filled=True)\n    .encode(x=\"hour:Q\", y=\"load_mw:Q\", opacity=alt.condition(nearest, alt.value(1), alt.value(0)))\n    .add_params(nearest)\n)\nxhair_rule = (\n    alt.Chart(full_df)\n    .mark_rule(color=INK_SOFT, strokeDash=[4, 4], strokeWidth=1)\n    .encode(x=\"hour:Q\", opacity=alt.condition(nearest, alt.value(0.6), alt.value(0)))\n)\n\nTITLE = \"line-load-duration · python · altair · anyplot.ai\"\nSTATIC_LAYERS = [\n    peak_area,\n    inter_area,\n    base_area,\n    line,\n    tier_rules,\n    tier_labels,\n    peak_lbl,\n    inter_lbl,\n    base_lbl,\n    energy_lbl,\n]\n\n# ── PNG (static) ──────────────────────────────────────────────────────────────\nchart = (\n    alt.layer(*STATIC_LAYERS)\n    .properties(width=620, height=320, background=PAGE_BG, title=alt.Title(TITLE, fontSize=16))\n    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=320)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=12,\n    )\n    .configure_axisX(grid=False)\n    .configure_title(color=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Pad to exact 3200 × 1800 canvas (vl-convert inner view is smaller)\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        \"Shrink chart .properties(width=, height=) and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# ── Interactive HTML ──────────────────────────────────────────────────────────\ninter_chart = (\n    alt.layer(*STATIC_LAYERS, xhair_pt, xhair_rule)\n    .properties(width=620, height=320, background=PAGE_BG, title=alt.Title(TITLE, fontSize=16))\n    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=320)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=12,\n    )\n    .configure_axisX(grid=False)\n    .configure_title(color=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .interactive()\n)\ninter_chart.save(f\"plot-{THEME}.html\")\n"}