# attractors.py """ 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 # ----------------------------- # generic RK4 integrator # ----------------------------- 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:] # ----------------------------- # 1) Lorenz-63 (3D ODE) # ----------------------------- 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) # ----------------------------- # 2) Lorenz-96 (dD ODE) # ----------------------------- 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: # common init: near-constant with small noise x0 = F * np.ones(d, dtype=float) x0 += 0.01 * rng.standard_normal(d) # add a localized bump x0[0] += 0.1 else: x0 = np.asarray(x0, dtype=float).reshape(d) def f(x: Array) -> Array: # cyclic indices: x_{i+1}, x_{i-1}, x_{i-2} 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) # ----------------------------- # 3) Rössler (3D ODE) # ----------------------------- 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) # ----------------------------- # 4) Duffing oscillator (forced, 2D ODE) # ----------------------------- def duffing( T: int, dt: float = 0.01, *, delta: float = 0.2, gamma: float = 0.3, omega: float = 1.2, # equation: x'' + delta x' - x + x^3 = gamma cos(omega t) x0: Optional[Array] = None, # (x, v) 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) # Nonautonomous RK4 (depends on time) t = 0.0 for n in range(int(T)): traj[n] = x def f(tcur: float, s: Array) -> Array: xx, vv = s # x' = v # v' = x - x^3 - delta v + gamma cos(omega t) 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): # h(x) = m1*x + 0.5*(m0-m1)(|x+1|-|x-1|) 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) # RK4 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), # irrational ratio -> quasi-periodic seed: int = 0, noise: float = 0.0, # set small (e.g. 1e-3) for "almost but not really" ): """ 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 # ----------------------------- # Dadras (Dadras–Momeni) attractor (3D autonomous ODE) # ----------------------------- 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, # (x, y, z) 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) # RK4 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) # ----------------------------- # Halvorsen attractor (3D autonomous ODE) # ----------------------------- def halvorsen( T: int, dt: float = 0.005, *, alpha: float = 1.4, x0: Optional[Array] = None, # (x, y, z) 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: # classic-ish small start 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 # RK4 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 # ----------------------------- # Duffing oscillator (forced) # ----------------------------- def duffing_NESS( T: int, dt: float = 0.01, *, # equation: x'' + delta x' - x + x^3 = gamma cos(omega t) delta: float = 0.2, gamma: float = 0.30, omega: float = 1.0, # IC x0: Optional[Array] = None, # (x, v) OR (x, v, theta) seed: Optional[int] = 0, burn_in: int = 0, # extra outputs include_phase: bool = True, # return (x,v,cosθ,sinθ) if True else (x,v) ) -> 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) # init 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) # RK4 integration 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 # x' = v # v' = x - x^3 - delta v + gamma cos(omega t) return np.array([vv, xx - xx**3 - delta * vv + gamma * np.cos(th)], dtype=float) # RK4 for (x,v) with theta advanced explicitly 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) # ----------------------------- # registry / convenience # ----------------------------- 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 }