"""Module A: ODE simulation and trajectory data collection. Implements inverted pendulum dynamics and persistent excitation signal generation for data-driven adaptive optimal control. """ import numpy as np from scipy.integrate import solve_ivp class DataCollector: """Inverted pendulum simulator with data collection capabilities. The pendulum dynamics are given by Eq. (27): dx₁/dt = x₂ dx₂/dt = (mgl/J)·sin(x₁) + (1/J)·u """ def __init__(self, J: float = 1.0, mgl: float = 1.0, n_freqs: int = 100): """ Args: J: Moment of inertia (default 1.0). mgl: Mass × gravity × length product (default 1.0). n_freqs: Number of frequency components in PE signal. """ self.J = float(J) self.mgl = float(mgl) self.n_freqs = n_freqs # Pre-compute PE signal parameters self._rng = np.random.RandomState(42) self._omegas = self._rng.uniform(0.5, 50.0, size=n_freqs) self._a = self._rng.uniform(-1.0, 1.0, size=n_freqs) self._b = self._rng.uniform(-1.0, 1.0, size=n_freqs) # Scale amplitudes so that max |pe_signal| ≈ 0.1 raw_max = np.sum(np.abs(self._a) + np.abs(self._b)) self._scale = 0.1 / raw_max if raw_max > 0 else 1.0 def pe_signal(self, t): """Persistent excitation signal. u_e(t) = scale · Σ (a_k·sin(ω_k·t) + b_k·cos(ω_k·t)) Args: t: Time value (scalar). Returns: float: PE signal value at time t. """ t = float(t) signals = self._a * np.sin(self._omegas * t) + self._b * np.cos(self._omegas * t) return self._scale * np.sum(signals) def f(self, x): """Drift dynamics f(x) — Eq. (27). f(x) = [x₂, (mgl/J)·sin(x₁)]ᵀ Args: x: State vector (2,) or (N, 2). Returns: np.ndarray: Drift dynamics values. """ x = np.asarray(x, dtype=np.float64) if x.ndim == 1: return np.array([x[1], (self.mgl / self.J) * np.sin(x[0])]) return np.column_stack([x[:, 1], (self.mgl / self.J) * np.sin(x[:, 0])]) def g(self, x): """Input matrix g(x) — Eq. (27). g(x) = [0, 1/J]ᵀ (constant) Args: x: State vector (2,) or (N, 2) — ignored, kept for interface consistency. Returns: np.ndarray: Input matrix [0, 1/J]ᵀ. """ x = np.asarray(x, dtype=np.float64) if x.ndim == 1: return np.array([0.0, 1.0 / self.J]) N = x.shape[0] result = np.zeros((N, 2)) result[:, 1] = 1.0 / self.J return result def dynamics(self, t, x, u_func): """ODE right-hand side: ẋ = f(x) + g(x)·u(t). Args: t: Current time. x: State vector (2,). u_func: Callable u_func(t, x) → control input. Returns: np.ndarray: State derivative (2,). """ u_val = u_func(t, x) return self.f(x) + self.g(x) * u_val def collect_trajectory(self, x0, T, dt, u_func): """Collect a trajectory by simulating the system. Args: x0: Initial state (2,). T: Simulation duration. dt: Time step for output sampling. u_func: Control function u_func(t, x) → float. Returns: tuple: (t, X, U) where t: Time points (M,). X: State trajectory (M, 2). U: Control inputs (M,). """ t_eval = np.arange(0, T, dt) t_span = (0.0, T) def ode_rhs(t, x): return self.dynamics(t, x, u_func) sol = solve_ivp(ode_rhs, t_span, x0, method="RK45", t_eval=t_eval, rtol=1e-8, atol=1e-10) t = sol.t X = sol.y.T # (M, 2) U = np.array([u_func(ti, xi) for ti, xi in zip(t, X)]) return t, X, U