{"spec_id":"timeseries-forecast-uncertainty","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\ntimeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\nLibrary: letsplot 4.9.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-19\n\"\"\"\n# ruff: noqa: F405\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\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\"\nINK_GRID = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\n\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\"]\n\nALPHA_95 = 0.24 if THEME == \"light\" else 0.35\nALPHA_80 = 0.38 if THEME == \"light\" else 0.55\n\n# Monthly energy demand: 36 months history + 12 month forecast\nnp.random.seed(42)\n\ndates_hist = pd.date_range(\"2023-01-01\", periods=36, freq=\"MS\")\ntrend = np.linspace(420, 510, 36)\nseasonal = 40 * np.sin(np.linspace(0, 6 * np.pi, 36))\nnoise = np.random.normal(0, 12, 36)\nactual = trend + seasonal + noise\n\ndates_forecast = pd.date_range(\"2026-01-01\", periods=12, freq=\"MS\")\ntrend_fc = np.linspace(510, 545, 12)\nseasonal_fc = 40 * np.sin(np.linspace(6 * np.pi, 8 * np.pi, 12))\nforecast = trend_fc + seasonal_fc\nuncertainty_80 = np.linspace(18, 45, 12)\nuncertainty_95 = np.linspace(28, 68, 12)\n\ndf_hist = pd.DataFrame({\"date\": dates_hist, \"value\": actual, \"series\": \"Historical\"})\ndf_fc = pd.DataFrame(\n    {\n        \"date\": dates_forecast,\n        \"value\": forecast,\n        \"lower_80\": forecast - uncertainty_80,\n        \"upper_80\": forecast + uncertainty_80,\n        \"lower_95\": forecast - uncertainty_95,\n        \"upper_95\": forecast + uncertainty_95,\n        \"series\": \"Forecast\",\n    }\n)\n\nforecast_start = dates_forecast[0]\n\n# Plot — theme_classic gives L-shaped spines; theme() overrides specific elements\nplot = (\n    ggplot()\n    # 95% CI (outer, lighter)\n    + geom_ribbon(\n        aes(x=\"date\", ymin=\"lower_95\", ymax=\"upper_95\"), data=df_fc, fill=IMPRINT[1], alpha=ALPHA_95, color=None\n    )\n    # 80% CI (inner, darker)\n    + geom_ribbon(\n        aes(x=\"date\", ymin=\"lower_80\", ymax=\"upper_80\"), data=df_fc, fill=IMPRINT[1], alpha=ALPHA_80, color=None\n    )\n    # Historical solid line (brand green) — tooltips show value on hover in HTML\n    + geom_line(\n        aes(x=\"date\", y=\"value\", color=\"series\"),\n        data=df_hist[[\"date\", \"value\", \"series\"]],\n        size=1.2,\n        linetype=\"solid\",\n        tooltips=layer_tooltips().line(\"@value{.0f} MWh\").line(\"@date\"),\n    )\n    # Forecast dashed line (orange) — tooltips show forecast and CI bounds on hover\n    + geom_line(\n        aes(x=\"date\", y=\"value\", color=\"series\"),\n        data=df_fc[[\"date\", \"value\", \"lower_80\", \"upper_80\", \"lower_95\", \"upper_95\", \"series\"]],\n        size=1.2,\n        linetype=\"dashed\",\n        tooltips=layer_tooltips()\n        .line(\"Forecast: @value{.0f} MWh\")\n        .line(\"80% CI: [@lower_80{.0f}, @upper_80{.0f}]\")\n        .line(\"95% CI: [@lower_95{.0f}, @upper_95{.0f}]\")\n        .line(\"@date\"),\n    )\n    # Vertical marker at forecast boundary\n    + geom_vline(xintercept=forecast_start.timestamp() * 1000, color=INK_MUTED, size=0.6, linetype=\"dotted\")\n    + scale_color_manual(values={\"Historical\": IMPRINT[0], \"Forecast\": IMPRINT[1]}, name=\"\")\n    + labs(\n        x=\"Date\",\n        y=\"Energy Demand (MWh)\",\n        title=\"timeseries-forecast-uncertainty · python · letsplot · anyplot.ai\",\n        caption=\"Bands: 80% CI (darker)  ·  95% CI (lighter)\",\n    )\n    + ggsize(800, 450)\n    + theme_classic()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        panel_grid_major_y=element_line(color=INK_GRID, size=0.5),\n        axis_title=element_text(color=INK, size=14),\n        axis_text=element_text(color=INK_SOFT, size=12),\n        axis_line=element_line(color=INK_SOFT, size=0.5),\n        plot_title=element_text(color=INK, size=18),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_text=element_text(color=INK_SOFT, size=12),\n        legend_title=element_blank(),\n        plot_caption=element_text(color=INK_MUTED, size=11),\n        legend_position=\"bottom\",\n    )\n)\n\n# Save PNG and HTML with theme suffix\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}