Recurrence Plot for Nonlinear Time Series — Bokeh

A recurrence plot is a binary or distance-based matrix visualization that reveals when states in a dynamical system recur over time. Both axes represent time indices, and a point is plotted at position (i, j) when the system state at time i is sufficiently similar to the state at time j (distance below a threshold). The resulting symmetric matrix exposes hidden structure in complex, nonlinear dynamics — diagonal lines indicate determinism, vertical/horizontal lines reveal laminar states, and block structures signal regime changes.

Recurrence Plot for Nonlinear Time Series rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
recurrence-basic: Recurrence Plot for Nonlinear Time Series
Library: bokeh 3.9.1 | Python 3.13.13
Quality: 90/100 | Updated: 2026-06-10
"""

import io
import os
import sys
import time
from pathlib import Path

import numpy as np
from PIL import Image


# Workaround: remove current directory from import path to avoid shadowing
# the bokeh package with this file (bokeh.py) when run in-place
original_path = sys.path.copy()
sys.path = [p for p in sys.path if p != "" and not (os.path.isfile(os.path.join(p, "bokeh.py")) if p else False)]

try:
    from bokeh.io import output_file, save
    from bokeh.models import BasicTicker, ColorBar, ColumnDataSource, HoverTool, Label, LinearColorMapper
    from bokeh.plotting import figure
    from bokeh.resources import CDN
    from selenium import webdriver
    from selenium.webdriver.chrome.options import Options
finally:
    sys.path = original_path

# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Imprint sequential colormap: brand green → blue (256 stops, single-polarity)
_c0 = np.array([0x00, 0x9E, 0x73])  # #009E73
_c1 = np.array([0x44, 0x67, 0xA3])  # #4467A3
IMPRINT_SEQ256 = [
    "#{:02X}{:02X}{:02X}".format(*(int(round(v)) for v in (_c0 + (_c1 - _c0) * t / 255.0))) for t in range(256)
]

# Data — Lorenz attractor x-component
np.random.seed(42)
dt = 0.01
num_steps = 5000
lx, ly, lz = 1.0, 1.0, 1.0
sigma, rho, beta = 10.0, 28.0, 8.0 / 3.0

trajectory = np.empty(num_steps)
for step in range(num_steps):
    dx = sigma * (ly - lx) * dt
    dy = (lx * (rho - lz) - ly) * dt
    dz = (lx * ly - beta * lz) * dt
    lx, ly, lz = lx + dx, ly + dy, lz + dz
    trajectory[step] = lx

signal = trajectory[::10]
n_points = len(signal)

# Time-delay embedding (Takens' theorem): dim=3, delay=5
tau = 5
dim = 3
n_embedded = n_points - (dim - 1) * tau
embedded = np.empty((n_embedded, dim))
for d in range(dim):
    embedded[:, d] = signal[d * tau : d * tau + n_embedded]

# Pairwise Euclidean distance matrix
diff = embedded[:, np.newaxis, :] - embedded[np.newaxis, :, :]
dist_matrix = np.sqrt(np.sum(diff**2, axis=2))
threshold = np.percentile(dist_matrix, 10)
max_dist = dist_matrix.max()
dist_normalized = dist_matrix / max_dist
dist_flipped = dist_normalized[::-1, :]  # row 0 at top

color_mapper = LinearColorMapper(palette=IMPRINT_SEQ256, low=0.0, high=1.0)

# Plot — square 2400×2400 canonical
n = n_embedded
p = figure(
    width=2400,
    height=2400,
    title="recurrence-basic · python · bokeh · anyplot.ai",
    x_axis_label="Time Index (Lorenz x-component, dt=0.01)",
    y_axis_label="Time Index (Lorenz x-component, dt=0.01)",
    toolbar_location=None,
    tools="",
    x_range=(0, n),
    y_range=(0, n),
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=110,
    min_border_right=50,
)

p.image(image=[dist_flipped], x=0, y=0, dw=n, dh=n, color_mapper=color_mapper)

color_bar = ColorBar(
    color_mapper=color_mapper,
    ticker=BasicTicker(desired_num_ticks=6),
    label_standoff=16,
    border_line_color=None,
    background_fill_color=PAGE_BG,
    location=(0, 0),
    title="Normalized Distance",
    title_text_font_size="28pt",
    title_text_color=INK,
    major_label_text_font_size="24pt",
    major_label_text_color=INK_SOFT,
    width=40,
    padding=30,
)
p.add_layout(color_bar, "right")

p.add_layout(
    Label(
        x=200,
        y=260,
        text="Laminar regime",
        text_font_size="28pt",
        text_color=INK,
        text_font_style="bold",
        background_fill_color=ELEVATED_BG,
        background_fill_alpha=0.88,
    )
)
p.add_layout(
    Label(
        x=120,
        y=160,
        text="Deterministic diagonals",
        text_font_size="28pt",
        text_color=INK,
        text_font_style="bold",
        background_fill_color=ELEVATED_BG,
        background_fill_alpha=0.88,
        angle=0.78,
    )
)
p.add_layout(
    Label(
        x=10,
        y=n - 25,
        text=f"Recurrence threshold ε = {threshold:.1f} (10th percentile)",
        text_font_size="24pt",
        text_color=INK,
        background_fill_color=ELEVATED_BG,
        background_fill_alpha=0.88,
    )
)

# HoverTool via invisible scatter overlay (idiomatic Bokeh for image plots)
hover_step = 20
hover_xs, hover_ys, hover_dists, hover_recs = [], [], [], []
for i in range(0, n, hover_step):
    for j in range(0, n, hover_step):
        hover_xs.append(i + hover_step // 2)
        hover_ys.append(j + hover_step // 2)
        dist_ij = dist_matrix[i, j]
        hover_dists.append(round(float(dist_ij), 2))
        hover_recs.append("Yes" if dist_ij <= threshold else "No")

hover_source = ColumnDataSource(data={"x": hover_xs, "y": hover_ys, "distance": hover_dists, "recurrent": hover_recs})
invisible_scatter = p.scatter(x="x", y="y", source=hover_source, size=hover_step, fill_alpha=0, line_alpha=0)
p.add_tools(
    HoverTool(
        renderers=[invisible_scatter],
        tooltips=[
            ("Time i", "@x"),
            ("Time j", "@y"),
            ("Distance", "@distance"),
            ("Recurrent (d < {:.1f})".format(threshold), "@recurrent"),
        ],
    )
)

# Style — theme-adaptive chrome
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT
p.xgrid.grid_line_color = None
p.ygrid.grid_line_color = None
p.axis.minor_tick_line_color = None
p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.outline_line_color = INK_SOFT
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG

# Save HTML then screenshot with headless Chrome (export_png not used — snap shim fails)
output_file(f"plot-{THEME}.html")
save(p, resources=CDN)

# Window is H+200 tall so the full bokeh canvas renders; PIL crops to W×H.
W, H = 2400, 2400
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H + 200}",
    "--hide-scrollbars",
    "--force-device-scale-factor=1",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H + 200)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
raw = driver.get_screenshot_as_png()
driver.quit()
Image.open(io.BytesIO(raw)).crop((0, 0, W, H)).save(f"plot-{THEME}.png")

Part of Recurrence Plot for Nonlinear Time Series on anyplot.ai.

Other implementations