| |
| """ |
| Forced / nonstationary variants of chaotic attractors. |
| |
| Standard output is always a single long trajectory: |
| R_tX : (T-burn_in, D) |
| |
| For ODEs, forcing MUST be applied inside the integrator (nonautonomous RK4), |
| not post-hoc. This file provides three forcing mechanisms for Lorenz-63: |
| |
| 1) Additive forcing to the dynamics (adds kappa*u(t) to dx/dt, dy/dt, dz/dt) |
| 2) Parameter drift (e.g., rho(t) varies with time / forcing) |
| 3) Regime switching (piecewise-constant params over time) |
| |
| All functions accept (T, dt) as primary inputs, with reasonable defaults. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Callable, Dict, Optional, Sequence, Tuple, Union |
|
|
| import numpy as np |
|
|
| Array = np.ndarray |
|
|
|
|
| |
| |
| |
| 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 _as_1d(u: Union[None, float, Array]) -> Optional[Array]: |
| if u is None: |
| return None |
| if np.isscalar(u): |
| return np.asarray([float(u)], dtype=float) |
| u = np.asarray(u, dtype=float) |
| if u.ndim != 1: |
| raise ValueError(f"Expected 1D forcing array, got shape {u.shape}") |
| return u |
|
|
|
|
| def _build_time_grid(T: int, dt: float) -> Array: |
| return np.arange(int(T), dtype=float) * float(dt) |
|
|
|
|
| def _interp_u(u_t: Array, t_grid: Array, tcur: float) -> float: |
| """ |
| Forcing evaluated at arbitrary time using linear interpolation. |
| Uses endpoint values outside the grid. |
| """ |
| if u_t.size == 1: |
| return float(u_t[0]) |
| return float(np.interp(tcur, t_grid, u_t, left=u_t[0], right=u_t[-1])) |
|
|
|
|
| def _kappa_vec(kappa: Union[float, Sequence[float], Array], axis: Optional[int] = None) -> Array: |
| """ |
| Convert kappa specification into a length-3 vector for Lorenz63. |
| - If axis is provided, interpret kappa as scalar magnitude applied to that axis. |
| - Else if kappa is scalar -> applied to x-axis by default. |
| - Else if kappa has length 3 -> used directly. |
| """ |
| if axis is not None: |
| kv = np.zeros(3, dtype=float) |
| kv[int(axis)] = float(kappa) |
| return kv |
|
|
| if np.isscalar(kappa): |
| |
| return np.array([float(kappa), 0.0, 0.0], dtype=float) |
|
|
| kv = np.asarray(kappa, dtype=float).reshape(-1) |
| if kv.size != 3: |
| raise ValueError("kappa must be scalar, length-3, or scalar+axis") |
| return kv |
|
|
|
|
| |
| |
| |
| def forcing_sine(T: int, dt: float, *, amp: float = 1.0, freq_hz: float = 0.1, phase: float = 0.0, bias: float = 0.0) -> Array: |
| """ |
| u(t) = bias + amp * sin(2*pi*freq_hz*t + phase) |
| Returns u_t of length T aligned with sample times n*dt. |
| """ |
| t = _build_time_grid(T, dt) |
| return (bias + amp * np.sin(2.0 * np.pi * float(freq_hz) * t + float(phase))).astype(float) |
|
|
|
|
| def forcing_piecewise_constant(T: int, *, values: Sequence[float], lengths: Sequence[int]) -> Array: |
| """ |
| Build a piecewise-constant forcing u_t of length T. |
| Example: values=[0,1,0.5], lengths=[2000,3000,5000] |
| """ |
| vals = list(values) |
| lens = list(map(int, lengths)) |
| if len(vals) != len(lens): |
| raise ValueError("values and lengths must have same length") |
| u = np.concatenate([np.full(L, v, dtype=float) for v, L in zip(vals, lens)], axis=0) |
| if u.size < T: |
| u = np.pad(u, (0, T - u.size), mode="edge") |
| return u[:T] |
|
|
|
|
| |
| |
| |
| def _lorenz63_step_rk4( |
| x: Array, |
| t: float, |
| dt: float, |
| *, |
| sigma_fn: Callable[[float], float], |
| rho_fn: Callable[[float], float], |
| beta_fn: Callable[[float], float], |
| force_fn: Callable[[float], Array], |
| ) -> Array: |
| """ |
| One RK4 step for x' = f(t,x) with Lorenz63 + forcing. |
| """ |
| x = np.asarray(x, dtype=float) |
|
|
| def f(tcur: float, s: Array) -> Array: |
| sigma = float(sigma_fn(tcur)) |
| rho = float(rho_fn(tcur)) |
| beta = float(beta_fn(tcur)) |
|
|
| fx = np.empty(3, dtype=float) |
| fx[0] = sigma * (s[1] - s[0]) |
| fx[1] = s[0] * (rho - s[2]) - s[1] |
| fx[2] = s[0] * s[1] - beta * s[2] |
| fx += force_fn(tcur) |
| return fx |
|
|
| 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) |
| return x + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4) |
|
|
|
|
| def _simulate_lorenz63_nonauto( |
| T: int, |
| dt: float, |
| *, |
| x0: Array, |
| sigma_fn: Callable[[float], float], |
| rho_fn: Callable[[float], float], |
| beta_fn: Callable[[float], float], |
| force_fn: Callable[[float], Array], |
| ) -> Array: |
| T = int(T) |
| dt = float(dt) |
| x = np.asarray(x0, dtype=float).reshape(3).copy() |
| traj = np.zeros((T, 3), dtype=float) |
| t = 0.0 |
| for n in range(T): |
| traj[n] = x |
| x = _lorenz63_step_rk4( |
| x, t, dt, |
| sigma_fn=sigma_fn, |
| rho_fn=rho_fn, |
| beta_fn=beta_fn, |
| force_fn=force_fn, |
| ) |
| t += dt |
| return traj |
|
|
|
|
| |
| |
| |
| def lorenz63_forced_additive( |
| T: int, |
| dt: float = 0.01, |
| *, |
| sigma: float = 10.0, |
| rho: float = 28.0, |
| beta: float = 8.0 / 3.0, |
| u_t: Optional[Array] = None, |
| u_fn: Optional[Callable[[float], float]] = None, |
| kappa: Union[float, Sequence[float], Array] = 1.0, |
| axis: Optional[int] = 0, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| return_u: bool = False, |
| ) -> Union[Array, Tuple[Array, Array]]: |
| """ |
| Additive forcing applied to dx/dt,dy/dt,dz/dt. |
| |
| You may provide either: |
| - u_t: array of length T (sampled at n*dt), or |
| - u_fn: callable u(t) |
| |
| kappa: |
| - scalar + axis (default) => applies to one coordinate derivative |
| - length-3 vector => applies to all derivatives |
| - scalar with axis=None => defaults to x-axis |
| |
| Returns: |
| traj : (T-burn_in, 3) |
| and optionally u_used : (T,) if return_u=True |
| """ |
| 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) |
|
|
| kv = _kappa_vec(kappa, axis=axis) |
|
|
| |
| if u_fn is None: |
| ut = _as_1d(u_t) |
| if ut is None: |
| ut = np.zeros(int(T), dtype=float) |
| |
| if ut.size != int(T): |
| if ut.size < int(T): |
| ut = np.pad(ut, (0, int(T) - ut.size), mode="edge") |
| ut = ut[: int(T)] |
| t_grid = _build_time_grid(T, dt) |
|
|
| def u_eval(tcur: float) -> float: |
| return _interp_u(ut, t_grid, tcur) |
|
|
| u_used = ut |
| else: |
| def u_eval(tcur: float) -> float: |
| return float(u_fn(tcur)) |
|
|
| |
| t_grid = _build_time_grid(T, dt) |
| u_used = np.array([u_eval(ti) for ti in t_grid], dtype=float) |
|
|
| def sigma_fn(_t: float) -> float: |
| return float(sigma) |
|
|
| def rho_fn(_t: float) -> float: |
| return float(rho) |
|
|
| def beta_fn(_t: float) -> float: |
| return float(beta) |
|
|
| def force_fn(tcur: float) -> Array: |
| return kv * u_eval(tcur) |
|
|
| traj = _simulate_lorenz63_nonauto( |
| T=T, dt=dt, x0=x0, |
| sigma_fn=sigma_fn, rho_fn=rho_fn, beta_fn=beta_fn, |
| force_fn=force_fn |
| ) |
| traj = _discard_burn_in(traj, burn_in) |
| u_used2 = _discard_burn_in(u_used[:, None], burn_in).reshape(-1) |
| return (traj, u_used2) if return_u else traj |
|
|
|
|
| |
| |
| |
| def lorenz63_param_drift( |
| T: int, |
| dt: float = 0.01, |
| *, |
| sigma: float = 10.0, |
| rho0: float = 28.0, |
| beta: float = 8.0 / 3.0, |
| |
| rho_t: Optional[Array] = None, |
| rho_fn: Optional[Callable[[float], float]] = None, |
| u_t: Optional[Array] = None, |
| u_fn: Optional[Callable[[float], float]] = None, |
| kappa_rho: float = 1.0, |
| |
| kappa_force: Union[float, Sequence[float], Array] = 0.0, |
| axis_force: Optional[int] = 0, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| return_rho: bool = False, |
| ) -> Union[Array, Tuple[Array, Array]]: |
| """ |
| Nonstationary Lorenz63 where rho(t) drifts. |
| |
| Priority for defining rho(t): |
| 1) rho_fn(t) |
| 2) rho_t sampled at n*dt |
| 3) rho0 + kappa_rho * u(t) (u from u_fn or u_t; defaults to 0) |
| |
| You can also add *simultaneous additive forcing* via kappa_force/axis_force. |
| |
| Returns: |
| traj : (T-burn_in, 3) |
| and optionally rho_used : (T-burn_in,) if return_rho=True |
| """ |
| 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) |
|
|
| |
| ut = _as_1d(u_t) |
| if ut is None: |
| ut = np.zeros(int(T), dtype=float) |
| if ut.size != int(T): |
| if ut.size < int(T): |
| ut = np.pad(ut, (0, int(T) - ut.size), mode="edge") |
| ut = ut[: int(T)] |
| t_grid = _build_time_grid(T, dt) |
|
|
| if u_fn is None: |
| def u_eval(tcur: float) -> float: |
| return _interp_u(ut, t_grid, tcur) |
| u_used = ut |
| else: |
| def u_eval(tcur: float) -> float: |
| return float(u_fn(tcur)) |
| u_used = np.array([u_eval(ti) for ti in t_grid], dtype=float) |
|
|
| |
| if rho_fn is not None: |
| def rho_eval(tcur: float) -> float: |
| return float(rho_fn(tcur)) |
| rho_used = np.array([rho_eval(ti) for ti in t_grid], dtype=float) |
| elif rho_t is not None: |
| rt = _as_1d(rho_t) |
| if rt is None: |
| raise ValueError("rho_t must be a 1D array if provided") |
| if rt.size != int(T): |
| if rt.size < int(T): |
| rt = np.pad(rt, (0, int(T) - rt.size), mode="edge") |
| rt = rt[: int(T)] |
|
|
| def rho_eval(tcur: float) -> float: |
| return float(np.interp(tcur, t_grid, rt, left=rt[0], right=rt[-1])) |
| rho_used = rt |
| else: |
| def rho_eval(tcur: float) -> float: |
| return float(rho0 + kappa_rho * u_eval(tcur)) |
| rho_used = np.array([rho_eval(ti) for ti in t_grid], dtype=float) |
|
|
| kv_force = _kappa_vec(kappa_force, axis=axis_force) |
|
|
| def sigma_fn(_t: float) -> float: |
| return float(sigma) |
|
|
| def rho_fn2(tcur: float) -> float: |
| return float(rho_eval(tcur)) |
|
|
| def beta_fn(_t: float) -> float: |
| return float(beta) |
|
|
| def force_fn(tcur: float) -> Array: |
| |
| return kv_force * u_eval(tcur) if np.any(kv_force != 0.0) else np.zeros(3, dtype=float) |
|
|
| traj = _simulate_lorenz63_nonauto( |
| T=T, dt=dt, x0=x0, |
| sigma_fn=sigma_fn, rho_fn=rho_fn2, beta_fn=beta_fn, |
| force_fn=force_fn |
| ) |
|
|
| traj = _discard_burn_in(traj, burn_in) |
| rho_used2 = _discard_burn_in(rho_used[:, None], burn_in).reshape(-1) |
| return (traj, rho_used2) if return_rho else traj |
|
|
|
|
| |
| |
| |
| @dataclass(frozen=True) |
| class Lorenz63Params: |
| sigma: float = 10.0 |
| rho: float = 28.0 |
| beta: float = 8.0 / 3.0 |
|
|
|
|
| def lorenz63_regime_switch( |
| T: int, |
| dt: float = 0.01, |
| *, |
| |
| schedule: Sequence[Tuple[int, Lorenz63Params]] = ((0, Lorenz63Params()),), |
| |
| u_t: Optional[Array] = None, |
| u_fn: Optional[Callable[[float], float]] = None, |
| kappa: Union[float, Sequence[float], Array] = 0.0, |
| axis: Optional[int] = 0, |
| x0: Optional[Array] = None, |
| seed: Optional[int] = 0, |
| burn_in: int = 0, |
| return_params: bool = False, |
| ) -> Union[Array, Tuple[Array, Dict[str, Array]]]: |
| """ |
| Piecewise-constant parameter switching. `schedule` is a list of |
| (start_step, Lorenz63Params). Example: |
| |
| schedule = [ |
| (0, Lorenz63Params(rho=28.0)), |
| (8000, Lorenz63Params(rho=35.0)), |
| (14000,Lorenz63Params(rho=25.0)), |
| ] |
| |
| Regime selection at time t uses idx = floor(t/dt), then picks the last |
| schedule entry with start_step <= idx. |
| |
| Returns: |
| traj : (T-burn_in, 3) |
| and optionally a dict with sigma_t, rho_t, beta_t arrays (aligned). |
| """ |
| 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) |
|
|
| |
| T = int(T) |
| sigma_t = np.empty(T, dtype=float) |
| rho_t = np.empty(T, dtype=float) |
| beta_t = np.empty(T, dtype=float) |
|
|
| sched = sorted([(int(s), p) for s, p in schedule], key=lambda z: z[0]) |
| if not sched or sched[0][0] != 0: |
| raise ValueError("schedule must start at step 0") |
|
|
| |
| for i, (s0, p0) in enumerate(sched): |
| s1 = sched[i + 1][0] if i + 1 < len(sched) else T |
| s0 = max(0, min(T, s0)) |
| s1 = max(s0, min(T, s1)) |
| sigma_t[s0:s1] = float(p0.sigma) |
| rho_t[s0:s1] = float(p0.rho) |
| beta_t[s0:s1] = float(p0.beta) |
|
|
| |
| kv = _kappa_vec(kappa, axis=axis) |
|
|
| if u_fn is None: |
| ut = _as_1d(u_t) |
| if ut is None: |
| ut = np.zeros(T, dtype=float) |
| if ut.size != T: |
| if ut.size < T: |
| ut = np.pad(ut, (0, T - ut.size), mode="edge") |
| ut = ut[:T] |
| t_grid = _build_time_grid(T, dt) |
|
|
| def u_eval(tcur: float) -> float: |
| return _interp_u(ut, t_grid, tcur) |
|
|
| u_used = ut |
| else: |
| t_grid = _build_time_grid(T, dt) |
|
|
| def u_eval(tcur: float) -> float: |
| return float(u_fn(tcur)) |
|
|
| u_used = np.array([u_eval(ti) for ti in t_grid], dtype=float) |
|
|
| |
| def _param_at(arr: Array, tcur: float) -> float: |
| idx = int(tcur / float(dt)) |
| if idx < 0: |
| idx = 0 |
| elif idx >= T: |
| idx = T - 1 |
| return float(arr[idx]) |
|
|
| def sigma_fn(tcur: float) -> float: |
| return _param_at(sigma_t, tcur) |
|
|
| def rho_fn(tcur: float) -> float: |
| return _param_at(rho_t, tcur) |
|
|
| def beta_fn(tcur: float) -> float: |
| return _param_at(beta_t, tcur) |
|
|
| def force_fn(tcur: float) -> Array: |
| if np.all(kv == 0.0): |
| return np.zeros(3, dtype=float) |
| return kv * u_eval(tcur) |
|
|
| traj = _simulate_lorenz63_nonauto( |
| T=T, dt=dt, x0=x0, |
| sigma_fn=sigma_fn, rho_fn=rho_fn, beta_fn=beta_fn, |
| force_fn=force_fn |
| ) |
|
|
| traj = _discard_burn_in(traj, burn_in) |
| if not return_params: |
| return traj |
|
|
| |
| sigma_used = _discard_burn_in(sigma_t[:, None], burn_in).reshape(-1) |
| rho_used = _discard_burn_in(rho_t[:, None], burn_in).reshape(-1) |
| beta_used = _discard_burn_in(beta_t[:, None], burn_in).reshape(-1) |
| u_used2 = _discard_burn_in(u_used[:, None], burn_in).reshape(-1) |
|
|
| info = { |
| "sigma_t": sigma_used, |
| "rho_t": rho_used, |
| "beta_t": beta_used, |
| "u_t": u_used2, |
| } |
| return traj, info |
|
|