| |
| """ |
| A small zoo of chaotic attractors with a standardized API. |
| |
| All generators return a trajectory array of shape (T, D), where: |
| - T is the number of time points / steps. |
| - D is the state dimension (e.g., 3 for Lorenz-63). |
| |
| For continuous-time systems (ODEs), dt is the integration step (RK4). |
| For discrete-time maps (Hénon, Ikeda), dt is accepted for API consistency but ignored. |
| |
| You can later standardize/split/chop trajectories for train/test externally. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Callable, Dict, Optional, Tuple |
|
|
| import numpy as np |
|
|
|
|
| Array = np.ndarray |
|
|
|
|
| |
| |
| |
| def rk4(f: Callable[[Array], Array], x0: Array, T: int, dt: float) -> Array: |
| """ |
| Integrate x' = f(x) using fixed-step RK4. |
| |
| Parameters |
| ---------- |
| f : function x -> dx/dt |
| x0 : (D,) |
| T : number of time points (trajectory length) |
| dt : step size |
| |
| Returns |
| ------- |
| traj : (T, D) |
| """ |
| x = np.asarray(x0, dtype=float).copy() |
| D = x.size |
| traj = np.zeros((int(T), D), dtype=float) |
| for t in range(int(T)): |
| traj[t] = x |
| k1 = f(x) |
| k2 = f(x + 0.5 * dt * k1) |
| k3 = f(x + 0.5 * dt * k2) |
| k4 = f(x + dt * k3) |
| x = x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4) |
| return traj |
|
|
|
|
| def _rng_from_seed(seed: Optional[int]) -> np.random.Generator: |
| return np.random.default_rng(None if seed is None else int(seed)) |
|
|
|
|
| def _discard_burn_in(traj: Array, burn_in: int) -> Array: |
| burn_in = int(burn_in) |
| if burn_in <= 0: |
| return traj |
| if burn_in >= traj.shape[0]: |
| raise ValueError(f"burn_in={burn_in} must be < T={traj.shape[0]}") |
| return traj[burn_in:] |
|
|
|
|
| |
| |
| |
| def lorenz63( |
| T: int, |
| dt: float = 0.01, |
| *, |
| sigma: float = 10.0, |
| rho: float = 28.0, |
| beta: float = 8.0 / 3.0, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| ) -> Array: |
| """ |
| Lorenz-63 system (classic chaotic regime). |
| Returns (T-burn_in, 3). |
| """ |
| rng = _rng_from_seed(seed) |
| if x0 is None: |
| x0 = np.array([1.0, 1.0, 1.0], dtype=float) + 0.01 * rng.standard_normal(3) |
| else: |
| x0 = np.asarray(x0, dtype=float).reshape(3) |
|
|
| def f(x: Array) -> Array: |
| return np.array( |
| [ |
| sigma * (x[1] - x[0]), |
| x[0] * (rho - x[2]) - x[1], |
| x[0] * x[1] - beta * x[2], |
| ], |
| dtype=float, |
| ) |
|
|
| traj = rk4(f, x0, T=T, dt=float(dt)) |
| return _discard_burn_in(traj, burn_in) |
|
|
|
|
| |
| |
| |
| def lorenz96( |
| T: int, |
| dt: float = 0.01, |
| *, |
| dim: int = 5, |
| F: float = 8.0, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| ) -> Array: |
| """ |
| Lorenz-96 in the standard chaotic regime (F ~ 8, dim ~ 40). |
| Returns (T-burn_in, dim). |
| """ |
| rng = _rng_from_seed(seed) |
| d = int(dim) |
| if x0 is None: |
| |
| x0 = F * np.ones(d, dtype=float) |
| x0 += 0.01 * rng.standard_normal(d) |
| |
| x0[0] += 0.1 |
| else: |
| x0 = np.asarray(x0, dtype=float).reshape(d) |
|
|
| def f(x: Array) -> Array: |
| |
| xp1 = np.roll(x, -1) |
| xm1 = np.roll(x, 1) |
| xm2 = np.roll(x, 2) |
| return (xp1 - xm2) * xm1 - x + F |
|
|
| traj = rk4(f, x0, T=T, dt=float(dt)) |
| return _discard_burn_in(traj, burn_in) |
|
|
|
|
| |
| |
| |
| def rossler( |
| T: int, |
| dt: float = 0.02, |
| *, |
| a: float = 0.2, |
| b: float = 0.2, |
| c: float = 5.7, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| ) -> Array: |
| """ |
| Rössler attractor (classic chaotic parameters). |
| Returns (T-burn_in, 3). |
| """ |
| rng = _rng_from_seed(seed) |
| if x0 is None: |
| x0 = np.array([0.1, 0.0, 0.0], dtype=float) + 0.01 * rng.standard_normal(3) |
| else: |
| x0 = np.asarray(x0, dtype=float).reshape(3) |
|
|
| def f(x: Array) -> Array: |
| x1, y1, z1 = x |
| return np.array( |
| [ |
| -y1 - z1, |
| x1 + a * y1, |
| b + z1 * (x1 - c), |
| ], |
| dtype=float, |
| ) |
|
|
| traj = rk4(f, x0, T=T, dt=float(dt)) |
| return _discard_burn_in(traj, burn_in) |
|
|
| |
| |
| |
| def duffing( |
| T: int, |
| dt: float = 0.01, |
| *, |
| delta: float = 0.2, |
| gamma: float = 0.3, |
| omega: float = 1.2, |
| |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| ) -> Array: |
| """ |
| Forced Duffing oscillator in a commonly chaotic regime. |
| State is (x, v) where v = x'. |
| Returns (T-burn_in, 2). |
| |
| Note: This is already nonautonomous due to cos(omega t). |
| """ |
| rng = _rng_from_seed(seed) |
| if x0 is None: |
| x = np.array([0.1, 0.0], dtype=float) + 0.01 * rng.standard_normal(2) |
| else: |
| x = np.asarray(x0, dtype=float).reshape(2) |
|
|
| traj = np.zeros((int(T), 2), dtype=float) |
|
|
| |
| t = 0.0 |
| for n in range(int(T)): |
| traj[n] = x |
|
|
| def f(tcur: float, s: Array) -> Array: |
| xx, vv = s |
| |
| |
| return np.array( |
| [vv, xx - xx**3 - delta * vv + gamma * np.cos(omega * tcur)], |
| dtype=float, |
| ) |
|
|
| k1 = f(t, x) |
| k2 = f(t + 0.5 * dt, x + 0.5 * dt * k1) |
| k3 = f(t + 0.5 * dt, x + 0.5 * dt * k2) |
| k4 = f(t + dt, x + dt * k3) |
| x = x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4) |
| t += dt |
|
|
| return _discard_burn_in(traj, burn_in) |
|
|
| def generate(name: str, T: int, dt: float = 0.01, **kwargs) -> Array: |
| """ |
| Convenience wrapper: |
| traj = generate("lorenz63", T=20000, dt=0.01, burn_in=2000) |
| """ |
| key = name.lower() |
| if key not in ATTRACTORS: |
| raise KeyError(f"Unknown attractor '{name}'. Options: {sorted(ATTRACTORS.keys())}") |
| return ATTRACTORS[key](T=T, dt=dt, **kwargs) |
|
|
| def _chua_nonlinearity(x, m0, m1): |
| |
| return m1 * x + 0.5 * (m0 - m1) * (np.abs(x + 1.0) - np.abs(x - 1.0)) |
|
|
| def chua( |
| T: int, |
| *, |
| dt: float = 0.01, |
| burn_in: int = 2000, |
| alpha: float = 15.6, |
| beta: float = 28.0, |
| m0: float = -1.15, |
| m1: float = -0.70, |
| seed: int = 0, |
| x0: np.ndarray | None = None, |
| ): |
| """ |
| Chua double-scroll attractor. |
| |
| Equations: |
| xdot = alpha*(y - x - h(x)) |
| ydot = x - y + z |
| zdot = -beta*y |
| """ |
| rng = np.random.default_rng(seed) |
|
|
| if x0 is None: |
| x = 0.1 * rng.standard_normal(3) |
| else: |
| x = np.array(x0, dtype=np.float64).reshape(3) |
|
|
| def f(x): |
| xx, yy, zz = x |
| h = _chua_nonlinearity(xx, m0=m0, m1=m1) |
| dx = alpha * (yy - xx - h) |
| dy = xx - yy + zz |
| dz = -beta * yy |
| return np.array([dx, dy, dz], dtype=np.float64) |
|
|
| |
| T = int(T_total) |
| out = np.zeros((T, 3), dtype=np.float64) |
| for n in range(T): |
| out[n] = x |
| k1 = f(x) |
| k2 = f(x + 0.5 * dt * k1) |
| k3 = f(x + 0.5 * dt * k2) |
| k4 = f(x + dt * k3) |
| x = x + (dt / 6.0) * (k1 + 2*k2 + 2*k3 + k4) |
|
|
| if burn_in > 0: |
| out = out[burn_in:] |
| return out |
|
|
| def torus_quasiperiodic( |
| T: int, |
| *, |
| dt: float = 0.01, |
| burn_in: int = 0, |
| R: float = 2.0, |
| r: float = 0.7, |
| omega1: float = 1.0, |
| omega2: float = np.sqrt(2.0), |
| seed: int = 0, |
| noise: float = 0.0, |
| ): |
| """ |
| Quasiperiodic orbit on a torus embedded in R^3. |
| |
| Returns: |
| (T_total - burn_in, 3) |
| """ |
| rng = np.random.default_rng(seed) |
| T = int(T_total) |
| t = np.arange(T, dtype=np.float64) * float(dt) |
|
|
| th1 = omega1 * t |
| th2 = omega2 * t |
|
|
| x = (R + r * np.cos(th2)) * np.cos(th1) |
| y = (R + r * np.cos(th2)) * np.sin(th1) |
| z = r * np.sin(th2) |
|
|
| X = np.stack([x, y, z], axis=1) |
|
|
| if noise > 0: |
| X = X + noise * rng.standard_normal(size=X.shape) |
|
|
| if burn_in > 0: |
| X = X[burn_in:] |
| return X |
|
|
|
|
| |
| |
| |
| def dadras( |
| T: int, |
| dt: float = 0.01, |
| *, |
| a: float = 3.0, |
| b: float = 2.7, |
| c: float = 1.7, |
| d: float = 2.0, |
| e: float = 9.0, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| ) -> Array: |
| """ |
| Dadras (Dadras–Momeni) chaotic attractor. |
| |
| Equations (common form): |
| x' = y - a*x + b*y*z |
| y' = c + z*(1 - x) |
| z' = d*x*y - e*z |
| |
| Returns: (T - burn_in, 3) as [x, y, z]. |
| |
| Typical chaotic params: |
| a=3, b=2.7, c=1.7, d=2, e=9 |
| |
| Notes: |
| - Autonomous & stationary on attractor after burn-in. |
| - RK4 integrator (stable for dt ~ 1e-3..1e-2 typically). |
| """ |
| rng = _rng_from_seed(seed) |
|
|
| if x0 is None: |
| x = np.array([1.0, 1.0, 1.0], dtype=float) + 0.01 * rng.standard_normal(3) |
| else: |
| x = np.asarray(x0, dtype=float).reshape(3) |
|
|
| traj = np.zeros((int(T), 3), dtype=float) |
|
|
| t = 0.0 |
| for n in range(int(T)): |
| traj[n] = x |
|
|
| def f(_t: float, s: Array) -> Array: |
| xx, yy, zz = s |
| dx = yy - a * xx + b * yy * zz |
| dy = c + zz * (1.0 - xx) |
| dz = d * xx * yy - e * zz |
| return np.array([dx, dy, dz], dtype=float) |
|
|
| |
| k1 = f(t, x) |
| k2 = f(t + 0.5 * dt, x + 0.5 * dt * k1) |
| k3 = f(t + 0.5 * dt, x + 0.5 * dt * k2) |
| k4 = f(t + dt, x + dt * k3) |
| x = x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4) |
| t += dt |
|
|
| return _discard_burn_in(traj, burn_in) |
|
|
| |
| |
| |
| def halvorsen( |
| T: int, |
| dt: float = 0.005, |
| *, |
| alpha: float = 1.4, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| ) -> Array: |
| """ |
| Halvorsen attractor (cyclically symmetric chaotic system). |
| |
| Equations: |
| x' = -α x - 4y - 4z - y^2 |
| y' = -α y - 4z - 4x - z^2 |
| z' = -α z - 4x - 4y - x^2 |
| |
| Typical chaotic parameter: |
| α = 1.4 |
| |
| Returns: (T - burn_in, 3) as [x, y, z]. |
| |
| Notes: |
| - dt=0.005 is a common stable choice for this system. |
| - RK4 integrator. |
| """ |
| rng = _rng_from_seed(seed) |
|
|
| if x0 is None: |
| |
| x = np.array([0.1, 0.0, 0.0], dtype=float) + 0.01 * rng.standard_normal(3) |
| else: |
| x = np.asarray(x0, dtype=float).reshape(3) |
|
|
| traj = np.zeros((int(T), 3), dtype=float) |
|
|
| t = 0.0 |
| a = float(alpha) |
| h = float(dt) |
|
|
| def f(_t: float, s: Array) -> Array: |
| xx, yy, zz = s |
| dx = -a * xx - 4.0 * yy - 4.0 * zz - (yy * yy) |
| dy = -a * yy - 4.0 * zz - 4.0 * xx - (zz * zz) |
| dz = -a * zz - 4.0 * xx - 4.0 * yy - (xx * xx) |
| return np.array([dx, dy, dz], dtype=float) |
|
|
| for n in range(int(T)): |
| traj[n] = x |
|
|
| |
| k1 = f(t, x) |
| k2 = f(t + 0.5 * h, x + 0.5 * h * k1) |
| k3 = f(t + 0.5 * h, x + 0.5 * h * k2) |
| k4 = f(t + h, x + h * k3) |
| x = x + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4) |
| t += h |
|
|
| return _discard_burn_in(traj, burn_in) |
|
|
| def _rng_from_seed(seed: Optional[int]) -> np.random.Generator: |
| if seed is None: |
| return np.random.default_rng() |
| return np.random.default_rng(int(seed)) |
|
|
| def _discard_burn_in(traj: Array, burn_in: int) -> Array: |
| b = int(max(0, burn_in)) |
| return traj[b:] if b > 0 else traj |
|
|
| |
| |
| |
| def duffing_NESS( |
| T: int, |
| dt: float = 0.01, |
| *, |
| |
| delta: float = 0.2, |
| gamma: float = 0.30, |
| omega: float = 1.0, |
| |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| |
| include_phase: bool = True, |
| ) -> Array: |
| """ |
| Forced Duffing oscillator in a commonly chaotic regime. |
| |
| Base 2D state: (x, v) where v = x' |
| If include_phase=True, we *augment* the observed state with forcing phase: |
| theta' = omega |
| return (x, v, cos(theta), sin(theta)) -> 4D autonomous observation |
| This tends to improve forecastability vs a purely 2D nonautonomous observation. |
| |
| References for typical forced Duffing form and chaos behavior: |
| - Duffing equation definition and chaos discussion :contentReference[oaicite:1]{index=1} |
| - Example parameter sets (alpha=-1, beta=1, delta~0.2, gamma~0.3, omega~1) :contentReference[oaicite:2]{index=2} |
| """ |
| rng = _rng_from_seed(seed) |
|
|
| |
| if x0 is None: |
| x = np.array([0.1, 0.0], dtype=float) + 0.01 * rng.standard_normal(2) |
| theta = 0.0 |
| else: |
| x0 = np.asarray(x0, dtype=float).reshape(-1) |
| if x0.size >= 2: |
| x = x0[:2].copy() |
| else: |
| raise ValueError("x0 must have at least (x,v).") |
| theta = float(x0[2]) if x0.size >= 3 else 0.0 |
|
|
| if include_phase: |
| traj = np.zeros((int(T), 4), dtype=float) |
| else: |
| traj = np.zeros((int(T), 2), dtype=float) |
|
|
| |
| t = 0.0 |
| for n in range(int(T)): |
| if include_phase: |
| traj[n, 0] = x[0] |
| traj[n, 1] = x[1] |
| traj[n, 2] = np.cos(theta) |
| traj[n, 3] = np.sin(theta) |
| else: |
| traj[n] = x |
|
|
| def f(tcur: float, s: Array, th: float) -> Array: |
| xx, vv = s |
| |
| |
| return np.array([vv, xx - xx**3 - delta * vv + gamma * np.cos(th)], dtype=float) |
|
|
| |
| k1 = f(t, x, theta) |
| k2 = f(t + 0.5 * dt, x + 0.5 * dt * k1, theta + 0.5 * dt * omega) |
| k3 = f(t + 0.5 * dt, x + 0.5 * dt * k2, theta + 0.5 * dt * omega) |
| k4 = f(t + dt, x + dt * k3, theta + dt * omega) |
|
|
| x = x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4) |
| theta = theta + dt * omega |
| t += dt |
|
|
| return _discard_burn_in(traj, burn_in) |
|
|
|
|
|
|
| |
| |
| |
| ATTRACTORS: Dict[str, Callable[..., Array]] = { |
| "lorenz63": lorenz63, |
| "lorenz96": lorenz96, |
| "rossler": rossler, |
| "halvorsen": halvorsen, |
| "chua": chua, |
| "dadras": dadras, |
| "torus_quasiperiodic": torus_quasiperiodic, |
| "duffing": duffing, |
| "duffing_NESS": duffing_NESS |
| } |