Source code for dynax.linearize

"""Functions related to input-output linearization of nonlinear systems."""

from collections.abc import Callable
from functools import partial
from typing import Literal, Optional, Sequence

import jax
import jax.numpy as jnp
import numpy as np
import optimistix as optx

from .custom_types import Array, ArrayLike, FloatScalarLike
from .derivative import lie_derivative
from .system import (
    _CoupledSystemMixin,
    AbstractControlAffine,
    AbstractSystem,
    DynamicStateFeedbackSystem,
    LinearSystem,
    static_field,
)


[docs] def relative_degree( sys: AbstractControlAffine, xs: ArrayLike, output: Optional[int] = None ) -> int: """Estimate the relative degree of a SISO control-affine system. Tests that the Lie derivatives of the output are zero exactly up but not including to the relative-degree'th order for each state in `xs`. Args: sys: Continous time control-affine system with well defined relative degree and single input and output. xs: Samples of the state space stacked along the first axis. output: Optional index of the output if `sys` has multiple outputs. Returns: Estimated relative degree of the system. """ xs = jnp.asarray(xs) if sys.n_inputs not in ["scalar", 1]: raise ValueError("System must be single input.") if output is None: # Make sure system has single output if sys.n_outputs not in ["scalar", 1]: raise ValueError(f"Output is None, but system has {sys.n_outputs} outputs.") h = sys.h else: h = lambda *args, **kwargs: sys.h(*args, **kwargs)[output] max_reldeg = jnp.size(sys.initial_state) for n in range(0, max_reldeg + 1): if n == 0: res = jax.vmap(sys.i)(xs) else: LgLfn1h = lie_derivative(sys.g, lie_derivative(sys.f, h, n - 1)) res = jax.vmap(LgLfn1h)(xs) if np.all(res == 0.0): continue elif np.all(res != 0.0): return n raise RuntimeError("sys has ill-defined relative degree.")
# TODO: remove?
[docs] def is_controllable(A, B) -> bool: """Test controllability of linear system.""" n = A.shape[0] contrmat = np.hstack([np.linalg.matrix_power(A, ni).dot(B) for ni in range(n)]) return np.linalg.matrix_rank(contrmat) == n
# TODO: Adapt to general nonlinear reference system.
[docs] def input_output_linearize( sys: AbstractControlAffine, reldeg: int, ref: LinearSystem, output: Optional[int] = None, asymptotic: Optional[Sequence] = None, reg: Optional[float] = None, ) -> Callable[[Array, Array, Array | float], Array]: """Construct an input-output linearizing feedback law. Args: sys: Continous time control-affine system with well defined relative degree and single input and output. reldeg: Relative degree of `sys` and lower bound of relative degree of `ref`. ref: Linear target system with single input and output. output: Optional index of the output if `sys` has multiple outputs. asymptotic: If `None`, compute the exactly linearizing law. Otherwise, compute an asymptotically linearizing law. Then `asymptotic` is interpreted as the sequence of length `reldeg` of coefficients of the characteristic polynomial of the tracking error system. reg: Regularization parameter that controls the linearization effort. Only effective if asymptotic is not `None`. Returns: Feedback law `u = u(x, z, v)` that input-output linearizes the system. """ assert sys.n_inputs == ref.n_inputs, "systems have same input dimension" assert sys.n_inputs in [1, "scalar"] if output is None: assert sys.n_outputs == ref.n_outputs, "systems must have same output dimension" assert sys.n_outputs in [1, "scalar"] h = sys.h A, b, c = ref.A, ref.B, ref.C else: h = lambda x, t=None: sys.h(x)[output] A, b, c = ref.A, ref.B, ref.C[output] Lfnh = lie_derivative(sys.f, h, reldeg) LgLfnm1h = lie_derivative(sys.g, lie_derivative(sys.f, h, reldeg - 1)) cAn = c.dot(np.linalg.matrix_power(A, reldeg)) cAnm1b = c.dot(np.linalg.matrix_power(A, reldeg - 1)).dot(b) if asymptotic is None: def feedbacklaw(x: Array, z: Array, v: Array | float) -> Array: y_reldeg_ref = cAn.dot(z) + cAnm1b * v y_reldeg = Lfnh(x) out = (y_reldeg_ref - y_reldeg) / LgLfnm1h(x) return out if sys.n_inputs != "scalar" else out.squeeze() else: if len(asymptotic) != reldeg: raise ValueError( f"asymptotic must be of length {reldeg=} but, {len(asymptotic)=}" ) coeffs = np.concatenate(([1], asymptotic)) if not np.all(np.real(np.roots(coeffs)) <= 0): raise ValueError("Polynomial must be Hurwitz") alphas = asymptotic cAis = [c.dot(np.linalg.matrix_power(A, i)) for i in range(reldeg)] Lfihs = [lie_derivative(sys.f, h, i) for i in range(reldeg)] def feedbacklaw(x: Array, z: Array, v: Array | float) -> Array: y_reldeg_ref = cAn.dot(z) + cAnm1b * v y_reldeg = Lfnh(x) ae0s = jnp.array( [ ai * (cAi.dot(z) - Lfih(x)) for ai, Lfih, cAi in zip(alphas, Lfihs, cAis, strict=True) ] ) error = y_reldeg_ref - y_reldeg + jnp.sum(ae0s) if reg is None: out = error / LgLfnm1h(x) else: l = LgLfnm1h(x) out = error * l / (l + reg) return out if sys.n_inputs != "scalar" else out.squeeze() return feedbacklaw
def _propagate( f: Callable[[Array, Array | float], Array], n: int, x: Array, u: Array | float ) -> Array: # Propagates system for n <= discrete_relative_degree(sys) steps.""" def fun(x, _): return f(x, u), None xn, _ = jax.lax.scan(fun, x, jnp.arange(n)) return xn
[docs] def discrete_relative_degree( sys: AbstractSystem, xs: ArrayLike, us: ArrayLike, output: Optional[int] = None, ): """Estimate the relative degree of a SISO discrete-time system. Tests that exactly the first relative-degree - 1 output samples are independent of the input for each `(x, u)` for the initial state and input samples `(xs, us)`. In this way, the discrete relative-degree can be interpreted as a system delay. Args: sys: Discrete-time dynamical system with well defined relative degree and single input and output. xs: Initial state samples stacked along the first axis. us: Initial input samples stacked along the first axis. output: Optional index of the output if the system has multiple outputs. Returns: The discrete-time relative degree of the system. See :cite:p:`leeLinearizationNonlinearControl2022{def 7.7.}`. """ xs = jnp.asarray(xs) us = jnp.asarray(us) if sys.n_inputs not in ["scalar", 1]: raise ValueError("System must be single input.") if output is None: # Make sure system has single output if sys.n_outputs not in ["scalar", 1]: raise ValueError(f"Output is None, but system has {sys.n_outputs} outputs.") h = sys.output else: h = lambda *args, **kwargs: sys.output(*args, **kwargs)[output] _vf = sys.vector_field f: Callable[[Array, Array | float], Array] = lambda x, u: _vf(x, jnp.asarray(u)) y = lambda n, x, u: h(_propagate(f, n, x, u), u) y_depends_u = jax.grad(y, 2) max_reldeg = jnp.size(sys.initial_state) for n in range(0, max_reldeg + 1): res = jax.vmap(partial(y_depends_u, n))(xs, us) if np.all(res == 0): continue elif np.all(res != 0): return n raise RuntimeError("sys has ill defined relative degree.")
[docs] def discrete_input_output_linearize( sys: AbstractSystem, reldeg: int, ref: AbstractSystem, output: Optional[int] = None, solver: Optional[optx.AbstractRootFinder] = None, ) -> Callable[[Array, Array, float, float], float]: """Construct the input-output linearizing feedback for a discrete-time system. This is similar to model-predictive control with a horizon of a single time step and without constraints. The reference system can be nonlinear, in which case the feedback law implements an exact tracking controller. Args: sys: Discrete-time dynamical system with well defined relative degree and single input and output. reldeg: Relative degree of `sys` and lower bound of relative degree of `ref`. ref: Discrete-time reference system. output: Optional index of the output if the `sys` has multiple outputs. solver: Root finding algorithm to solve the feedback law. Defaults to :py:class:`optimistix.Newton` with absolute and relative tolerance `1e-6`. Returns: Feedback law :math:`u_n = u(x_n, z_n, v_n, u_{n-1})` that input-output linearizes the system. See :cite:p:`leeLinearizationNonlinearControl2022{def 7.4.}`. """ f: Callable[[Array, Array | float], Array] = lambda x, u: sys.vector_field( x, jnp.asarray(u) ) h = sys.output if sys.n_inputs not in ["scalar", 1] or ref.n_inputs not in ["scalar", 1]: raise ValueError("Systems must have single input.") if output is None: if not (sys.n_outputs == ref.n_outputs and sys.n_outputs in ["scalar", 1]): raise ValueError("Systems must be single output and `output` is None.") _output = lambda x: x else: _output = lambda x: x[output] if solver is None: solver = optx.Newton(rtol=1e-6, atol=1e-6) def y_reldeg_ref(z, v): if isinstance(ref, LinearSystem): # A little faster for the linear case (if this is not optimized by jit) A, b, c = ref.A, ref.B, ref.C A_reldeg = c.dot(np.linalg.matrix_power(A, reldeg)) B_reldeg = c.dot(np.linalg.matrix_power(A, reldeg - 1)).dot(b) return _output(A_reldeg.dot(z) + B_reldeg.dot(v)) else: _ref_f: Callable[[Array, Array | float], Array] = lambda x, u: ( ref.vector_field(x, jnp.asarray(u)) ) _output(ref.output(_propagate(_ref_f, reldeg, z, v))) def feedbacklaw(x: Array, z: Array, v: float, u_prev: float) -> float: def fn(u, _): return ( _output(h(_propagate(f, reldeg, x, u))) - y_reldeg_ref(z, v) ).squeeze() u = optx.root_find(fn, solver, u_prev).value return u return feedbacklaw
[docs] class DiscreteLinearizingSystem(AbstractSystem, _CoupledSystemMixin): r"""Coupled discrete-time system of dynamics, reference and linearizing feedback. .. math:: x_{n+1} &= f^{sys}(x_n, v_n) \\ z_{n+1} &= f^{ref}(z_n, u_n) \\ y_n &= v_n = v(x_n, z_n, u_n) where :math:`v` is such that :math:`y_n^{sys} = h^{sys}(x_n, u_n)` equals :math:`y^{ref}_n = h^{ref}(z_n, u_n)`. Args: sys: Discrete-time dynamical system with well defined relative degree and single input and output. ref: Discrete-time reference system. reldeg: Discrete relative degree of `sys` and lower bound of discrete relative degree of `ref`. fb_kwargs: Additional keyword arguments passed to :py:func:`discrete_input_output_linearize`. """ _v: Callable initial_state: Array = static_field(init=False) n_inputs: int | Literal["scalar"] = static_field(init=False) def __init__( self, sys: AbstractSystem, ref: AbstractSystem, reldeg: int, **fb_kwargs, ): if sys.n_inputs != "scalar": raise ValueError("Only single input systems supported.") self._sys1 = sys self._sys2 = ref self.initial_state = jnp.append( self._pack_states( jnp.asarray(self._sys1.initial_state), jnp.asarray(self._sys2.initial_state), ), 0.0, ) self.n_inputs = "scalar" self._v = discrete_input_output_linearize(sys, reldeg, ref, **fb_kwargs)
[docs] def vector_field(self, x, u=None, t=None): (x, z), v_last = self._unpack_states(x[:-1]), x[-1] v = self._v(x, z, u, v_last) xn = self._sys1.vector_field(x, v) zn = self._sys2.vector_field(z, u) return jnp.append(self._pack_states(xn, zn), v)
[docs] def output(self, x, u=None, t=None): (x, z), v_last = self._unpack_states(x[:-1]), x[-1] v = self._v(x, z, u, v_last) # NOTE: feedback law is computed twice return v
[docs] class LinearizingSystem(DynamicStateFeedbackSystem): r"""Coupled ODE of nonlinear dynamics, linear reference and linearizing feedback. .. math:: ẋ &= f(x) + g(x)v \\ ż &= Az + Bu \\ y &= v = v(x, z, u) where :math:`v` is such that :math:`y^{sys} = h(x) + i(x)v` equals :math:`y^{ref} = Cz + Du`. Args: sys: Continous time control-affine system with well defined relative degree and single input and output. ref: Linear target system with single input and output. reldeg: Relative degree of `sys` and lower bound of relative degree of `ref`. fb_kwargs: Additional keyword arguments passed to :py:func:`input_output_linearize`. """ def __init__( self, sys: AbstractControlAffine, ref: LinearSystem, reldeg: int, **fb_kwargs, ): v = input_output_linearize(sys, reldeg, ref, **fb_kwargs) super().__init__(sys, ref, v)
[docs] def output( self, x: Array, u: Array | None = None, t: FloatScalarLike | None = None ) -> Array: x, z = self._unpack_states(x) v = self._v(x, z, u if u is not None else jnp.zeros(())) return v