Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First PR to implement CLF. #31

Merged
merged 1 commit into from
Jan 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
*.pyc
compatible_clf_cbf.egg-info/*
216 changes: 216 additions & 0 deletions compatible_clf_cbf/clf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""
Certify and search Control Lyapunov function (CLF) through sum-of-squares optimization.
"""
from dataclasses import dataclass
from typing import List, Optional, Union
from typing_extensions import Self

import numpy as np
import pydrake.solvers as solvers
import pydrake.symbolic as sym

from compatible_clf_cbf.utils import (
check_array_of_polynomials,
get_polynomial_result,
new_sos_polynomial,
)


@dataclass
class ClfWoInputLimitLagrangian:
"""
The Lagrangians for proving the CLF condition for systems _without_ input limits.
"""

# The Lagrangian λ₀(x) in (1+λ₀(x))*(∂V/∂x*f(x)+κ*V(x))
dVdx_times_f: sym.Polynomial
# The array of Lagrangians λ₁(x) in λ₁(x)*∂V/∂x*g(x)
dVdx_times_g: np.ndarray
# The Lagrangian
rho_minus_V: sym.Polynomial

@classmethod
def construct(
cls,
prog: solvers.MathematicalProgram,
x_set: sym.Variables,
dVdx_times_f_degree: int,
dVdx_times_g_degrees: List[int],
rho_minus_V_degree: int,
x_equilibrium: np.ndarray,
) -> Self:
"""
Constructs the Lagrangians as SOS polynomials.
"""
dVdx_times_f, _ = new_sos_polynomial(prog, x_set, dVdx_times_f_degree)
dVdx_times_g = np.array(
[
new_sos_polynomial(prog, x_set, degree)[0]
for degree in dVdx_times_g_degrees
]
)
# We know that V(x_equilibrium) = 0 and dVdx at x_equilibrium is also
# 0. Hence by
# -(1+λ₀(x))*(∂V/∂x*f(x)+κ*V(x))−λ₁(x)*∂V/∂x*g(x)−λ₂(x)*(ρ−V(x))
# being sos, we know that λ₂(x_equilibrium) = 0.
# When x_equilibrium = 0, this means that λ₂(0) = 0. Since λ₂(x) is
# sos, we know that its monomial basis doesn't contain 1.
if rho_minus_V_degree == 0:
rho_minus_V = sym.Polynomial()
else:
if np.all(x_equilibrium == 0):
monomial_basis = sym.MonomialBasis(
x_set, int(np.floor(rho_minus_V_degree / 2))
)
assert monomial_basis[-1].total_degree() == 0
rho_minus_V, _ = prog.NewSosPolynomial(monomial_basis[:-1])
else:
rho_minus_V, _ = prog.NewSosPolynomial(x_set, rho_minus_V_degree)
return ClfWoInputLimitLagrangian(dVdx_times_f, dVdx_times_g, rho_minus_V)

def get_result(
self,
result: solvers.MathematicalProgramResult,
coefficient_tol: Optional[float],
) -> Self:
dVdx_times_f_result = get_polynomial_result(
result, self.dVdx_times_f, coefficient_tol
)
dVdx_times_g_result = get_polynomial_result(
result, self.dVdx_times_g, coefficient_tol
)
rho_minus_V_result = get_polynomial_result(
result, self.rho_minus_V, coefficient_tol
)
return ClfWoInputLimitLagrangian(
dVdx_times_f=dVdx_times_f_result,
dVdx_times_g=dVdx_times_g_result,
rho_minus_V=rho_minus_V_result,
)


@dataclass
class ClfWInputLimitLagrangian:
"""
The Lagrangians for proving the CLF condition for systems _with_ input limits.
"""

# The Lagrangian λ₀(x) in (1+λ₀(x))(V(x)−ρ)xᵀx
V_minus_rho: sym.Polynomial
# The Lagrangians λᵢ(x) in ∑ᵢ λᵢ(x)*(∂V/∂x*(f(x)+g(x)uᵢ)+κ*V(x))
Vdot: np.ndarray

def get_result(
self,
result: solvers.MathematicalProgramResult,
coefficient_tol: Optional[float],
) -> Self:
V_minus_rho_result: sym.Polynomial = get_polynomial_result(
result, self.V_minus_rho, coefficient_tol
)
Vdot_result: np.ndarray = get_polynomial_result(
result, self.Vdot, coefficient_tol
)
return ClfWInputLimitLagrangian(
V_minus_rho=V_minus_rho_result, Vdot=Vdot_result
)


class ControlLyapunov:
"""
For a control affine system
ẋ=f(x)+g(x)u, u∈𝒰
we certify its CLF V, together with an inner approximation of the region of
attraction (ROA) {x|V(x)≤ρ} through SOS.

If the set 𝒰 is the entire space (namely the control input is not bounded),
then V is an CLF iff the set satisfying the following conditions
∂V/∂x*f(x)+κ*V(x) > 0
∂V/∂x*g(x)=0
V(x)≤ρ
is empty.

By positivestellasatz, this means that there exists λ₀(x), λ₁(x), λ₂(x)
satisfying
-(1+λ₀(x))*(∂V/∂x*f(x)+κ*V(x))−λ₁(x)*∂V/∂x*g(x)−λ₂(x)*(ρ−V(x)) is sos.
λ₀(x), λ₁(x), λ₂(x) are sos.

If the set 𝒰 is a polytope with vertices u₁,..., uₙ, then V is an CLF iff
(V(x)-ρ)xᵀx is always non-negative on the semi-algebraic set
{x|∂V/∂x*(f(x)+g(x)uᵢ)≥ −κ*V(x), i=1,...,n}
By positivestellasatz, we have
(1+λ₀(x))(V(x)−ρ)xᵀx − ∑ᵢ λᵢ(x)*(∂V/∂x*(f(x)+g(x)uᵢ)+κ*V(x))
λ₀(x), λᵢ(x) are sos.
"""

def __init__(
self,
*,
f: np.ndarray,
g: np.ndarray,
x: np.ndarray,
x_equilibrium: np.ndarray,
u_vertices: Optional[np.ndarray] = None,
):
"""
Args:
f: np.ndarray
An array of symbolic polynomials. The dynamics is ẋ = f(x)+g(x)u.
The shape is (nx,)
g: np.ndarray
An array of symbolic polynomials. The dynamics is ẋ = f(x)+g(x)u.
The shape is (nx, nu)
x: np.ndarray
An array of symbolic variables representing the state.
The shape is (nx,)
x_equilibrium: The equilibrium state. I strongly recommend using 0
as the equilibrium state.
u_vertices: The vertices of the input constraint polytope 𝒰. Each row
is a vertex. If u_vertices=None, then the input is unconstrained.

"""
assert len(f.shape) == 1
assert len(g.shape) == 2
self.nx: int = f.shape[0]
self.nu: int = g.shape[1]
assert g.shape == (self.nx, self.nu)
assert x.shape == (self.nx,)
self.f = f
self.g = g
self.x = x
self.x_set: sym.Variables = sym.Variables(x)
check_array_of_polynomials(f, self.x_set)
check_array_of_polynomials(g, self.x_set)
assert x_equilibrium.shape == (self.nx,)
self.x_equilibrium = x_equilibrium
if u_vertices is not None:
assert u_vertices.shape[1] == self.nu
self.u_vertices = u_vertices

def _add_clf_condition(
self,
prog: solvers.MathematicalProgram,
V: sym.Polynomial,
lagrangians: Union[ClfWInputLimitLagrangian, ClfWoInputLimitLagrangian],
rho: Union[sym.Variable, float],
kappa: float,
) -> sym.Polynomial:
dVdx = V.Jacobian(self.x)
dVdx_times_f = dVdx.dot(self.f)
dVdx_times_g = dVdx.reshape((1, -1)) @ self.g
if self.u_vertices is None:
assert isinstance(lagrangians, ClfWoInputLimitLagrangian)
sos_poly = (
-(1 + lagrangians.dVdx_times_f) * (dVdx_times_f + kappa * V)
- dVdx_times_g.squeeze().dot(lagrangians.dVdx_times_g)
- lagrangians.rho_minus_V * (rho - V)
)
prog.AddSosConstraint(sos_poly)
else:
assert isinstance(lagrangians, ClfWInputLimitLagrangian)
Vdot = dVdx_times_f + dVdx_times_g @ self.u_vertices
sos_poly = (1 + lagrangians.V_minus_rho) * (V - rho) * sym.Polynomial(
self.x.dot(self.x)
) - lagrangians.Vdot.dot(Vdot + kappa * V)
prog.AddSosConstraint(sos_poly)
return sos_poly
20 changes: 20 additions & 0 deletions compatible_clf_cbf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,23 @@ def solve_with_id(
solver = solvers.MakeSolver(solver_id)
result = solver.Solve(prog, None, solver_options)
return result


def new_sos_polynomial(
prog: solvers.MathematicalProgram, x_set: sym.Variables, degree: int
) -> Tuple[sym.Polynomial, np.ndarray]:
"""
Returns a new SOS polynomial (where the coefficients are decision variables).

Return:
sos_poly: The newly constructed sos polynomial.
gram: The Gram matrix of sos_poly.
"""
if degree == 0:
coeff = prog.NewContinuousVariables(1)[0]
prog.AddBoundingBoxConstraint(0, np.inf, coeff)
sos_poly = sym.Polynomial({sym.Monomial(): sym.Expression(coeff)})
return sos_poly, np.array([[coeff]])
else:
sos_poly, gram = prog.NewSosPolynomial(x_set, degree)
return sos_poly, gram
101 changes: 101 additions & 0 deletions tests/test_clf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import compatible_clf_cbf.clf as mut

from typing import Tuple

import numpy as np
import pytest # noqa

import pydrake.solvers as solvers
import pydrake.symbolic as sym
import pydrake.systems.controllers as controllers


class TestClf:
@classmethod
def setup_class(cls):
cls.nx = 3
cls.nu = 2
cls.x = sym.MakeVectorContinuousVariable(cls.nx, "x")
cls.f = np.array(
[
sym.Polynomial(-cls.x[0]),
sym.Polynomial(cls.x[0] * cls.x[1] * cls.x[2] - cls.x[1]),
sym.Polynomial(cls.x[1] ** 3 - 2 * cls.x[0]),
]
)
cls.g = np.array(
[
[sym.Polynomial(1), sym.Polynomial(cls.x[0] ** 2)],
[sym.Polynomial(cls.x[0] * cls.x[2]), sym.Polynomial(1)],
[sym.Polynomial(cls.x[1] * cls.x[2]), sym.Polynomial(3)],
]
)

def linearize(self) -> Tuple[np.ndarray, np.ndarray]:
"""
Linearize the dynamics at x=0 and u=0.
"""
env = {self.x[i]: 0 for i in range(self.nx)}
for i in range(self.nx):
assert self.f[i].Evaluate(env) == 0
A = np.empty((self.nx, self.nx))
B = np.empty((self.nx, self.nu))
for i in range(self.nx):
for j in range(self.nx):
A[i, j] = self.f[i].Differentiate(self.x[j]).Evaluate(env)
for j in range(self.nu):
B[i, j] = self.g[i, j].Evaluate(env)
return (A, B)

def get_V_init(self) -> sym.Polynomial:
A, B = self.linearize()
K, S = controllers.LinearQuadraticRegulator(
A, B, Q=np.eye(self.nx), R=np.eye(self.nu)
)
return sym.Polynomial(self.x.dot(S @ self.x))

def test_add_clf_condition_wo_input_limit(self):
dut = mut.ControlLyapunov(
f=self.f,
g=self.g,
x=self.x,
x_equilibrium=np.zeros(self.nx),
u_vertices=None,
)
prog = solvers.MathematicalProgram()
prog.AddIndeterminates(dut.x_set)
V = self.get_V_init()

lagrangians = mut.ClfWoInputLimitLagrangian.construct(
prog,
dut.x_set,
dVdx_times_f_degree=2,
dVdx_times_g_degrees=[4, 4],
rho_minus_V_degree=4,
x_equilibrium=dut.x_equilibrium,
)
# The Lagrangian for rho-V doesn't contain constant or linear terms.
assert (
sym.Monomial()
not in lagrangians.rho_minus_V.monomial_to_coefficient_map().keys()
)
for x_i in dut.x_set:
assert (
sym.Monomial(x_i)
not in lagrangians.rho_minus_V.monomial_to_coefficient_map().keys()
)

rho = 0.1
kappa = 0.01

condition = dut._add_clf_condition(prog, V, lagrangians, rho, kappa)
dVdx = V.Jacobian(dut.x)
dVdx_times_f = dVdx.dot(dut.f)
dVdx_times_g = (dVdx @ dut.g).reshape((-1,))

condition_expected = (
-(1 + lagrangians.dVdx_times_f) * (dVdx_times_f + kappa * V)
- lagrangians.dVdx_times_g.dot(dVdx_times_g)
- lagrangians.rho_minus_V * (rho - V)
)
assert condition.EqualTo(condition_expected)
2 changes: 1 addition & 1 deletion tests/test_clf_cbf.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def setup_class(cls):

def linearize(self) -> Tuple[np.ndarray, np.ndarray]:
"""
Linearize the dynamics at x=0 at u = 0
Linearize the dynamics at x=0 and u = 0
"""
env = {self.x[i]: 0 for i in range(self.nx)}
for i in range(self.nx):
Expand Down