{"spec_id":"line-load-duration","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nline-load-duration: Load Duration Curve for Energy Systems\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-06-10\n\"\"\"\n\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT_PALETTE = (\n    \"#009E73\",  # brand green — peak load region\n    \"#C475FD\",  # lavender — intermediate load region\n    \"#4467A3\",  # blue — base load region\n    \"#BD8233\",  # ochre — base capacity line\n    \"#AE3030\",  # matte red — intermediate capacity line\n    \"#2ABCCD\",  # cyan — peak capacity line\n)\n\n# Data — synthetic annual hourly load profile for a mid-sized utility\nnp.random.seed(42)\nhours = 8760\n\nbase_load = 400\npeak_load = 1200\nmid_load = (base_load + peak_load) / 2\n\nhour_of_year = np.arange(hours)\nday_of_year = hour_of_year / 24.0\nhour_of_day = hour_of_year % 24\n\n# Seasonal component (summer peak, winter secondary peak)\nseasonal = 150 * np.sin(2 * np.pi * (day_of_year - 45) / 365)\nseasonal += 80 * np.sin(4 * np.pi * day_of_year / 365)\n\n# Daily component (daytime peak)\ndaily = 120 * np.sin(np.pi * (hour_of_day - 6) / 16)\ndaily[hour_of_day < 6] = -80\ndaily[hour_of_day > 22] = -60\n\n# Random noise\nnoise = np.random.normal(0, 40, hours)\n\n# Combine and sort descending for load duration curve\nraw_load = mid_load + seasonal + daily + noise\nraw_load = np.clip(raw_load, base_load * 0.9, peak_load * 1.05)\nload_mw = np.sort(raw_load)[::-1]\n\n# Capacity tiers — defined by load percentiles for visually balanced regions\n# Peak: top 15% of hours; Base: bottom 40% of hours; Intermediate: the rest\npeak_end = int(0.15 * hours)  # ~1314 hours\nbase_start = int(0.60 * hours)  # ~5256 hours\n\n# Round capacity MW to nearest 50 for clean engineering annotations\nintermediate_capacity = int(round(float(load_mw[peak_end]) / 50) * 50)\nbase_capacity = int(round(float(load_mw[base_start]) / 50) * 50)\n\n# Total energy consumption (area under curve)\ntotal_energy_twh = np.trapezoid(load_mw) / 1e6\n\n# Downsample for SVG performance (8760 points too heavy)\nstep = 15\nindices = list(range(0, hours, step))\nif indices[-1] != hours - 1:\n    indices.append(hours - 1)\nn_pts = len(indices)\n\nload_sampled = [float(load_mw[i]) for i in indices]\n\n# Build three filled region series (None outside each region)\npeak_series = [None] * n_pts\ninter_series = [None] * n_pts\nbase_series = [None] * n_pts\n\nfor i, idx in enumerate(indices):\n    val = load_sampled[i]\n    if idx <= peak_end:\n        peak_series[i] = val\n    elif idx <= base_start:\n        inter_series[i] = val\n    else:\n        base_series[i] = val\n\n# Overlap one point at each boundary for visual continuity\nfor i, idx in enumerate(indices):\n    if idx >= peak_end and inter_series[i] is None and peak_series[i] is not None:\n        inter_series[i] = load_sampled[i]\n        break\nfor i, idx in enumerate(indices):\n    if idx >= base_start and base_series[i] is None and inter_series[i] is not None:\n        base_series[i] = load_sampled[i]\n        break\n\n# Title — total energy moved to in-chart annotation per spec requirement\ntitle_str = \"Load Duration Curve · line-load-duration · python · pygal · anyplot.ai\"\nn_chars = len(title_str)\nratio = 67 / n_chars if n_chars > 67 else 1.0\ntitle_font_size = max(44, round(66 * ratio))\n\n# Style\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    title_font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    title_font_size=title_font_size,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    legend_font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    label_font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    major_label_font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    value_font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    stroke_width=3,\n    opacity=0.55,\n    opacity_hover=0.75,\n    guide_stroke_color=INK_MUTED,\n    guide_stroke_dasharray=\"4,4\",\n)\n\n# Chart\nchart = pygal.Line(\n    width=3200,\n    height=1800,\n    title=title_str,\n    x_title=\"Hours of Year (ranked by demand)\",\n    y_title=\"Power Demand (MW)\",\n    style=custom_style,\n    fill=True,\n    show_dots=False,\n    stroke_style={\"width\": 3},\n    show_y_guides=True,\n    show_x_guides=False,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_box_size=24,\n    value_formatter=lambda x: f\"{x:,.0f} MW\" if x else \"\",\n    min_scale=4,\n    max_scale=8,\n    margin_bottom=140,\n    margin_left=160,\n    margin_right=140,\n    margin_top=60,\n    range=(0, 1350),\n    truncate_label=10,\n)\n\n# Load region series (filled)\nchart.add(f\"Peak Load (0–{peak_end} hrs)\", peak_series, fill=True, stroke_style={\"width\": 3})\nchart.add(f\"Intermediate ({peak_end}–{base_start} hrs)\", inter_series, fill=True, stroke_style={\"width\": 3})\nchart.add(f\"Base Load ({base_start}–{hours} hrs)\", base_series, fill=True, stroke_style={\"width\": 3})\n\n# Capacity reference lines — wider stroke for prominence against filled regions\nchart.add(\n    f\"Base Capacity ({base_capacity} MW)\",\n    [base_capacity] * n_pts,\n    fill=False,\n    show_dots=False,\n    stroke_style={\"width\": 4, \"dasharray\": \"16, 8\"},\n)\nchart.add(\n    f\"Intermediate Capacity ({intermediate_capacity} MW)\",\n    [intermediate_capacity] * n_pts,\n    fill=False,\n    show_dots=False,\n    stroke_style={\"width\": 4, \"dasharray\": \"16, 8\"},\n)\nchart.add(\n    \"Peak Capacity (1200 MW)\",\n    [1200] * n_pts,\n    fill=False,\n    show_dots=False,\n    stroke_style={\"width\": 4, \"dasharray\": \"16, 8\"},\n)\n\n# X-axis labels at key milestones\nx_labels = []\nfor idx in indices:\n    if idx == 0:\n        x_labels.append(\"0\")\n    elif idx % 1000 < step:\n        x_labels.append(str((idx // 1000) * 1000))\n    elif idx == indices[-1]:\n        x_labels.append(\"8760\")\n    else:\n        x_labels.append(\"\")\nchart.x_labels = x_labels\n\n# === SVG post-processing: inject region labels and total energy annotation ===\n# Pygal has no native text-annotation API; add SVG <text> nodes before </svg>.\n# Approximate chart data area (3200×1800 canvas, based on margins and axis space):\n#   X: margin_left(160) + y_title(~60) + y_tick_labels(~180) ≈ 450 left, 3200-145 right\n#   Y: title(~120) + margin_top(60) ≈ 190 top;\n#      1800 - margin_bottom(140) - x_tick(~80) - x_title(~60) - legend(~150) ≈ 1370 bottom\n_XL, _XR = 450, 3055\n_YT, _YB = 190, 1360\n_XW, _YH = _XR - _XL, _YB - _YT\n_MW_MAX = 1350.0\n\n\ndef _sx(hour_frac):\n    \"\"\"Hour fraction [0,1] → SVG x coordinate.\"\"\"\n    return _XL + hour_frac * _XW\n\n\ndef _sy(mw):\n    \"\"\"MW value → SVG y coordinate (y increases downward).\"\"\"\n    return _YT + (1.0 - mw / _MW_MAX) * _YH\n\n\ndef _ann(x, y, text, color, anchor=\"middle\"):\n    \"\"\"SVG <text> element with a PAGE_BG halo stroke for readability on fills.\"\"\"\n    return (\n        f'<text x=\"{x:.0f}\" y=\"{y:.0f}\" '\n        f'font-family=\"DejaVu Sans,Helvetica,Arial,sans-serif\" '\n        f'font-size=\"52\" font-weight=\"bold\" '\n        f'fill=\"{color}\" stroke=\"{PAGE_BG}\" stroke-width=\"6\" paint-order=\"stroke fill\" '\n        f'text-anchor=\"{anchor}\">{text}</text>'\n    )\n\n\nsvg_bytes = chart.render()\nsvg_str = svg_bytes.decode(\"utf-8\")\n\n# Label x = midpoint of each region; y = representative load height inside the fill\npeak_mid_frac = (peak_end / 2) / hours\ninter_mid_frac = ((peak_end + base_start) / 2) / hours\nbase_mid_frac = ((base_start + hours) / 2) / hours\n\nannotations = \"\\n\".join(\n    [\n        _ann(_sx(peak_mid_frac), _sy(880), \"Peak Load\", IMPRINT_PALETTE[0]),\n        _ann(_sx(inter_mid_frac), _sy(640), \"Intermediate\", IMPRINT_PALETTE[1]),\n        _ann(_sx(base_mid_frac), _sy(450), \"Base Load\", IMPRINT_PALETTE[2]),\n        # Total energy annotation: right-aligned in the upper-right of the data area\n        _ann(_XR - 20, _YT + 75, f\"Annual Energy: {total_energy_twh:.1f} TWh\", INK, \"end\"),\n    ]\n)\n\n# Insert annotation block just before the closing </svg> tag\nidx = svg_str.rfind(\"</svg>\")\nsvg_modified = svg_str[:idx] + annotations + \"\\n</svg>\"\n\n# Write PNG from modified SVG (cairosvg, same engine used by render_to_png)\ncairosvg.svg2png(bytestring=svg_modified.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n\n# HTML interactive output uses the original (unmodified) SVG\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(svg_bytes)\n"}