dima / forcing.py
sparsetrace's picture
Upload 4 files
bf80929 verified
Raw
History Blame Contribute Delete
16.3 kB
# forcing.py
"""
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
# -----------------------------
# small utils
# -----------------------------
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) # type: ignore[arg-type]
return kv
if np.isscalar(kappa):
# default: force x-equation
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
# -----------------------------
# forcing signal helpers (optional)
# -----------------------------
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]
# -----------------------------
# core integrator: nonautonomous RK4 for Lorenz63
# -----------------------------
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], # returns (3,) additive term to dx/dt
) -> 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
# -----------------------------
# 1) Additive forcing: x' = f(x) + kappa * u(t)
# -----------------------------
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)
# build forcing evaluator
if u_fn is None:
ut = _as_1d(u_t)
if ut is None:
ut = np.zeros(int(T), dtype=float)
# ensure length T
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))
# produce a sampled u for logging/return
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) # align
return (traj, u_used2) if return_u else traj
# -----------------------------
# 2) Parameter drift: rho(t) varies (optionally driven by u(t))
# -----------------------------
def lorenz63_param_drift(
T: int,
dt: float = 0.01,
*,
sigma: float = 10.0,
rho0: float = 28.0,
beta: float = 8.0 / 3.0,
# specify rho(t) either directly or via forcing
rho_t: Optional[Array] = None, # length T at n*dt
rho_fn: Optional[Callable[[float], float]] = None,
u_t: Optional[Array] = None, # used only if rho_t/rho_fn not provided
u_fn: Optional[Callable[[float], float]] = None,
kappa_rho: float = 1.0, # rho(t) = rho0 + kappa_rho * u(t)
# optional additive forcing simultaneously (set to 0 to disable)
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)
# build u evaluator (only if needed)
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)
# build rho evaluator
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:
# optional additive forcing in dynamics
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
# -----------------------------
# 3) Regime switching: piecewise-constant params (sigma,rho,beta)
# -----------------------------
@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,
*,
# a schedule of regimes: (start_step, params)
schedule: Sequence[Tuple[int, Lorenz63Params]] = ((0, Lorenz63Params()),),
# optional additive forcing simultaneously
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)
# build regime arrays (per step)
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")
# fill by segments
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)
# forcing evaluator (optional)
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)
# parameter evaluators: stepwise constant
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
# align parameter arrays with burn_in
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