Time Series Forecast with Uncertainty Band — Pygal

A time series plot that displays historical observed data followed by a forecast projection with confidence intervals or uncertainty bands. The plot clearly distinguishes between the historical period and the forecast period using a vertical line marker, with shaded bands representing different confidence levels (typically 80% and 95%). This visualization is essential for communicating prediction uncertainty in forecasting applications, helping stakeholders understand both the expected values and the range of possible outcomes.

Time Series Forecast with Uncertainty Band rendered with Pygal

Renders

Python source (Pygal)

""" anyplot.ai
timeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band
Library: pygal 3.1.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-05-19
"""

import os
import sys
sys.path[:] = [p for p in sys.path if p not in ("", ".", os.path.dirname(__file__))]

import cairosvg
import numpy as np
import pandas as pd
import pygal
from pygal.style import Style


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

BRAND = "#009E73"
FORECAST_COLOR = "#C475FD"

np.random.seed(42)
months = 42
dates = pd.date_range("2022-01-01", periods=months, freq="MS")
split_idx = 36

t = np.arange(split_idx)
base = 1000 + 50 * t + 100 * np.sin(2 * np.pi * t / 12)
noise = np.random.normal(0, 30, split_idx)
actual = base + noise

t_forecast = np.arange(split_idx, months)
forecast_base = actual[-1] + 30 * (t_forecast - split_idx)
forecast_values = forecast_base + 50 * np.sin(2 * np.pi * t_forecast / 12)

upper_95 = forecast_values + 150
lower_95 = forecast_values - 150
upper_80 = forecast_values + 100
lower_80 = forecast_values - 100

x_labels = [d.strftime("%b %y") for d in dates]

CI_ALPHA = "0.25" if THEME == "dark" else "0.20"
CI_95_FILL = f"rgba(196, 117, 253, {CI_ALPHA})"

# Series order is driven by the background-erase CI technique: upper_95 fill
# (orange) then lower_95 fill (PAGE_BG) produces the CI band; BRAND historical
# line must be drawn last (on top) so the erase fill doesn't cover it.
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=(CI_95_FILL, PAGE_BG, INK, INK, BRAND, FORECAST_COLOR),
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
)

chart = pygal.Line(
    width=3200,
    height=1800,
    style=custom_style,
    show_x_guides=False,
    show_y_guides=True,
    show_legend=True,
    legend_at_bottom=True,
    dots_size=2,
    print_values=False,
    # pygal auto-calculates truncation from chart_width / len(x_labels) / font_size;
    # with 42 data points this resolves to 1 char → every label collapses to "…".
    truncate_label=-1,
)

chart.title = "timeseries-forecast-uncertainty · python · pygal · anyplot.ai"
chart.x_title = "Month"
chart.y_title = "Sales ($)"

# Sparse labels: show every 6th month; x_labels_major marks Jan 25 as the
# forecast boundary with a prominent guide line.
sparse_labels = [x_labels[i] if i % 6 == 0 else "" for i in range(len(x_labels))]
chart.x_labels = sparse_labels
chart.x_labels_major = ["Jan 25"]

# Background-erase: CI series carry actual values in the history region so the
# erase fill covers exactly the same area as the orange fill → no net band there.
upper_95_full = list(actual) + list(upper_95)
lower_95_full = list(actual) + list(lower_95)

chart.add("95% CI", upper_95_full, fill=True, show_legend=True, stroke_dasharray=(0,))
chart.add(None, lower_95_full, fill=True, show_legend=False, stroke_dasharray=(0,))
chart.add("80% CI", [None] * split_idx + list(upper_80), fill=False, show_legend=True, stroke_dasharray=(4, 4))
chart.add(None, [None] * split_idx + list(lower_80), fill=False, show_legend=False, stroke_dasharray=(4, 4))
chart.add("Historical (observed)", list(actual) + [None] * (months - split_idx), fill=False, stroke_dasharray=(0,))
chart.add("Forecast (projected)", [None] * split_idx + list(forecast_values), fill=False, stroke_dasharray=(5, 5))

# pygal hardcodes stroke:black for guide lines — invisible on dark backgrounds.
svg_bytes = chart.render()
svg_str = svg_bytes.decode("utf-8")
svg_str = svg_str.replace(
    "stroke-dasharray:4,4;stroke:black}", f"stroke-dasharray:4,4;stroke:{INK_MUTED};stroke-opacity:0.5}}"
)
svg_str = svg_str.replace("stroke-dasharray:6,6;stroke:black}", f"stroke-dasharray:8,5;stroke:{INK};stroke-width:1.5}}")

cairosvg.svg2png(bytestring=svg_str.encode(), write_to=f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "w") as f:
    f.write(svg_str)

Part of Time Series Forecast with Uncertainty Band on anyplot.ai.

Other implementations