Source code for pycvxset.ConstrainedZonotope

# Copyright (C) 2020-2026 Mitsubishi Electric Research Laboratories (MERL)
#
# SPDX-License-Identifier: AGPL-3.0-or-later

# Code purpose:  Define the ConstrainedZonotope class

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence, cast, overload

if TYPE_CHECKING:
    from pycvxset.Polytope import Polytope
    import cvxpy

import numpy as np

from pycvxset.common import (
    _compute_project_multiple_points,
    _compute_support_function_multiple_eta,
    convex_set_closest_point,
    convex_set_distance,
    convex_set_extreme,
    convex_set_minimum_volume_circumscribing_rectangle,
    convex_set_project,
    convex_set_projection,
    convex_set_slice,
    convex_set_slice_then_projection,
    convex_set_support,
    is_constrained_zonotope,
    is_polytope,
    minimize,
    plot_polytopic_approximation,
    sanitize_Aebe,
    sanitize_Gc,
)
from pycvxset.common.constants import DEFAULT_CVXPY_ARGS_LP, DEFAULT_CVXPY_ARGS_SOCP, PYCVXSET_ZERO
from pycvxset.common.polytope_approximations import polytopic_inner_approximation, polytopic_outer_approximation
from pycvxset.ConstrainedZonotope.operations_binary import (
    DOCSTRING_FOR_PROJECT,
    DOCSTRING_FOR_PROJECTION,
    DOCSTRING_FOR_SLICE,
    DOCSTRING_FOR_SLICE_THEN_PROJECTION,
    DOCSTRING_FOR_SUPPORT,
    affine_map,
    approximate_pontryagin_difference,
    cartesian_product,
    contains,
    intersection,
    intersection_under_inverse_affine_map,
    intersection_with_affine_set,
    intersection_with_halfspaces,
    inverse_affine_map_under_invertible_matrix,
    minus,
    plus,
)
from pycvxset.ConstrainedZonotope.operations_unary import (
    chebyshev_centering,
    interior_point,
    maximum_volume_inscribing_ellipsoid,
    remove_redundancies,
)


[docs] class ConstrainedZonotope: r"""Constrained zonotope class Constrained zonotope defines a polytope in the working dimension :math:`\mathbb{R}^n` as an affine transformation of a polytope defined in latent space :math:`B_\infty(A_e, b_e)\subset \mathbb{R}^{N_C}`. Here, :math:`B_\infty(A_e, b_e)` is defined as the intersection of a unit :math:`\ell_\infty`-norm ball and a collection of :math:`M_C` linear constraints :math:`\{\xi\in\mathbb{R}^{N_C}|A_e \xi = b_e\}.` Formally, a **constrained zonotope** is defined as follows, .. math:: \mathcal{C} = \{G \xi + c\ |\ \xi \in B_\infty(A_e, b_e)\} \subset \mathbb{R}^n, where .. math:: B_\infty(A_e, b_e)= \{\xi\ |\ \| \xi \|_\infty \leq 1, A_e \xi = b_e\} \subset \mathbb{R}^{N_C}, with :math:`G\in\mathbb{R}^{n\times N_C}`, :math:`c\in\mathbb{R}^{n}`, :math:`A_e\in\mathbb{R}^{M_C\times N_C}`, and :math:`b\in\mathbb{R}^{M_C}`. A constrained zonotope provide an alternative and equivalent representation of any convex and compact polytope. Furthermore, a constrained zonotope admits closed-form expressions for several set manipulations that can often be accomplished without invoking any optimization solvers. See [SDGR16]_ [RK22]_ [VWD24]_ for more details. A **zonotope** is a special class of constrained zonotopes, and are defined as .. math:: \mathcal{Z} = \{G \xi + c\ |\ \|\xi\|_\infty \leq 1\} \subset \mathbb{R}^n. In other words, a zonotope is a constrained zonotope with no equality constraints in the latent dimension space. In :class:`ConstrainedZonotope`, we model zonotopes by having (Ae,be) be empty (n\_equalities is zero). Constrained zonotope object construction admits **one** of the following combinations (as keyword arguments): #. dim for an **empty** constrained zonotope of dimension dim, #. (G, c, Ae, be) for a **constrained zonotope**, #. (G, c) for a **zonotope**, #. (lb, ub) for a **zonotope** equivalent to an **axis-aligned cuboid** with appropriate bounds :math:`\{x\ |\ lb\leq x \leq ub\}`, and #. (c, h) for a **zonotope** equivalent to an **axis-aligned cuboid** centered at c with specified scalar/vector half-sides :math:`h`, :math:`\{x\ |\ \forall i\in\{1,2,...,n\}, |x_i - c_i| \leq h_i\}`. #. (c=p, G=None) for a **zonotope** equivalent to a **single point** p, #. P for a **constrained zonotope** equivalent to the :class:`pycvxset.Polytope.Polytope` object P, Args: dim (int, optional): Dimension of the empty constrained zonotope. If NOTHING is provided, dim=0 is assumed. c (Sequence[float] | numpy.ndarray, optional): Affine transformation translation vector. Must be 1D array, and the constrained zonotope dimension is determined by number of elements in c. When c is provided, either (G) or (G, Ae, be) or (h) must be provided additionally. When h is provided, c is the centroid of the resulting zonotope. G (Sequence[Sequence[float]] | numpy.ndarray): Affine transformation matrix. The vectors are stacked vertically with matching number of rows as c. When G is provided, (c, Ae, be) OR (c) must also be provided. To define a constrained zonotope with a single point, set c to the point AND G to None (do not set (Ae, be) or set them to (None, None)). Ae (Sequence[Sequence[float]] | numpy.ndarray): Equality coefficient vectors. The vectors are stacked vertically with matching number of columns as G. When Ae is provided, (G, c, be) must also be provided. be (Sequence[float] | numpy.ndarray): Equality coefficient constants. The constants are expected to be in a 1D numpy array. When be is provided, (G, c, Ae) must also be provided. lb (Sequence[float] | numpy.ndarray, optional): Lower bounds of the axis-aligned cuboid. Must be 1D array, and the constrained zonotope dimension is determined by number of elements in lb. When lb is provided, ub must also be provided. ub (Sequence[float] | numpy.ndarray, optional): Upper bounds of the axis-aligned cuboid. Must be 1D array of length as same as lb. When ub is provided, lb must also be provided. h (float | Sequence[float] | numpy.ndarray, optional): Half-side length of the axis-aligned cuboid. Can be a scalar or a vector of length as same as c. When h is provided, c must also be provided. polytope (Polytope, optional): Polytope to use to construct constrained zonotope. Raises: ValueError: (G, c) is not compatible. ValueError: (G, c, Ae, be) is not compatible. ValueError: (lb, ub) is not valid ValueError: (c, h) is not valid ValueError: Provided polytope is not bounded. UserWarning: When a row with all zeros in Ae and be. """ if TYPE_CHECKING: @overload def __init__(self) -> None: ... @overload def __init__(self, *, dim: int) -> None: ... @overload def __init__( self, *, G: Sequence[Sequence[float]] | np.ndarray | None, c: Sequence[float] | np.ndarray ) -> None: ... @overload def __init__( self, *, G: Sequence[Sequence[float]] | np.ndarray | None, c: Sequence[float] | np.ndarray, Ae: Sequence[Sequence[float]] | np.ndarray, be: Sequence[float] | np.ndarray, ) -> None: ... @overload def __init__(self, *, lb: Sequence[float] | np.ndarray, ub: Sequence[float] | np.ndarray) -> None: ... @overload def __init__( self, *, c: Sequence[float] | np.ndarray, h: float | Sequence[float] | np.ndarray, ) -> None: ... @overload def __init__(self, *, polytope: "Polytope") -> None: ...
[docs] def __init__(self, **kwargs: Any) -> None: """Constructor for ConstrainedZonotope class""" self._type_of_set: str = "ConstrainedZonotope" self._G: Optional[np.ndarray] = None self._c: Optional[np.ndarray] = None self._Ae: Optional[np.ndarray] = None self._be: Optional[np.ndarray] = None self._is_full_dimensional: Optional[bool] = None self._is_empty: Optional[bool] = None self._is_singleton: Optional[bool] = None # These attributes are used by CVXPY to solve problems self._cvxpy_args_lp = DEFAULT_CVXPY_ARGS_LP self._cvxpy_args_socp = DEFAULT_CVXPY_ARGS_SOCP # Check how the constructor was called. empty_constrained_zonotope_passed = ("dim" in kwargs) or not kwargs lb_and_ub_passed = all(kw in kwargs for kw in ("lb", "ub")) c_and_h_passed = all(kw in kwargs for kw in ("c", "h")) G_and_c_passed = all(k in kwargs for k in ("G", "c")) Ae_and_be_passed = all(k in kwargs for k in ("Ae", "be")) polytope_passed = "polytope" in kwargs # Set _G, _c, _Ae, _be, _is_empty, _is_full_dimensional if empty_constrained_zonotope_passed: if len(kwargs) > 1: raise ValueError("Cannot set dimension dim with other arguments") else: dim = kwargs.get("dim", 0) self._G, self._c, self._Ae, self._be = self._get_Gc_Aebe_for_empty_constrained_zonotope(dim, 0) self._is_full_dimensional, self._is_empty = (dim == 0), True elif lb_and_ub_passed or c_and_h_passed: if lb_and_ub_passed: if len(kwargs) != 2: raise ValueError("Cannot set bounds (lb, ub) with other arguments") lb, ub = kwargs.get("lb"), kwargs.get("ub") else: # We have c_and_h_passed is True if len(kwargs) != 2: raise ValueError("Cannot set up constrained zonotope from (c, h) with other arguments") try: c = np.atleast_1d(np.squeeze(kwargs.get("c"))).astype(float) h = np.squeeze(kwargs.get("h")).astype(float) except (TypeError, ValueError) as err: raise ValueError( "Expected c and h to be convertible into a numpy 1D array and scalar/1D array of " f"float respectively! Got c: {np.array2string(np.array(kwargs.get('c')))} and " f"h: {np.array2string(np.array(kwargs.get('h')))}" ) from err if c.ndim >= 2: raise ValueError( f"Expected c to be a 1-dimensional array-like object! Got c: {np.array2string(np.array(c))}." ) if h.ndim >= 2: raise ValueError( "Expected h to be a 0-dimensional or 1-dimensional array-like object " f"Got {np.array2string(np.array(h))}." ) elif h.ndim == 1: if h.shape != c.shape: raise ValueError( "Expected c and 1-dimensional h to match in dimensions. Got {c.shape} and {h.shape}" ) lb = c - h ub = c + h else: lb = c - h * np.ones_like(c) ub = c + h * np.ones_like(c) self._G, self._c, self._Ae, self._be, lb, ub = self._get_Gc_Aebe_from_bounds(lb, ub) self._is_full_dimensional = (self.dim == 1) or (np.abs(ub - lb) > PYCVXSET_ZERO).all() self._is_empty = self.c is None elif G_and_c_passed: # Either it is a zonotope (G, c) or a constrained zonotope (G, c, Ae, be) if len(kwargs) != 2 and (len(kwargs) != 4 and not Ae_and_be_passed): raise ValueError( "Cannot set zonotope (G, c) or constrained zonotope (G, c, Ae, be) with other arguments" ) G_val, c_val = sanitize_Gc(kwargs.get("G"), kwargs.get("c")) self._G, self._c = G_val, c_val if Ae_and_be_passed: sanitized_Ae, sanitized_be = sanitize_Aebe(kwargs.get("Ae"), kwargs.get("be")) if G_val.size == 0 and (sanitized_Ae is not None or sanitized_be is not None): raise ValueError("When G is None and (Ae, be) was passed, then (Ae, be) must be (None, None)!") else: if sanitized_Ae is None: self._Ae, self._be = np.empty((0, self.dim)), np.empty((0,)) else: self._Ae, self._be = sanitized_Ae, sanitized_be if self.Ae.shape[1] != self.latent_dim: # Check if (Ae, be) and (A, b) can go together? raise ValueError( f"Expected Ae to have {self.latent_dim:d} number of columns. Got Ae.shape: " f"{self.Ae.shape}!" ) # ConstrainedZonotope emptiness and full-dimensionality needs to be confirmed else: # Zonotope => Set only (Ae, be) to empty | Full-dimensionality needs to be confirmed _, _, self._Ae, self._be = self._get_Gc_Aebe_for_empty_constrained_zonotope(self.dim, self.latent_dim) self._is_empty = c_val is None if G_val.size == 0 and not self._is_empty: self._is_full_dimensional = self.dim == 1 elif polytope_passed: if len(kwargs) != 1: raise ValueError("Cannot construct constrained zonotope from polytope if other arguments are provided") polytope: Polytope | None = kwargs.get("polytope") if polytope is None or not is_polytope(polytope): raise ValueError("Expected a polytope when constructing a constrained zonotope.") self._is_full_dimensional, self._is_empty = polytope.is_full_dimensional, polytope.is_empty if not polytope.is_bounded: raise ValueError("Expected a convex and compact polytope!") elif polytope.is_empty: self._G, self._c, self._Ae, self._be = self._get_Gc_Aebe_for_empty_constrained_zonotope(polytope.dim, 0) else: if polytope.in_H_rep: # Compute zonotope Z_0 ={G \xi + c| ||\xi||_\infty \leq 1, \xi\in R^n_g} s.t. polytope <= Z_0. lb, ub = polytope.minimum_volume_circumscribing_rectangle() Z_0 = self.__class__(lb=lb, ub=ub) # sigma satisfies sigma <= H z <= k for all z \in polytope where H is polytope.A (using Scott's # notation in the paper [SDGR16]_) H = polytope.A sigma, k = -polytope.support(-H)[0], polytope.b # Implement (21) from Scott's paper (with additional equality constraints when needed) latent_dim = Z_0.dim + polytope.n_halfspaces G = np.hstack((Z_0.G, np.zeros((Z_0.dim, latent_dim - Z_0.dim)))) c = Z_0.c Ae = np.hstack((H @ Z_0.G, np.diag((sigma - k) / 2))) be = (sigma + k) / 2 - (H @ Z_0.c) if polytope.n_equalities > 0: # Define CZ and unpack relevant members CZ = self.__class__(G=G, c=c, Ae=Ae, be=be).intersection_with_affine_set( Ae=polytope.Ae, be=polytope.be ) self._G, self._c, self._Ae, self._be = CZ.G, CZ.c, CZ.Ae, CZ.be else: self._G, self._c, self._Ae, self._be = G, c, Ae, be else: if polytope.n_vertices == 1: self._G, _, self._Ae, self._be = self._get_Gc_Aebe_for_empty_constrained_zonotope( polytope.dim, 0 ) self._c = np.squeeze(polytope.V) else: # Define a ConstrainedZonotope object corresponding to the polytope.n_vertices-dimension simplex CZ_simplex = self.__class__( lb=np.zeros((polytope.n_vertices, 1)), ub=np.ones((polytope.n_vertices, 1)) ).intersection_with_affine_set(Ae=np.ones((1, polytope.n_vertices)), be=1) # CZ of interest is the affine transformation of the simplex CZ = polytope.V.T @ CZ_simplex # Unpack relevant members self._G, self._c, self._Ae, self._be = CZ.G, CZ.c, CZ.Ae, CZ.be else: raise ValueError( "Got invalid arguments while defining a constrained zonotope. Please specify either (G, c) or " "(G, c, Ae, be) or (lb, ub) or (c, h) or polytope or dim or NOTHING." )
@property def type_of_set(self: ConstrainedZonotope) -> str: """Return the type of set Returns: str: Type of the set """ return self._type_of_set @staticmethod def _get_Gc_Aebe_for_empty_constrained_zonotope( dim: int, latent_dim: int ) -> tuple[np.ndarray, None, np.ndarray, np.ndarray]: return np.empty((dim, latent_dim)), None, np.empty((0, latent_dim)), np.empty((0,)) def _get_Gc_Aebe_from_bounds( self, lb: Sequence[float] | np.ndarray, ub: Sequence[float] | np.ndarray ) -> tuple[np.ndarray, Optional[np.ndarray], np.ndarray, np.ndarray, np.ndarray, np.ndarray]: r"""Define a zonotope from bounds (lb, ub), i.e., a zonotope that is equivalent to the polytope defined from the bounds (lb, ub). Args: lb (Sequence[float] | numpy.ndarray): Lower bound of the constrained zonotope. ub (Sequence[float] | numpy.ndarray): Upper bound of the constrained zonotope. Raises: ValueError: Mismatch in lb, ub shape ValueError: lb, ub is not convertible into 1D numpy float arrays Notes: When :math:`lb_i > ub_i` for any :math:`i`, then the zonotope is empty. Otherwise, we uses the following simple manipulations to define a zonotope from the bounds lb, ub: .. math :: newobj &= {x\ |\ lb \leq x \leq ub}\\ &= {x\ |\ - (ub - lb)/2 \leq x - (ub + lb)/2 \leq + (ub - lb)/2}\\ &= {x\ |\ - d \leq x - c \leq d}\\ &= {x\ |\ -1 \leq diag(1./d)(x - c) \leq 1}\\ &= {diag(d)z + c\ |\ -1 \leq z \leq 1} Embedded zonotopes (some dimension is held constant) also have their latent dimension equal to set dimension. """ try: lb_arr: np.ndarray = np.atleast_1d(np.squeeze(lb)).astype(float) ub_arr: np.ndarray = np.atleast_1d(np.squeeze(ub)).astype(float) except (TypeError, ValueError) as err: raise ValueError("Expected lb, ub to convertible into 1D float numpy arrays") from err if lb_arr.shape != ub_arr.shape or lb_arr.ndim != 1: raise ValueError("Expected lb, ub to 1D numpy arrays of same shape") elif np.any(ub_arr < lb_arr): return *self._get_Gc_Aebe_for_empty_constrained_zonotope(lb_arr.shape[0], 0), lb_arr, ub_arr elif np.allclose(lb_arr, ub_arr): G, _, Ae, be = self._get_Gc_Aebe_for_empty_constrained_zonotope(lb_arr.shape[0], 0) c = lb_arr return G, c, Ae, be, lb_arr, ub_arr else: dim, latent_dim = lb_arr.shape[0], lb_arr.shape[0] _, _, Ae, be = self._get_Gc_Aebe_for_empty_constrained_zonotope(dim, latent_dim) d = (ub_arr - lb_arr) / 2 G = np.diag(d) c = (lb_arr + ub_arr) / 2 return G, c, Ae, be, lb_arr, ub_arr @property def dim(self: ConstrainedZonotope) -> int: """Dimension of the constrained zonotope. Returns: int: Dimension of the constrained zonotope. Notes: We determine dimension from G, since c is set to None in case of empty (constrained) zonotope. """ return self.G.shape[0] @property def c(self: ConstrainedZonotope) -> Optional[np.ndarray]: """Affine transformation vector c for the constrained zonotope. Returns: numpy.ndarray: Affine transformation vector c. """ return self._c @property def G(self: ConstrainedZonotope) -> np.ndarray: """Affine transformation matrix G for the constrained zonotope. Returns: numpy.ndarray: Affine transformation matrix G. """ return self._G @property def latent_dim(self: ConstrainedZonotope) -> int: """Latent dimension of the constrained zonotope. Returns: int: Latent dimension of the constrained zonotope. """ return self.G.shape[1] if self.G.size > 0 else 0 @property def Ae(self: ConstrainedZonotope) -> np.ndarray: """Equality coefficient vectors Ae for the constrained zonotope. Returns: numpy.ndarray: Equality coefficient vectors Ae for the constrained zonotope. Ae is np.empty((0, self.latent_dim)) for a zonotope. """ return self._Ae @property def be(self: ConstrainedZonotope) -> np.ndarray: """Equality constants be for the constrained zonotope. Returns: numpy.ndarray: Equality constants be for the constrained zonotope. be is np.empty((0,)) for a zonotope. """ return self._be @property def He(self: ConstrainedZonotope) -> np.ndarray: r"""Equality constraints `He=[Ae, be]` for the constrained zonotope. Returns: numpy.ndarray: H-Rep in [Ae, be]. He is np.empty((0, self.latent_dim + 1)) for a zonotope. """ return np.hstack((self.Ae, np.array([self.be]).T)) @property def n_equalities(self: ConstrainedZonotope) -> int: """Number of equality constraints used when defining the constrained zonotope. Returns: int: Number of equality constraints used when defining the constrained zonotope """ return self.Ae.shape[0] @property def is_bounded(self: ConstrainedZonotope) -> bool: """Check if the constrained zonotope is bounded (which is always True)""" return True @property def is_empty(self: ConstrainedZonotope) -> bool: """Check if the constrained zonotope is empty Raises: NotImplementedError: Unable to solve the feasibility problem using CVXPY Returns: bool: When True, the polytope is empty Notes: This function may trigger a cvxpy feasibility problem if emptiness is not already known. """ if self._is_empty is None: if self.c is None: # When c is None, constrained zonotope is empty by construction. self._is_empty = True elif self.is_singleton: # is_singleton ensures that redundancies are removed, and _is_empty is populated when possible. self._is_empty = False elif self._is_empty is None: # Do feasibility check if ConstrainedZonotope's emptiness is still not known import cvxpy as cp x = cp.Variable((self.dim,)) _, feasibility_value, _ = self.minimize( x, objective_to_minimize=cp.Constant(0), cvxpy_args=self.cvxpy_args_lp, task_str="emptiness check for the constrained zonotope", ) self._is_empty = bool(feasibility_value == np.inf) else: pass return self._is_empty @property def is_full_dimensional(self: ConstrainedZonotope) -> bool: r""" Check if the affine dimension of the constrained zonotope is the same as the constrained zonotope dimension Returns: bool: True when the affine hull containing the constrained zonotope has the dimension `self.dim` Notes: An empty polytope is full dimensional if dim=0, otherwise it is not full-dimensional. See Sec. 2.1.3 of [BV04] for discussion on affine dimension. A non-empty zonotope is full-dimensional if and only if G has full row rank. A non-empty constrained zonotope is full-dimensional if and only if [G; A] has full row rank. This function may trigger a cvxpy feasibility problem if emptiness is not already known. """ if self._is_full_dimensional is None: if self.is_singleton: # is_singleton ensures that redundancies are removed, and _is_full_dimensional populated when possible. self._is_full_dimensional = self.dim == 1 else: if self.is_empty: self._is_full_dimensional = self.dim == 0 else: stacked_G_Ae = np.vstack((self.G, self.Ae)) if self.n_equalities else self.G stacked_G_Ae_is_fat_or_sqr_matrix = stacked_G_Ae.shape[0] <= stacked_G_Ae.shape[1] stacked_G_Ae_is_full_row_rank = stacked_G_Ae.shape[0] == np.linalg.matrix_rank(stacked_G_Ae) self._is_full_dimensional = stacked_G_Ae_is_fat_or_sqr_matrix and stacked_G_Ae_is_full_row_rank return cast(bool, self._is_full_dimensional) @property def is_singleton(self: ConstrainedZonotope) -> bool: """Check if the constrained zonotope is a singleton This function does not invoke any optimization solver, and relies on rank computations. A non-empty constrained zonotope is a singleton if and only if latent set is empty or a singleton. """ if self._is_singleton is None: if self.n_equalities > 0: # If latent set has equalities, remove_redundancies will detect if latent set is empty or singleton self.remove_redundancies() if self.n_equalities == 0: self._is_singleton = (self.latent_dim == 0) and (self.c is not None) else: # Equalities remain and form a non-trivial latent set self._is_singleton = False return self._is_singleton @property def is_zonotope(self: ConstrainedZonotope) -> bool: """Check if the constrained zonotope is a zonotope""" if self.is_singleton: return True else: return self.n_equalities == 0 @property def cvxpy_args_lp(self: ConstrainedZonotope) -> dict[str, Any]: """CVXPY arguments in use when solving a linear program Returns: dict: CVXPY arguments in use when solving a linear program. Defaults to dictionary in `pycvxset.common.DEFAULT_CVXPY_ARGS_LP`. """ return self._cvxpy_args_lp @cvxpy_args_lp.setter def cvxpy_args_lp(self: ConstrainedZonotope, value: dict[str, Any]) -> None: """Update CVXPY arguments in use when solving a linear program Args: value: Dictionary with new CVXPY arguments in use when solving a linear program. """ self._cvxpy_args_lp = value @property def cvxpy_args_socp(self: ConstrainedZonotope) -> dict[str, Any]: """CVXPY arguments in use when solving a second-order cone program Returns: dict: CVXPY arguments in use when solving a second-order cone program. Defaults to dictionary in `pycvxset.common.DEFAULT_CVXPY_ARGS_SOCP`. """ return self._cvxpy_args_socp @cvxpy_args_socp.setter def cvxpy_args_socp(self: ConstrainedZonotope, value: dict[str, Any]) -> None: """Update CVXPY arguments in use when solving a second-order cone program Args: value: Dictionary with new CVXPY arguments in use when solving a second-order cone program. """ self._cvxpy_args_socp = value ################################## # Plotting and polytope operations ################################## plot = plot_polytopic_approximation polytopic_inner_approximation = polytopic_inner_approximation polytopic_outer_approximation = polytopic_outer_approximation ########### # Auxiliary ###########
[docs] def containment_constraints( self, x: cvxpy.Variable, flatten_order: Literal["F", "C"] = "F" ) -> tuple[list[cvxpy.Constraint], Optional[cvxpy.Variable]]: """Get CVXPY constraints for containment of x (a cvxpy.Variable) in a constrained zonotope. Args: x (cvxpy.Variable): CVXPY variable to be optimized. flatten_order (Literal["F", "C"]): Order to use for flatten (choose between "F", "C"). Defaults to "F", which implements column-major flatten. In 2D, column-major flatten results in stacking rows horizontally to achieve a single horizontal row. Raises: ValueError: When constrained zonotope is empty Returns: tuple: A tuple with two items: #. constraint_list (list): CVXPY constraints for the containment of x in the constrained zonotope. #. xi (cvxpy.Variable | None): CVXPY variable representing the latent dimension variable. It is None, when the constrained zonotope is a single point. Notes: This function imports CVXPY locally to avoid overhead. """ import cvxpy as cp x_reshaped = cp.reshape(x, (x.size,), order=flatten_order) if self.c is None: raise ValueError("Containment constraints can not be generated for an empty constrained zonotope!") elif self.is_singleton: return [x_reshaped == self.c], None else: xi = cp.Variable((self.latent_dim,)) if self.is_zonotope: return [x_reshaped == self.G @ xi + self.c, cp.norm(xi, p="inf") <= 1], xi else: return [x_reshaped == self.G @ xi + self.c, cp.norm(xi, p="inf") <= 1, self.Ae @ xi == self.be], xi
minimize = minimize def __pow__(self: ConstrainedZonotope, power: int) -> ConstrainedZonotope: r"""Compute the Cartesian product with itself. Args: power (int): Number of times the polytope is multiplied with itself Returns: Polytope: The polytope :math:`\mathcal{R}` corresponding to P`^N`. """ if self.c is None: # When c is None, the constrained zonotope is empty by construction, and Cartesian product is also empty. return self.__class__(dim=self.dim * power) else: concatenated_G = np.kron(np.eye(power), self.G) concatenated_c = np.tile(self.c, (power,)) concatenated_Ae = np.kron(np.eye(power), self.Ae) concatenated_be = np.tile(self.be, (power,)) return self.__class__(G=concatenated_G, c=concatenated_c, Ae=concatenated_Ae, be=concatenated_be) ################## # Unary operations ##################
[docs] def copy(self: ConstrainedZonotope) -> ConstrainedZonotope: """Get a copy of the constrained zonotope""" return self.__class__(G=self.G, c=self.c, Ae=self.Ae, be=self.be)
chebyshev_centering = chebyshev_centering interior_point = interior_point maximum_volume_inscribing_ellipsoid = maximum_volume_inscribing_ellipsoid minimum_volume_circumscribing_rectangle = convex_set_minimum_volume_circumscribing_rectangle remove_redundancies = remove_redundancies ###################### # Comparison operators ###################### contains = contains __contains__ = contains def __le__(self: ConstrainedZonotope, Q: "ConstrainedZonotope | Polytope") -> bool: """Overload <= operator for containment. self <= Q is equivalent to Q.contains(self).""" if is_polytope(Q) or is_constrained_zonotope(Q): return Q.contains(self) else: return NotImplemented def __ge__( self: ConstrainedZonotope, Q: "Sequence[float] | np.ndarray | ConstrainedZonotope | Polytope", ) -> bool: """Overload >= operator for containment. self >= Q is equivalent to self.contains(Q).""" if is_polytope(Q) or is_constrained_zonotope(Q): return self.contains(Q) else: try: Q_arr = np.atleast_2d(Q).astype(float) except (TypeError, ValueError): return NotImplemented if Q_arr.size == self.dim and Q_arr.shape[0] == 1: # Q is a point ====> ConstrainedZonotope >= Point case return self.contains(Q_arr) else: return NotImplemented def __eq__(self: ConstrainedZonotope, Q: object) -> bool: """Overload == operator with equality check. P == Q is equivalent to Q.contains(P) and P.contains(Q)""" if is_constrained_zonotope(Q) or is_polytope(Q): return self <= cast("ConstrainedZonotope | Polytope", Q) and self >= Q else: return NotImplemented __lt__ = __le__ __gt__ = __ge__ #################### # Binary operations #################### plus = plus __add__ = plus __radd__ = plus minus = minus __sub__ = minus def __rsub__(self: ConstrainedZonotope, Q: Any): raise TypeError(f"Unsupported operation: {type(Q)} - ConstrainedZonotope!") __array_ufunc__ = None # Allows for numpy matrix times Polytope affine_map = affine_map inverse_affine_map_under_invertible_matrix = inverse_affine_map_under_invertible_matrix # ConstrainedZonotope times Matrix __matmul__ = inverse_affine_map_under_invertible_matrix def __mul__(self: ConstrainedZonotope, x: Any): """Do not allow ConstrainedZonotope * anything""" return NotImplemented def __neg__(self: ConstrainedZonotope) -> ConstrainedZonotope: return affine_map(self, -1) # Scalar/Matrix times ConstrainedZonotope (called when left operand does not support multiplication) def __rmatmul__( self: ConstrainedZonotope, M: int | float | Sequence[Sequence[float]] | np.ndarray ) -> ConstrainedZonotope: """Overload @ operator for affine map (matrix times ConstrainedZonotope).""" return affine_map(self, M) def __rmul__(self: ConstrainedZonotope, m: int | float | Sequence[float] | np.ndarray) -> ConstrainedZonotope: """Overload * operator for multiplication.""" try: m = np.squeeze(m).astype(float) except (TypeError, ValueError) as err: raise TypeError(f"Unsupported operation: {type(m)} * ConstrainedZonotope!") from err return affine_map(self, m) approximate_pontryagin_difference = approximate_pontryagin_difference cartesian_product = cartesian_product closest_point = convex_set_closest_point distance = convex_set_distance extreme = convex_set_extreme intersection = intersection intersection_with_halfspaces = intersection_with_halfspaces intersection_with_affine_set = intersection_with_affine_set intersection_under_inverse_affine_map = intersection_under_inverse_affine_map
[docs] def project( self: ConstrainedZonotope, x: Sequence[float] | Sequence[Sequence[float]] | np.ndarray, p: int | str = 2 ) -> tuple[np.ndarray, np.ndarray]: return convex_set_project(self, x, p=p)
project.__doc__ = (convex_set_project.__doc__ or "") + DOCSTRING_FOR_PROJECT
[docs] def projection( self: ConstrainedZonotope, project_away_dims: int | Sequence[int] | np.ndarray ) -> ConstrainedZonotope: return convex_set_projection(self, project_away_dims=project_away_dims)
projection.__doc__ = (convex_set_projection.__doc__ or "") + DOCSTRING_FOR_PROJECTION
[docs] def slice( self: ConstrainedZonotope, dims: int | Sequence[int] | np.ndarray, constants: float | Sequence[float] | np.ndarray, ) -> ConstrainedZonotope: return convex_set_slice(self, dims, constants)
slice.__doc__ = (convex_set_slice.__doc__ or "") + DOCSTRING_FOR_SLICE
[docs] def slice_then_projection( self, dims: int | Sequence[int] | np.ndarray, constants: float | Sequence[float] | np.ndarray ) -> "ConstrainedZonotope": return convex_set_slice_then_projection(self, dims=dims, constants=constants)
slice_then_projection.__doc__ = ( convex_set_slice_then_projection.__doc__ or "" ) + DOCSTRING_FOR_SLICE_THEN_PROJECTION
[docs] def support( self: ConstrainedZonotope, eta: Sequence[float] | Sequence[Sequence[float]] | np.ndarray ) -> tuple[np.ndarray, np.ndarray]: return convex_set_support(self, eta)
support.__doc__ = (convex_set_support.__doc__ or "") + DOCSTRING_FOR_SUPPORT _compute_support_function_multiple_eta = _compute_support_function_multiple_eta _compute_project_multiple_points = _compute_project_multiple_points ##################################### # Constrained zonotope representation ##################################### def __str__(self: ConstrainedZonotope): if self.is_empty: short_str = f"(empty) in R^{self.dim:d}" else: short_str = f"in R^{self.dim:d}" return f"Constrained Zonotope {short_str:s}" def __repr__(self: ConstrainedZonotope): long_str = [str(self)] if self.is_empty: pass elif self.is_zonotope and self.G.size == 0: long_str += ["\n\tthat is a zonotope representing a single point"] elif self.is_zonotope: long_str += [f"\n\tthat is a zonotope with latent dimension {self.latent_dim:d}"] elif self.n_equalities == 1: long_str += [f"\n\twith latent dimension {self.latent_dim:d} and 1 equality constraint"] else: long_str += [ f"\n\twith latent dimension {self.latent_dim:d} and {self.n_equalities:d} equality constraints" ] return "".join(long_str)