GitHub

Python Examples

Complete worked examples using the koopman_dmd Python package

This page provides end-to-end Python examples for every major feature of the koopman_dmd library. Each section includes runnable code, expected output descriptions, and explanatory notes. The Python bindings call directly into the Rust core via PyO3, so performance is identical to native Rust.

Prerequisites. Install the package with pip install maturin && cd koopman-dmd-py && maturin develop --release. You will also need numpy and, for plotting examples, matplotlib.

1. Core DMD -- Fit, Analyze, Predict

The most common workflow: construct a damped oscillator signal, fit a DMD model on the training window, then predict beyond the observed data. DMD decomposes the dynamics into a small number of spatiotemporal modes, each with an associated eigenvalue that encodes its oscillation frequency and growth/decay rate.

import numpy as np
from koopman_dmd import DMD

# Damped oscillator: 2 variables, 300 time steps
dt = 0.05
t = np.arange(300) * dt
x1 = np.exp(-0.05 * t) * np.sin(2 * np.pi * 0.5 * t)
x2 = np.exp(-0.05 * t) * np.cos(2 * np.pi * 0.5 * t)
data = np.vstack([x1, x2])

# Train on first 200, predict all 299
model = DMD(rank=2).fit(data[:, :200])
pred = model.predict(299)

print(f"Rank: {model.rank}")
print(f"Eigenvalues: {model.eigenvalues}")
print(f"Prediction shape: {pred.shape}")  # (2, 299)

Because the underlying system is a damped linear oscillator, DMD with rank 2 recovers it exactly (up to numerical precision). The two eigenvalues form a complex conjugate pair whose magnitude is less than 1, encoding both the 0.5 Hz oscillation frequency and the exponential decay rate. The prediction extends cleanly beyond the 200-sample training window because each time step is computed independently from the spectral decomposition, not recursively.

DMD prediction vs actual signal for a damped oscillator

Plot generated from equivalent R code. The Python output is numerically identical.

2. Spectral and Stability Analysis

After fitting a DMD model, the spectrum() method converts discrete-time eigenvalues into physical quantities: oscillation frequency (Hz), eigenvalue magnitude, growth rate, and a stability label. The stability() method summarizes the overall system behavior, and error() quantifies reconstruction accuracy.

# Spectrum: frequency, magnitude, growth rate per mode
spectrum = model.spectrum(dt=0.05)
for i, mode in enumerate(spectrum):
    print(f"Mode {i}: freq={mode['frequency']:.3f} Hz, "
          f"|lambda|={mode['magnitude']:.4f}, "
          f"stability={mode['stability']}")

# Stability summary
stab = model.stability()
print(f"Spectral radius: {stab['spectral_radius']:.6f}")
print(f"Stable: {stab['is_stable']}")

# Error metrics
err = model.error(data[:, :200])
print(f"RMSE: {err['rmse']:.2e}")
print(f"Relative error: {err['relative_error']:.2e}")

For a damped oscillator, both modes will report "stable" because their eigenvalue magnitudes are strictly less than 1. The spectral radius (maximum eigenvalue magnitude) is the single most important number for stability: values below 1 indicate decay, values above 1 indicate exponential growth. The RMSE and relative error should be near machine precision for this linear system.

DMD eigenvalues plotted on the unit circle

Plot generated from equivalent R code. Eigenvalues inside the unit circle indicate stable (decaying) modes.

3. Extended DMD with Lifting

Standard DMD assumes the data evolves under a linear operator. When the dynamics are nonlinear, we can "lift" the state into a higher-dimensional feature space where the evolution is approximately linear. Extended DMD automates this: specify a lifting function, and the library handles the feature expansion, DMD in the lifted space, and back-projection of predictions to the original coordinates.

import numpy as np
from koopman_dmd import DMD

# Nonlinear signal: sin^2(t)
t = np.arange(200) * 0.05
data = np.sin(t)**2
data = data.reshape(1, -1)

# Standard DMD
model_std = DMD().fit(data)
err_std = model_std.error(data)

# Polynomial lifting (degree 2)
model_lift = DMD(lifting="polynomial", lifting_param=2).fit(data)
err_lift = model_lift.error(data)

print(f"Standard RMSE: {err_std['rmse']:.6f}")
print(f"Lifted RMSE:   {err_lift['rmse']:.6f}")

# Predictions are automatically back-projected
pred = model_lift.predict(50)
print(f"Prediction shape: {pred.shape}")  # (1, 50)

The signal sin^2(t) = (1 - cos(2t))/2 is nonlinear in the original coordinate but becomes linear after polynomial lifting. With degree-2 polynomials, the lifted state includes both x and x^2, which spans the subspace needed to represent the dynamics exactly. You should see a dramatic reduction in RMSE -- often several orders of magnitude -- compared to standard DMD.

Comparison of standard DMD vs extended DMD with polynomial lifting

Plot generated from equivalent R code. The lifted model captures the nonlinear oscillation that standard DMD cannot.

4. DMD with Centering

When the signal has a non-zero mean (DC offset), standard DMD must devote a mode to representing that constant component. Centering subtracts the temporal mean before decomposition and adds it back during reconstruction, freeing all DMD modes to capture the oscillatory dynamics.

import numpy as np
from koopman_dmd import DMD

# Signal with DC offset
t = np.arange(300) * 0.02
signal = 3.0 + 2.0 * np.sin(2 * np.pi * t) + 0.5 * np.sin(6 * np.pi * t)
data = signal.reshape(1, -1)

# Without centering: must capture mean with an eigenvalue
model_no_center = DMD(rank=4).fit(data)

# With centering: mean subtracted, DMD focuses on dynamics
model_centered = DMD(rank=4, center=True).fit(data)

pred_nc = model_no_center.predict(299)
pred_c = model_centered.predict(299)

err_nc = model_no_center.error(data)
err_c = model_centered.error(data)

print(f"Without centering -- RMSE: {err_nc['rmse']:.6f}")
print(f"With centering    -- RMSE: {err_c['rmse']:.6f}")

Centering is especially helpful when:

When centering is enabled, the stored mean is automatically added back to all predictions and reconstructions, so the output is always in the original coordinate system.

5. Hankel-DMD for Scalar Time Series

When you have a single measured variable (scalar time series), standard DMD cannot recover multiple frequencies because the data matrix has only one row. Hankel-DMD solves this by constructing a time-delay embedding: it stacks lagged copies of the signal into a Hankel matrix, creating a multi-row data matrix where DMD can identify the underlying modes.

import numpy as np
from koopman_dmd import HankelDMD

# Two-frequency scalar signal
dt = 0.02
t = np.arange(500) * dt
signal = np.sin(2 * np.pi * 1.0 * t) + 0.4 * np.sin(2 * np.pi * 3.0 * t)
data = signal.reshape(1, -1)

# Train on 400 samples
model = HankelDMD(delays=30, rank=4, dt=dt).fit(data[:, :400])

# Reconstruct training data
recon = model.reconstruct()
print(f"Reconstruction shape: {recon.shape}")

# Predict 100 future steps
pred = model.predict(100)
print(f"Prediction shape: {pred.shape}")

# Recovered eigenvalues
for ev in model.eigenvalues:
    freq = abs(np.angle(ev)) / (2 * np.pi * dt)
    print(f"  lambda={ev:.4f}, |lambda|={abs(ev):.4f}, freq={freq:.2f} Hz")

With 30 delays and rank 4, Hankel-DMD recovers both the 1.0 Hz and 3.0 Hz components as complex conjugate eigenvalue pairs. The number of delays should be at least 2-3 times the number of modes you expect. The eigenvalue magnitudes should be very close to 1.0 for this undamped signal, and the recovered frequencies should match 1.0 Hz and 3.0 Hz. Predictions are automatically projected back from the augmented Hankel space to the original scalar observation.

Hankel-DMD reconstruction and out-of-sample prediction

Plot generated from equivalent R code. The blue region is training data; the red region is the out-of-sample forecast.

6. GLA -- Generalized Laplace Analysis

Generalized Laplace Analysis (GLA) is an alternative to DMD that extracts Koopman eigenvalues by solving an optimization problem rather than computing the SVD of a data matrix. GLA can be more accurate than DMD for systems with eigenvalues exactly on the unit circle (pure oscillators) and can handle longer time series without the memory cost of storing large matrices.

import numpy as np
from koopman_dmd import GLA

# Pure oscillator
t = np.arange(400) * 0.1
data = np.vstack([np.sin(t), np.cos(t)])

# Fit GLA
model = GLA(n_eigenvalues=2, tol=1e-4).fit(data)
print(f"Eigenvalues: {model.eigenvalues}")

# Reconstruct and predict
recon = model.reconstruct()
pred = model.predict(100)
print(f"Reconstruction shape: {recon.shape}")
print(f"Prediction shape: {pred.shape}")

For a pure sine/cosine oscillator, GLA should recover two eigenvalues that are complex conjugates lying exactly on the unit circle. The convergence tolerance controls how precisely the eigenvalues are refined: smaller values yield more accurate results at the cost of additional iterations.

GLA reconstruction and prediction for a pure oscillator

Plot generated from equivalent R code. GLA recovers eigenvalues on the unit circle exactly.

7. Trajectory Generation

The library includes several classical dynamical maps implemented in Rust for high performance. These serve as test systems for DMD analysis and for studying the Koopman operator on systems with mixed regular and chaotic dynamics.

from koopman_dmd import generate_trajectory

# Standard map (regular orbit)
traj_reg = generate_trajectory(
    ic=[0.1, 0.2], map_name="standard", n_steps=5000,
    params={"epsilon": 0.12}
)
print(f"Regular trajectory: {traj_reg.shape}")

# Standard map (chaotic orbit)
traj_chaos = generate_trajectory(
    ic=[0.5, 0.5], map_name="standard", n_steps=50000,
    params={"epsilon": 0.97}
)

# Henon attractor
traj_henon = generate_trajectory(
    ic=[0.0, 0.0], map_name="henon", n_steps=20000,
    params={"a": 1.4, "b": 0.3}
)

# Logistic map
traj_log = generate_trajectory(
    ic=[0.4], map_name="logistic", n_steps=200,
    params={"r": 3.9}
)

The standard map at epsilon=0.12 is in a near-integrable regime where most orbits lie on invariant circles (regular motion). At epsilon=0.97, the phase space is largely chaotic. The Henon map with classical parameters a=1.4, b=0.3 produces its famous strange attractor. The logistic map at r=3.9 is in the chaotic regime. All trajectories are returned as NumPy arrays of shape (n_vars, n_steps).

Phase portraits of the standard map and Henon attractor

Plot generated from equivalent R code. Left: standard map orbits. Right: Henon attractor.

8. Harmonic Time Averages

Harmonic time averages (HTAs) provide a finite-time approximation of Koopman eigenfunctions. At frequency omega=0, the HTA converges to the time-average of the observable along the orbit. For regular (quasiperiodic) orbits, the HTA converges; for chaotic orbits, it does not. This convergence/divergence behavior is the basis for classifying phase space regions.

from koopman_dmd import harmonic_time_average, classify_phase_space

# HTA at a single point
hta = harmonic_time_average(
    ic=[0.1, 0.2], map_name="standard",
    observable="sin_pi", omega=0.0, n_iter=10000,
    params={"epsilon": 0.12}
)
print(f"Magnitude: {hta['magnitude']:.6f}")
print(f"Phase: {hta['phase']:.6f}")

# Classify phase space region
cls = classify_phase_space(
    ic=[0.1, 0.2], map_name="standard",
    omega=0.0, n_iter=50000,
    params={"epsilon": 0.12}
)
print(f"Classification: {cls['classification']}")

The initial condition [0.1, 0.2] with epsilon=0.12 lies on a regular invariant circle of the standard map, so the classification should return "regular". The HTA magnitude is non-zero and stable. For a chaotic initial condition, the HTA magnitude would fluctuate without converging, and the classifier would return "chaotic".

HTA convergence comparison for regular vs chaotic orbits

Plot generated from equivalent R code. Regular orbits converge smoothly; chaotic orbits oscillate indefinitely.

9. Mesochronic Plots

A mesochronic plot visualizes the harmonic time average over a grid of initial conditions, producing a color-coded map of phase space. Regions of regular motion appear as smooth, coherent bands of color; chaotic regions appear noisy. The Rust backend parallelizes the grid computation across all available CPU cores, making high-resolution mesochronic plots practical.

from koopman_dmd import mesochronic_compute

# Compute HTA over a 100x100 grid (parallelized in Rust)
meso = mesochronic_compute(
    map_name="standard",
    x_range=(0.0, 1.0), y_range=(0.0, 1.0),
    resolution=100, observable="sin_pi",
    omega=0.0, n_iter=5000,
    params={"epsilon": 0.12}
)

print(f"HTA matrix shape: {meso['hta_matrix'].shape}")
print(f"Phase matrix shape: {meso['phase_matrix'].shape}")

# Visualize with matplotlib
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
ax1.pcolormesh(meso["x_coords"], meso["y_coords"],
               meso["hta_matrix"], cmap="viridis")
ax1.set_title("|HTA| Magnitude")
ax2.pcolormesh(meso["x_coords"], meso["y_coords"],
               meso["phase_matrix"], cmap="RdYlBu")
ax2.set_title("HTA Phase")
plt.tight_layout()
plt.savefig("mesochronic.png", dpi=150)

At resolution 100 with 5000 iterations per grid point, this computes 50 million map iterations total. The Rust parallelization makes this feasible in seconds rather than minutes. Increasing the resolution to 200 or 500 produces publication-quality images at the cost of proportionally longer computation.

Mesochronic plot showing regular and chaotic regions of the standard map

Plot generated from equivalent R code. Smooth bands indicate invariant circles (regular motion); noisy regions indicate chaos.

10. Complete Workflow -- DMD on Map Trajectory

This example chains together trajectory generation and DMD analysis into a complete workflow: generate data from a dynamical map, fit a DMD model, analyze the spectrum and stability, compute errors, and forecast beyond the training window.

import numpy as np
from koopman_dmd import DMD, generate_trajectory

# Generate standard map trajectory
traj = generate_trajectory(
    ic=[0.1, 0.2], map_name="standard", n_steps=2000,
    params={"epsilon": 0.12}
)

# Train DMD on first 1500 steps
model = DMD(rank=4).fit(traj[:, :1500])

# Analyze
print(f"Rank: {model.rank}")
spec = model.spectrum(dt=1.0)
for s in spec:
    print(f"  freq={s['frequency']:.4f}, |lambda|={s['magnitude']:.4f}")

stab = model.stability()
print(f"Stable: {stab['is_stable']}")

err = model.error(traj[:, :1500])
print(f"RMSE: {err['rmse']:.6f}")

# Predict out-of-sample
pred = model.predict(500)
print(f"Forecast shape: {pred.shape}")

# Dominant modes
top = model.dominant_modes(criterion="energy", n=2)
print(f"Top 2 modes by energy: {top}")

For the regular orbit at epsilon=0.12, DMD with rank 4 should recover eigenvalues very close to the unit circle (the system is area-preserving and the orbit is quasiperiodic). The spectral radius should be approximately 1.0, and the system should be classified as stable. The frequencies correspond to the winding number of the invariant circle. Out-of-sample prediction quality depends on how well the linear approximation captures the local dynamics.

11. Visualization Recipes

Below are reusable matplotlib patterns for the most common koopman-dmd visualizations. Each recipe takes the output of a standard library call and produces a publication-ready plot.

Eigenvalue Unit Circle Plot

Plot DMD eigenvalues against the unit circle to visualize stability. Eigenvalues inside the circle are decaying modes; outside are growing; on the circle are neutral (energy-preserving).

import numpy as np
import matplotlib.pyplot as plt
from koopman_dmd import DMD

# Fit a DMD model (assumes 'data' is defined)
model = DMD(rank=4).fit(data)

# Extract eigenvalues
evals = np.array(model.eigenvalues)

# Draw the unit circle
theta = np.linspace(0, 2 * np.pi, 200)
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.plot(np.cos(theta), np.sin(theta), "--", color="gray", lw=0.8)
ax.scatter(evals.real, evals.imag, s=80, zorder=5,
          edgecolors="black", linewidths=0.5)
ax.set_xlabel("Re(lambda)")
ax.set_ylabel("Im(lambda)")
ax.set_title("DMD Eigenvalues")
ax.set_aspect("equal")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("eigenvalue_circle.png", dpi=150)

Predicted vs Actual Time Series

Overlay the DMD prediction on the true signal to visually assess both reconstruction accuracy (training region) and forecast quality (out-of-sample region).

import numpy as np
import matplotlib.pyplot as plt
from koopman_dmd import DMD

# Assuming 'data' shape (n_vars, n_time) and model is fitted
n_train = 200
model = DMD(rank=2).fit(data[:, :n_train])
pred = model.predict(data.shape[1])

fig, ax = plt.subplots(figsize=(10, 4))
t = np.arange(data.shape[1])
ax.plot(t, data[0], label="Actual", color="#58a6ff")
ax.plot(t[:pred.shape[1]], pred[0].real, "--",
       label="DMD prediction", color="#f78166")
ax.axvline(n_train, ls=":", color="gray", label="Train/test split")
ax.set_xlabel("Time step")
ax.set_ylabel("x[0]")
ax.legend()
ax.set_title("DMD Prediction vs Actual")
plt.tight_layout()
plt.savefig("predict_vs_actual.png", dpi=150)

Reconstruction Error Heatmap

For multi-variable systems, visualize the pointwise reconstruction error as a heatmap with variables on the y-axis and time on the x-axis. This reveals which variables and time regions have the highest error.

import numpy as np
import matplotlib.pyplot as plt
from koopman_dmd import DMD

# Fit and reconstruct
n_time = data.shape[1]
model = DMD(rank=4).fit(data)
recon = model.reconstruct(n_time)

# Pointwise absolute error
error_matrix = np.abs(data - recon.real)

fig, ax = plt.subplots(figsize=(10, 3))
im = ax.imshow(error_matrix, aspect="auto", cmap="inferno",
               interpolation="nearest")
ax.set_xlabel("Time step")
ax.set_ylabel("Variable")
ax.set_title("Reconstruction Error")
plt.colorbar(im, ax=ax, label="Absolute error")
plt.tight_layout()
plt.savefig("error_heatmap.png", dpi=150)

Spectrum Bar Chart

Visualize the frequency content of the DMD decomposition as a bar chart, with bar height proportional to mode amplitude. This is the DMD analog of a Fourier power spectrum.

import numpy as np
import matplotlib.pyplot as plt
from koopman_dmd import DMD

dt = 0.02
model = DMD(rank=6).fit(data)
spec = model.spectrum(dt=dt)

freqs = [s["frequency"] for s in spec]
amps = [abs(a) for a in model.amplitudes]

fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(freqs, amps, width=0.3, edgecolor="black", linewidth=0.5)
ax.set_xlabel("Frequency (Hz)")
ax.set_ylabel("Amplitude")
ax.set_title("DMD Spectrum")
ax.grid(True, axis="y", alpha=0.3)
plt.tight_layout()
plt.savefig("spectrum_bar.png", dpi=150)