Skip to content

expr

Symbolic expression package for trajectory optimization.

This package provides a comprehensive symbolic expression system for building optimization problems in openscvx. It implements an Abstract Syntax Tree (AST) framework that allows you to write optimization problems using natural mathematical notation.

Example

Import the package through the main openscvx module::

import openscvx as ox

# Create symbolic variables
x = ox.Variable("x", shape=(3,))
u = ox.Control("u", shape=(2,))

# Build expressions
cost = ox.Norm(x - [1, 2, 3])**2 + 0.1 * ox.Norm(u)**2
constraint = x[0] <= 5.0
Module Organization

The package is organized into the following modules:

Core Expressions (expr.py): Base classes and utilities including Expr, Leaf, Parameter, Constant, and helper functions to_expr and traverse.

Arithmetic Operations (arithmetic.py): Fundamental arithmetic operations including Add, Sub, Mul, Div, MatMul, Neg, and Power.

Array Operations (array.py): Array manipulation operations including Index, Concat, Stack, Hstack, and Vstack for indexing, slicing, and combining arrays.

Constraints (constraint.py): Constraint types including Constraint, Equality, Inequality, NodalConstraint, and CTCS (Continuous-Time Constraint Satisfaction).

Optimization Variables (variable.py, state.py, control.py): Variable for general optimization variables, State for time-varying state in trajectory problems, and Control for control inputs.

Mathematical Functions (math.py): Trigonometric functions (Sin, Cos), exponential functions (Exp, Log, Sqrt, Square), and nonlinear functions (PositivePart, Huber, SmoothReLU, Max).

Linear Algebra (linalg.py): Matrix operations (Transpose, Diag) and reductions (Sum, Norm).

Spatial Operations (spatial.py): 6-DOF operations for aerospace and robotics including QDCM (Quaternion to Direction Cosine Matrix), SSMP (4×4 skew-symmetric matrix for quaternion dynamics), and SSM (3×3 skew-symmetric matrix for cross products).

Constraint Specifications (constraint.py): NodalConstraint for enforcing constraints at discrete nodes and CTCS for continuous-time constraint satisfaction.

Signal Temporal Logic (stl.py): Or for logical disjunction in task specifications.

Abs

Bases: Expr

Element-wise absolute value function for symbolic expressions.

Computes the absolute value (|x|) of each element in the operand. Preserves the shape of the input expression. The absolute value function is convex and DCP-compliant in CVXPy.

Attributes:

Name Type Description
operand

Expression to apply absolute value to

Example

Define an Abs expression:

x = Variable("x", shape=(3,))
abs_x = Abs(x)  # Element-wise |x|
Source code in openscvx/symbolic/expr/math.py
class Abs(Expr):
    """Element-wise absolute value function for symbolic expressions.

    Computes the absolute value (|x|) of each element in the operand. Preserves
    the shape of the input expression. The absolute value function is convex
    and DCP-compliant in CVXPy.

    Attributes:
        operand: Expression to apply absolute value to

    Example:
        Define an Abs expression:

            x = Variable("x", shape=(3,))
            abs_x = Abs(x)  # Element-wise |x|
    """

    def __init__(self, operand):
        """Initialize an absolute value operation.

        Args:
            operand: Expression to apply absolute value to
        """
        self.operand = to_expr(operand)

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Abs(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Abs preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"abs({self.operand!r})"
check_shape() -> Tuple[int, ...]

Abs preserves the shape of its operand.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Abs preserves the shape of its operand."""
    return self.operand.check_shape()

Add

Bases: Expr

Addition operation for symbolic expressions.

Represents element-wise addition of two or more expressions. Supports broadcasting following NumPy rules. Can be created using the + operator on Expr objects.

Attributes:

Name Type Description
terms

List of expression operands to add together

Example

Define an Add expression:

x = ox.State("x", shape=(3,))
y = ox.State("y", shape=(3,))
z = x + y + 5  # Creates Add(x, y, Constant(5))
Source code in openscvx/symbolic/expr/arithmetic.py
class Add(Expr):
    """Addition operation for symbolic expressions.

    Represents element-wise addition of two or more expressions. Supports broadcasting
    following NumPy rules. Can be created using the + operator on Expr objects.

    Attributes:
        terms: List of expression operands to add together

    Example:
        Define an Add expression:

            x = ox.State("x", shape=(3,))
            y = ox.State("y", shape=(3,))
            z = x + y + 5  # Creates Add(x, y, Constant(5))
    """

    def __init__(self, *args):
        """Initialize an addition operation.

        Args:
            *args: Two or more expressions to add together

        Raises:
            ValueError: If fewer than two operands are provided
        """
        if len(args) < 2:
            raise ValueError("Add requires two or more operands")
        self.terms = [to_expr(a) for a in args]

    def children(self):
        return list(self.terms)

    def canonicalize(self) -> "Expr":
        """Canonicalize addition: flatten, fold constants, and eliminate zeros.

        Returns:
            Expr: Canonical form of the addition expression
        """
        terms = []
        const_vals = []

        for t in self.terms:
            c = t.canonicalize()
            if isinstance(c, Add):
                terms.extend(c.terms)
            elif isinstance(c, Constant):
                const_vals.append(c.value)
            else:
                terms.append(c)

        if const_vals:
            total = sum(const_vals)
            # If not all-zero, keep it
            if not (isinstance(total, np.ndarray) and np.all(total == 0)):
                terms.append(Constant(total))

        if not terms:
            return Constant(np.array(0))
        if len(terms) == 1:
            return terms[0]
        return Add(*terms)

    def check_shape(self) -> Tuple[int, ...]:
        """Check shape compatibility and compute broadcasted result shape like NumPy.

        Returns:
            tuple: The broadcasted shape of all operands

        Raises:
            ValueError: If operand shapes are not broadcastable
        """
        shapes = [child.check_shape() for child in self.children()]
        try:
            return np.broadcast_shapes(*shapes)
        except ValueError as e:
            raise ValueError(f"Add shapes not broadcastable: {shapes}") from e

    def __repr__(self):
        inner = " + ".join(repr(e) for e in self.terms)
        return f"({inner})"
canonicalize() -> Expr

Canonicalize addition: flatten, fold constants, and eliminate zeros.

Returns:

Name Type Description
Expr Expr

Canonical form of the addition expression

Source code in openscvx/symbolic/expr/arithmetic.py
def canonicalize(self) -> "Expr":
    """Canonicalize addition: flatten, fold constants, and eliminate zeros.

    Returns:
        Expr: Canonical form of the addition expression
    """
    terms = []
    const_vals = []

    for t in self.terms:
        c = t.canonicalize()
        if isinstance(c, Add):
            terms.extend(c.terms)
        elif isinstance(c, Constant):
            const_vals.append(c.value)
        else:
            terms.append(c)

    if const_vals:
        total = sum(const_vals)
        # If not all-zero, keep it
        if not (isinstance(total, np.ndarray) and np.all(total == 0)):
            terms.append(Constant(total))

    if not terms:
        return Constant(np.array(0))
    if len(terms) == 1:
        return terms[0]
    return Add(*terms)
check_shape() -> Tuple[int, ...]

Check shape compatibility and compute broadcasted result shape like NumPy.

Returns:

Name Type Description
tuple Tuple[int, ...]

The broadcasted shape of all operands

Raises:

Type Description
ValueError

If operand shapes are not broadcastable

Source code in openscvx/symbolic/expr/arithmetic.py
def check_shape(self) -> Tuple[int, ...]:
    """Check shape compatibility and compute broadcasted result shape like NumPy.

    Returns:
        tuple: The broadcasted shape of all operands

    Raises:
        ValueError: If operand shapes are not broadcastable
    """
    shapes = [child.check_shape() for child in self.children()]
    try:
        return np.broadcast_shapes(*shapes)
    except ValueError as e:
        raise ValueError(f"Add shapes not broadcastable: {shapes}") from e

BoundaryType

Bases: str, Enum

Enumeration of boundary condition types for state variables.

This enum allows users to specify boundary conditions using plain strings while maintaining type safety internally. Boundary conditions control how the optimizer handles initial and final state values.

Attributes:

Name Type Description
FIXED str

State value is fixed to a specific value

FREE str

State value is free to be optimized within bounds

MINIMIZE str

Objective term to minimize the state value

MAXIMIZE str

Objective term to maximize the state value

Example

Can use either enum or string:

BoundaryType.FIXED
"fixed"  # Equivalent
Source code in openscvx/symbolic/expr/state.py
class BoundaryType(str, Enum):
    """Enumeration of boundary condition types for state variables.

    This enum allows users to specify boundary conditions using plain strings
    while maintaining type safety internally. Boundary conditions control how
    the optimizer handles initial and final state values.

    Attributes:
        FIXED (str): State value is fixed to a specific value
        FREE (str): State value is free to be optimized within bounds
        MINIMIZE (str): Objective term to minimize the state value
        MAXIMIZE (str): Objective term to maximize the state value

    Example:
        Can use either enum or string:

            BoundaryType.FIXED
            "fixed"  # Equivalent
    """

    FIXED = "fixed"
    FREE = "free"
    MINIMIZE = "minimize"
    MAXIMIZE = "maximize"

CTCS

Bases: Expr

Continuous-Time Constraint Satisfaction using augmented state dynamics.

CTCS enables strict continuous-time constraint enforcement in discretized trajectory optimization by augmenting the state vector with additional states whose dynamics are the constraint violation penalties. By constraining these augmented states to remain at zero throughout the trajectory, the original constraints are guaranteed to be satisfied continuously, not just at discrete nodes.

How it works:

  1. Each constraint (in canonical form: lhs <= 0) is wrapped in a penalty function
  2. Augmented states s_aug_i are added with dynamics: ds_aug_i/dt = sum(penalty_j(lhs_j)) for all CTCS constraints j in group i
  3. Each augmented state is constrained: s_aug_i(t) = 0 for all t (strictly enforced)
  4. Since s_aug_i integrates the penalties, s_aug_i = 0 implies all penalties in the group are zero, which means all constraints in the group are satisfied continuously

Grouping and augmented states:

  • CTCS constraints with the same node interval are grouped into a single augmented state by default (their penalties are summed)
  • CTCS constraints with different node intervals create separate augmented states
  • Using the idx parameter explicitly assigns constraints to specific augmented states, allowing manual control over grouping
  • Each unique group creates one augmented state named _ctcs_aug_0, _ctcs_aug_1, etc.

This is particularly useful for:

  • Path constraints that must hold throughout the entire trajectory (not just at nodes)
  • Obstacle avoidance where constraint violation between nodes could be catastrophic
  • State limits that should be respected continuously (e.g., altitude > 0 for aircraft)
  • Ensuring smooth, feasible trajectories between discretization points

Penalty functions (applied to constraint violations):

  • squared_relu: Square(PositivePart(lhs)) - smooth, differentiable (default)
  • huber: Huber(PositivePart(lhs)) - less sensitive to outliers than squared
  • smooth_relu: SmoothReLU(lhs) - smooth approximation of ReLU

Attributes:

Name Type Description
constraint

The wrapped Constraint (typically Inequality) to enforce continuously

penalty

Penalty function type ('squared_relu', 'huber', or 'smooth_relu')

nodes

Optional (start, end) tuple specifying the interval for enforcement, or None to enforce over the entire trajectory

idx

Optional grouping index for managing multiple augmented states. CTCS constraints with the same idx and nodes are grouped together, sharing an augmented state. If None, auto-assigned based on node intervals.

check_nodally

Whether to also enforce the constraint at discrete nodes for additional numerical robustness (creates both continuous and nodal constraints)

Example

Single augmented state (default behavior - same node interval):

altitude = State("alt", shape=(1,))
constraints = [
    (altitude >= 10).over((0, 10)),  # Both constraints share
    (altitude <= 1000).over((0, 10))  # one augmented state
]

Multiple augmented states (different node intervals):

constraints = [
    (altitude >= 10).over((0, 5)),  # Creates _ctcs_aug_0
    (altitude >= 20).over((5, 10))  # Creates _ctcs_aug_1
]

Manual grouping with idx parameter:

constraints = [
    (altitude >= 10).over((0, 10), idx=0),    # Group 0
    (velocity <= 100).over((0, 10), idx=1),   # Group 1 (separate state)
    (altitude <= 1000).over((0, 10), idx=0)   # Also group 0
]
Source code in openscvx/symbolic/expr/constraint.py
class CTCS(Expr):
    """Continuous-Time Constraint Satisfaction using augmented state dynamics.

    CTCS enables strict continuous-time constraint enforcement in discretized trajectory
    optimization by augmenting the state vector with additional states whose dynamics
    are the constraint violation penalties. By constraining these augmented states to remain
    at zero throughout the trajectory, the original constraints are guaranteed to be satisfied
    continuously, not just at discrete nodes.

    **How it works:**

    1. Each constraint (in canonical form: lhs <= 0) is wrapped in a penalty function
    2. Augmented states s_aug_i are added with dynamics: ds_aug_i/dt = sum(penalty_j(lhs_j))
       for all CTCS constraints j in group i
    3. Each augmented state is constrained: s_aug_i(t) = 0 for all t (strictly enforced)
    4. Since s_aug_i integrates the penalties, s_aug_i = 0 implies all penalties in the
       group are zero, which means all constraints in the group are satisfied continuously

    **Grouping and augmented states:**

    - CTCS constraints with the **same node interval** are grouped into a single augmented
      state by default (their penalties are summed)
    - CTCS constraints with **different node intervals** create separate augmented states
    - Using the `idx` parameter explicitly assigns constraints to specific augmented states,
      allowing manual control over grouping
    - Each unique group creates one augmented state named `_ctcs_aug_0`, `_ctcs_aug_1`, etc.

    This is particularly useful for:

    - Path constraints that must hold throughout the entire trajectory (not just at nodes)
    - Obstacle avoidance where constraint violation between nodes could be catastrophic
    - State limits that should be respected continuously (e.g., altitude > 0 for aircraft)
    - Ensuring smooth, feasible trajectories between discretization points

    **Penalty functions** (applied to constraint violations):

    - **squared_relu**: Square(PositivePart(lhs)) - smooth, differentiable (default)
    - **huber**: Huber(PositivePart(lhs)) - less sensitive to outliers than squared
    - **smooth_relu**: SmoothReLU(lhs) - smooth approximation of ReLU

    Attributes:
        constraint: The wrapped Constraint (typically Inequality) to enforce continuously
        penalty: Penalty function type ('squared_relu', 'huber', or 'smooth_relu')
        nodes: Optional (start, end) tuple specifying the interval for enforcement,
            or None to enforce over the entire trajectory
        idx: Optional grouping index for managing multiple augmented states.
            CTCS constraints with the same idx and nodes are grouped together, sharing
            an augmented state. If None, auto-assigned based on node intervals.
        check_nodally: Whether to also enforce the constraint at discrete nodes for
            additional numerical robustness (creates both continuous and nodal constraints)

    Example:
        Single augmented state (default behavior - same node interval):

            altitude = State("alt", shape=(1,))
            constraints = [
                (altitude >= 10).over((0, 10)),  # Both constraints share
                (altitude <= 1000).over((0, 10))  # one augmented state
            ]

        Multiple augmented states (different node intervals):

            constraints = [
                (altitude >= 10).over((0, 5)),  # Creates _ctcs_aug_0
                (altitude >= 20).over((5, 10))  # Creates _ctcs_aug_1
            ]

        Manual grouping with idx parameter:

            constraints = [
                (altitude >= 10).over((0, 10), idx=0),    # Group 0
                (velocity <= 100).over((0, 10), idx=1),   # Group 1 (separate state)
                (altitude <= 1000).over((0, 10), idx=0)   # Also group 0
            ]
    """

    def __init__(
        self,
        constraint: Constraint,
        penalty: str = "squared_relu",
        nodes: Optional[Tuple[int, int]] = None,
        idx: Optional[int] = None,
        check_nodally: bool = False,
    ):
        """Initialize a CTCS constraint.

        Args:
            constraint: The Constraint to enforce continuously (typically an Inequality)
            penalty: Penalty function type. Options:
                - 'squared_relu': Square(PositivePart(lhs)) - default, smooth, differentiable
                - 'huber': Huber(PositivePart(lhs)) - robust to outliers
                - 'smooth_relu': SmoothReLU(lhs) - smooth ReLU approximation
            nodes: Optional (start, end) tuple of node indices defining the enforcement interval.
                None means enforce over the entire trajectory. Must satisfy start < end.
                CTCS constraints with the same nodes are automatically grouped together.
            idx: Optional grouping index for multiple augmented states. Allows organizing
                multiple CTCS constraints with separate augmented state variables.
                If None, constraints are auto-grouped by their node intervals.
                Explicitly setting idx allows manual control over which constraints
                share an augmented state.
            check_nodally: If True, also enforce the constraint at discrete nodes for
                numerical stability (creates both continuous and nodal constraints).
                Defaults to False.

        Raises:
            TypeError: If constraint is not a Constraint instance
            ValueError: If nodes is not None or a 2-tuple of integers
            ValueError: If nodes[0] >= nodes[1] (invalid interval)
        """
        if not isinstance(constraint, Constraint):
            raise TypeError("CTCS must wrap a Constraint")

        # Validate nodes parameter for CTCS
        if nodes is not None:
            if not isinstance(nodes, tuple) or len(nodes) != 2:
                raise ValueError(
                    "CTCS constraints must specify nodes as a tuple of (start, end) or None "
                    "for all nodes"
                )
            if not all(isinstance(n, int) for n in nodes):
                raise ValueError("CTCS node indices must be integers")
            if nodes[0] >= nodes[1]:
                raise ValueError("CTCS node range must have start < end")

        self.constraint = constraint
        self.penalty = penalty
        self.nodes = nodes  # (start, end) node range or None for all nodes
        self.idx = idx  # Optional grouping index for multiple augmented states
        # Whether to also enforce this constraint nodally for numerical stability
        self.check_nodally = check_nodally

    def children(self):
        """Return the wrapped constraint as the only child.

        Returns:
            list: Single-element list containing the wrapped constraint
        """
        return [self.constraint]

    def canonicalize(self) -> "Expr":
        """Canonicalize the inner constraint while preserving CTCS parameters.

        Returns:
            CTCS: A new CTCS with canonicalized inner constraint and same parameters
        """
        canon_constraint = self.constraint.canonicalize()
        return CTCS(
            canon_constraint,
            penalty=self.penalty,
            nodes=self.nodes,
            idx=self.idx,
            check_nodally=self.check_nodally,
        )

    def check_shape(self) -> Tuple[int, ...]:
        """Validate the constraint and penalty expression shapes.

        CTCS transforms the wrapped constraint into a penalty expression that is
        summed (integrated) over the trajectory, always producing a scalar result.

        Returns:
            tuple: Empty tuple () representing scalar shape

        Raises:
            ValueError: If the wrapped constraint has invalid shape
            ValueError: If the generated penalty expression is not scalar
        """
        # First validate the wrapped constraint's shape
        self.constraint.check_shape()

        # Also validate the penalty expression that would be generated
        try:
            penalty_expr = self.penalty_expr()
            penalty_shape = penalty_expr.check_shape()

            # The penalty expression should always be scalar due to Sum wrapper
            if penalty_shape != ():
                raise ValueError(
                    f"CTCS penalty expression should be scalar, but got shape {penalty_shape}"
                )
        except Exception as e:
            # Re-raise with more context about which CTCS node failed
            raise ValueError(f"CTCS penalty expression validation failed: {e}") from e

        # CTCS always produces a scalar due to the Sum in penalty_expr
        return ()

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash CTCS including all its parameters.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"CTCS")
        # Hash penalty type
        hasher.update(self.penalty.encode())
        # Hash nodes interval
        if self.nodes is not None:
            hasher.update(struct.pack(">ii", self.nodes[0], self.nodes[1]))
        else:
            hasher.update(b"None")
        # Hash idx
        if self.idx is not None:
            hasher.update(struct.pack(">i", self.idx))
        else:
            hasher.update(b"None")
        # Hash check_nodally
        hasher.update(b"1" if self.check_nodally else b"0")
        # Hash the wrapped constraint
        self.constraint._hash_into(hasher)

    def over(self, interval: tuple[int, int]) -> "CTCS":
        """Set or update the continuous interval for this CTCS constraint.

        Args:
            interval: Tuple of (start, end) node indices defining the enforcement interval

        Returns:
            CTCS: New CTCS constraint with the specified interval

        Example:
            Define constraint over range:

                constraint = (altitude >= 10).over((0, 50))

            Update interval to cover different range:

                constraint_updated = constraint.over((50, 100))
        """
        return CTCS(
            self.constraint,
            penalty=self.penalty,
            nodes=interval,
            idx=self.idx,
            check_nodally=self.check_nodally,
        )

    def __repr__(self):
        """String representation of the CTCS constraint.

        Returns:
            str: String showing constraint, penalty type, and optional parameters
        """
        parts = [f"{self.constraint!r}", f"penalty={self.penalty!r}"]
        if self.nodes is not None:
            parts.append(f"nodes={self.nodes}")
        if self.idx is not None:
            parts.append(f"idx={self.idx}")
        if self.check_nodally:
            parts.append(f"check_nodally={self.check_nodally}")
        return f"CTCS({', '.join(parts)})"

    def penalty_expr(self) -> Expr:
        """Build the penalty expression for this CTCS constraint.

        Transforms the constraint's left-hand side (in canonical form: lhs <= 0)
        into a penalty expression using the specified penalty function. The penalty
        is zero when the constraint is satisfied and positive when violated.

        This penalty expression becomes part of the dynamics of an augmented state.
        Multiple CTCS constraints in the same group (same idx) have their penalties
        summed: ds_aug_i/dt = sum(penalty_j) for all j in group i. By constraining
        s_aug_i(t) = 0 for all t, we ensure all penalties in the group are zero,
        which strictly enforces all constraints in the group continuously.

        Returns:
            Expr: Sum of the penalty function applied to the constraint violation

        Raises:
            ValueError: If an unknown penalty type is specified

        Note:
            This method is used internally during problem compilation to create
            augmented state dynamics. Multiple penalty expressions with the same
            idx are summed together before being added to the dynamics vector via Concat.
        """
        lhs = self.constraint.lhs

        if self.penalty == "squared_relu":
            from openscvx.symbolic.expr.math import PositivePart, Square

            penalty = Square(PositivePart(lhs))
        elif self.penalty == "huber":
            from openscvx.symbolic.expr.math import Huber, PositivePart

            penalty = Huber(PositivePart(lhs))
        elif self.penalty == "smooth_relu":
            from openscvx.symbolic.expr.math import SmoothReLU

            penalty = SmoothReLU(lhs)
        else:
            raise ValueError(f"Unknown penalty {self.penalty!r}")

        return Sum(penalty)
_hash_into(hasher: hashlib._Hash) -> None

Hash CTCS including all its parameters.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/constraint.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash CTCS including all its parameters.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"CTCS")
    # Hash penalty type
    hasher.update(self.penalty.encode())
    # Hash nodes interval
    if self.nodes is not None:
        hasher.update(struct.pack(">ii", self.nodes[0], self.nodes[1]))
    else:
        hasher.update(b"None")
    # Hash idx
    if self.idx is not None:
        hasher.update(struct.pack(">i", self.idx))
    else:
        hasher.update(b"None")
    # Hash check_nodally
    hasher.update(b"1" if self.check_nodally else b"0")
    # Hash the wrapped constraint
    self.constraint._hash_into(hasher)
canonicalize() -> Expr

Canonicalize the inner constraint while preserving CTCS parameters.

Returns:

Name Type Description
CTCS Expr

A new CTCS with canonicalized inner constraint and same parameters

Source code in openscvx/symbolic/expr/constraint.py
def canonicalize(self) -> "Expr":
    """Canonicalize the inner constraint while preserving CTCS parameters.

    Returns:
        CTCS: A new CTCS with canonicalized inner constraint and same parameters
    """
    canon_constraint = self.constraint.canonicalize()
    return CTCS(
        canon_constraint,
        penalty=self.penalty,
        nodes=self.nodes,
        idx=self.idx,
        check_nodally=self.check_nodally,
    )
check_shape() -> Tuple[int, ...]

Validate the constraint and penalty expression shapes.

CTCS transforms the wrapped constraint into a penalty expression that is summed (integrated) over the trajectory, always producing a scalar result.

Returns:

Name Type Description
tuple Tuple[int, ...]

Empty tuple () representing scalar shape

Raises:

Type Description
ValueError

If the wrapped constraint has invalid shape

ValueError

If the generated penalty expression is not scalar

Source code in openscvx/symbolic/expr/constraint.py
def check_shape(self) -> Tuple[int, ...]:
    """Validate the constraint and penalty expression shapes.

    CTCS transforms the wrapped constraint into a penalty expression that is
    summed (integrated) over the trajectory, always producing a scalar result.

    Returns:
        tuple: Empty tuple () representing scalar shape

    Raises:
        ValueError: If the wrapped constraint has invalid shape
        ValueError: If the generated penalty expression is not scalar
    """
    # First validate the wrapped constraint's shape
    self.constraint.check_shape()

    # Also validate the penalty expression that would be generated
    try:
        penalty_expr = self.penalty_expr()
        penalty_shape = penalty_expr.check_shape()

        # The penalty expression should always be scalar due to Sum wrapper
        if penalty_shape != ():
            raise ValueError(
                f"CTCS penalty expression should be scalar, but got shape {penalty_shape}"
            )
    except Exception as e:
        # Re-raise with more context about which CTCS node failed
        raise ValueError(f"CTCS penalty expression validation failed: {e}") from e

    # CTCS always produces a scalar due to the Sum in penalty_expr
    return ()
children()

Return the wrapped constraint as the only child.

Returns:

Name Type Description
list

Single-element list containing the wrapped constraint

Source code in openscvx/symbolic/expr/constraint.py
def children(self):
    """Return the wrapped constraint as the only child.

    Returns:
        list: Single-element list containing the wrapped constraint
    """
    return [self.constraint]
over(interval: tuple[int, int]) -> CTCS

Set or update the continuous interval for this CTCS constraint.

Parameters:

Name Type Description Default
interval tuple[int, int]

Tuple of (start, end) node indices defining the enforcement interval

required

Returns:

Name Type Description
CTCS CTCS

New CTCS constraint with the specified interval

Example

Define constraint over range:

constraint = (altitude >= 10).over((0, 50))

Update interval to cover different range:

constraint_updated = constraint.over((50, 100))
Source code in openscvx/symbolic/expr/constraint.py
def over(self, interval: tuple[int, int]) -> "CTCS":
    """Set or update the continuous interval for this CTCS constraint.

    Args:
        interval: Tuple of (start, end) node indices defining the enforcement interval

    Returns:
        CTCS: New CTCS constraint with the specified interval

    Example:
        Define constraint over range:

            constraint = (altitude >= 10).over((0, 50))

        Update interval to cover different range:

            constraint_updated = constraint.over((50, 100))
    """
    return CTCS(
        self.constraint,
        penalty=self.penalty,
        nodes=interval,
        idx=self.idx,
        check_nodally=self.check_nodally,
    )
penalty_expr() -> Expr

Build the penalty expression for this CTCS constraint.

Transforms the constraint's left-hand side (in canonical form: lhs <= 0) into a penalty expression using the specified penalty function. The penalty is zero when the constraint is satisfied and positive when violated.

This penalty expression becomes part of the dynamics of an augmented state. Multiple CTCS constraints in the same group (same idx) have their penalties summed: ds_aug_i/dt = sum(penalty_j) for all j in group i. By constraining s_aug_i(t) = 0 for all t, we ensure all penalties in the group are zero, which strictly enforces all constraints in the group continuously.

Returns:

Name Type Description
Expr Expr

Sum of the penalty function applied to the constraint violation

Raises:

Type Description
ValueError

If an unknown penalty type is specified

Note

This method is used internally during problem compilation to create augmented state dynamics. Multiple penalty expressions with the same idx are summed together before being added to the dynamics vector via Concat.

Source code in openscvx/symbolic/expr/constraint.py
def penalty_expr(self) -> Expr:
    """Build the penalty expression for this CTCS constraint.

    Transforms the constraint's left-hand side (in canonical form: lhs <= 0)
    into a penalty expression using the specified penalty function. The penalty
    is zero when the constraint is satisfied and positive when violated.

    This penalty expression becomes part of the dynamics of an augmented state.
    Multiple CTCS constraints in the same group (same idx) have their penalties
    summed: ds_aug_i/dt = sum(penalty_j) for all j in group i. By constraining
    s_aug_i(t) = 0 for all t, we ensure all penalties in the group are zero,
    which strictly enforces all constraints in the group continuously.

    Returns:
        Expr: Sum of the penalty function applied to the constraint violation

    Raises:
        ValueError: If an unknown penalty type is specified

    Note:
        This method is used internally during problem compilation to create
        augmented state dynamics. Multiple penalty expressions with the same
        idx are summed together before being added to the dynamics vector via Concat.
    """
    lhs = self.constraint.lhs

    if self.penalty == "squared_relu":
        from openscvx.symbolic.expr.math import PositivePart, Square

        penalty = Square(PositivePart(lhs))
    elif self.penalty == "huber":
        from openscvx.symbolic.expr.math import Huber, PositivePart

        penalty = Huber(PositivePart(lhs))
    elif self.penalty == "smooth_relu":
        from openscvx.symbolic.expr.math import SmoothReLU

        penalty = SmoothReLU(lhs)
    else:
        raise ValueError(f"Unknown penalty {self.penalty!r}")

    return Sum(penalty)

Concat

Bases: Expr

Concatenation operation for symbolic expressions.

Concatenates a sequence of expressions along their first dimension. All inputs must have the same rank and matching dimensions except for the first dimension.

Attributes:

Name Type Description
exprs

Tuple of expressions to concatenate

Example

Define a Concat expression:

x = ox.State("x", shape=(3,))
y = ox.State("y", shape=(4,))
z = Concat(x, y)  # Creates Concat(x, y), result shape (7,)
Source code in openscvx/symbolic/expr/array.py
class Concat(Expr):
    """Concatenation operation for symbolic expressions.

    Concatenates a sequence of expressions along their first dimension. All inputs
    must have the same rank and matching dimensions except for the first dimension.

    Attributes:
        exprs: Tuple of expressions to concatenate

    Example:
        Define a Concat expression:

            x = ox.State("x", shape=(3,))
            y = ox.State("y", shape=(4,))
            z = Concat(x, y)  # Creates Concat(x, y), result shape (7,)
    """

    def __init__(self, *exprs: Expr):
        """Initialize a concatenation operation.

        Args:
            *exprs: Expressions to concatenate along the first dimension
        """
        # wrap raw values as Constant if needed
        self.exprs = [to_expr(e) for e in exprs]

    def children(self):
        return list(self.exprs)

    def canonicalize(self) -> "Expr":
        """Canonicalize concatenation by canonicalizing all operands.

        Returns:
            Expr: Canonical form of the concatenation expression
        """
        exprs = [e.canonicalize() for e in self.exprs]
        return Concat(*exprs)

    def check_shape(self) -> Tuple[int, ...]:
        """Check concatenation shape compatibility and return result shape."""
        shapes = [e.check_shape() for e in self.exprs]
        shapes = [(1,) if len(s) == 0 else s for s in shapes]
        rank = len(shapes[0])
        if any(len(s) != rank for s in shapes):
            raise ValueError(f"Concat rank mismatch: {shapes}")
        if any(s[1:] != shapes[0][1:] for s in shapes[1:]):
            raise ValueError(f"Concat non-0 dims differ: {shapes}")
        return (sum(s[0] for s in shapes),) + shapes[0][1:]

    def __repr__(self):
        inner = ", ".join(repr(e) for e in self.exprs)
        return f"Concat({inner})"
canonicalize() -> Expr

Canonicalize concatenation by canonicalizing all operands.

Returns:

Name Type Description
Expr Expr

Canonical form of the concatenation expression

Source code in openscvx/symbolic/expr/array.py
def canonicalize(self) -> "Expr":
    """Canonicalize concatenation by canonicalizing all operands.

    Returns:
        Expr: Canonical form of the concatenation expression
    """
    exprs = [e.canonicalize() for e in self.exprs]
    return Concat(*exprs)
check_shape() -> Tuple[int, ...]

Check concatenation shape compatibility and return result shape.

Source code in openscvx/symbolic/expr/array.py
def check_shape(self) -> Tuple[int, ...]:
    """Check concatenation shape compatibility and return result shape."""
    shapes = [e.check_shape() for e in self.exprs]
    shapes = [(1,) if len(s) == 0 else s for s in shapes]
    rank = len(shapes[0])
    if any(len(s) != rank for s in shapes):
        raise ValueError(f"Concat rank mismatch: {shapes}")
    if any(s[1:] != shapes[0][1:] for s in shapes[1:]):
        raise ValueError(f"Concat non-0 dims differ: {shapes}")
    return (sum(s[0] for s in shapes),) + shapes[0][1:]

Constant

Bases: Expr

Constant value expression.

Represents a constant numeric value in the expression tree. Constants are automatically normalized (squeezed) upon construction to ensure consistency.

Attributes:

Name Type Description
value

The numpy array representing the constant value (squeezed)

Example

Define constants:

c1 = Constant(5.0)        # Scalar constant
c2 = Constant([1, 2, 3])  # Vector constant
c3 = to_expr(10)          # Also creates a Constant
Source code in openscvx/symbolic/expr/expr.py
class Constant(Expr):
    """Constant value expression.

    Represents a constant numeric value in the expression tree. Constants are
    automatically normalized (squeezed) upon construction to ensure consistency.

    Attributes:
        value: The numpy array representing the constant value (squeezed)

    Example:
        Define constants:

            c1 = Constant(5.0)        # Scalar constant
            c2 = Constant([1, 2, 3])  # Vector constant
            c3 = to_expr(10)          # Also creates a Constant
    """

    def __init__(self, value: np.ndarray):
        """Initialize a constant expression.

        Args:
            value: Numeric value or numpy array to wrap as a constant.
                   Will be converted to numpy array and squeezed.
        """
        # Normalize immediately upon construction to ensure consistency
        # This ensures Constant(5.0) and Constant([5.0]) create identical objects
        if not isinstance(value, np.ndarray):
            value = np.array(value, dtype=float)
        self.value = np.squeeze(value)

    def canonicalize(self) -> "Expr":
        """Constants are already in canonical form.

        Returns:
            Expr: Returns self since constants are already canonical
        """
        return self

    def check_shape(self) -> Tuple[int, ...]:
        """Return the shape of this constant's value.

        Returns:
            tuple: The shape of the constant's numpy array value
        """
        # Verify the invariant: constants should already be squeezed during construction
        original_shape = self.value.shape
        squeezed_shape = np.squeeze(self.value).shape
        if original_shape != squeezed_shape:
            raise ValueError(
                f"Constant not properly normalized: has shape {original_shape} "
                "but should have shape {squeezed_shape}. "
                "Constants should be squeezed during construction."
            )
        return self.value.shape

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash constant by its value.

        Constants are hashed by their actual numeric value, ensuring that
        expressions with the same constant values produce the same hash.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"Constant")
        hasher.update(str(self.value.shape).encode())
        hasher.update(self.value.tobytes())

    def __repr__(self):
        # Show clean representation - always show as Python values, not numpy arrays
        if self.value.ndim == 0:
            # Scalar: show as plain number
            return f"Const({self.value.item()!r})"
        else:
            # Array: show as Python list for readability
            return f"Const({self.value.tolist()!r})"
_hash_into(hasher: hashlib._Hash) -> None

Hash constant by its value.

Constants are hashed by their actual numeric value, ensuring that expressions with the same constant values produce the same hash.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/expr.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash constant by its value.

    Constants are hashed by their actual numeric value, ensuring that
    expressions with the same constant values produce the same hash.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"Constant")
    hasher.update(str(self.value.shape).encode())
    hasher.update(self.value.tobytes())
canonicalize() -> Expr

Constants are already in canonical form.

Returns:

Name Type Description
Expr Expr

Returns self since constants are already canonical

Source code in openscvx/symbolic/expr/expr.py
def canonicalize(self) -> "Expr":
    """Constants are already in canonical form.

    Returns:
        Expr: Returns self since constants are already canonical
    """
    return self
check_shape() -> Tuple[int, ...]

Return the shape of this constant's value.

Returns:

Name Type Description
tuple Tuple[int, ...]

The shape of the constant's numpy array value

Source code in openscvx/symbolic/expr/expr.py
def check_shape(self) -> Tuple[int, ...]:
    """Return the shape of this constant's value.

    Returns:
        tuple: The shape of the constant's numpy array value
    """
    # Verify the invariant: constants should already be squeezed during construction
    original_shape = self.value.shape
    squeezed_shape = np.squeeze(self.value).shape
    if original_shape != squeezed_shape:
        raise ValueError(
            f"Constant not properly normalized: has shape {original_shape} "
            "but should have shape {squeezed_shape}. "
            "Constants should be squeezed during construction."
        )
    return self.value.shape

Constraint

Bases: Expr

Abstract base class for optimization constraints.

Constraints represent relationships between expressions that must be satisfied in the optimization problem. This base class provides common functionality for both equality and inequality constraints.

Attributes:

Name Type Description
lhs

Left-hand side expression

rhs

Right-hand side expression

is_convex

Flag indicating if the constraint is known to be convex

Note

Constraints are canonicalized to standard form: (lhs - rhs) {op} 0

Source code in openscvx/symbolic/expr/constraint.py
class Constraint(Expr):
    """Abstract base class for optimization constraints.

    Constraints represent relationships between expressions that must be satisfied
    in the optimization problem. This base class provides common functionality for
    both equality and inequality constraints.

    Attributes:
        lhs: Left-hand side expression
        rhs: Right-hand side expression
        is_convex: Flag indicating if the constraint is known to be convex

    Note:
        Constraints are canonicalized to standard form: (lhs - rhs) {op} 0
    """

    def __init__(self, lhs: Expr, rhs: Expr):
        """Initialize a constraint.

        Args:
            lhs: Left-hand side expression
            rhs: Right-hand side expression
        """
        self.lhs = lhs
        self.rhs = rhs
        self.is_convex = False

    def children(self):
        return [self.lhs, self.rhs]

    def canonicalize(self) -> "Expr":
        """Canonicalize constraint to standard form: (lhs - rhs) {op} 0.

        This works for both Equality and Inequality by using type(self) to
        construct the appropriate subclass type.
        """
        diff = Sub(self.lhs, self.rhs)
        canon_diff = diff.canonicalize()
        new_constraint = type(self)(canon_diff, Constant(np.array(0)))
        new_constraint.is_convex = self.is_convex  # Preserve convex flag
        return new_constraint

    def check_shape(self) -> Tuple[int, ...]:
        """Check that constraint operands are broadcastable. Returns scalar shape."""
        L_shape = self.lhs.check_shape()
        R_shape = self.rhs.check_shape()

        # Figure out their broadcasted shape (or error if incompatible)
        try:
            np.broadcast_shapes(L_shape, R_shape)
        except ValueError as e:
            constraint_type = type(self).__name__
            raise ValueError(f"{constraint_type} not broadcastable: {L_shape} vs {R_shape}") from e

        # Allow vector constraints - they're interpreted element-wise
        # Return () as constraints always produce a scalar
        return ()

    def at(self, nodes: Union[list, tuple]):
        """Apply this constraint only at specific discrete nodes.

        Args:
            nodes: List of node indices where the constraint should be enforced

        Returns:
            NodalConstraint wrapping this constraint with node specification
        """
        if isinstance(nodes, int):
            nodes = [nodes]
        return NodalConstraint(self, list(nodes))

    def over(
        self,
        interval: tuple[int, int],
        penalty: str = "squared_relu",
        idx: Optional[int] = None,
        check_nodally: bool = False,
    ):
        """Apply this constraint over a continuous interval using CTCS.

        Args:
            interval: Tuple of (start, end) node indices for the continuous interval
            penalty: Penalty function type ("squared_relu", "huber", "smooth_relu")
            idx: Optional grouping index for multiple augmented states
            check_nodally: Whether to also enforce this constraint nodally

        Returns:
            CTCS constraint wrapping this constraint with interval specification
        """
        return CTCS(self, penalty=penalty, nodes=interval, idx=idx, check_nodally=check_nodally)

    def convex(self) -> "Constraint":
        """Mark this constraint as convex for CVXPy lowering.

        Returns:
            Self with convex flag set to True (enables method chaining)
        """
        self.is_convex = True
        return self
at(nodes: Union[list, tuple])

Apply this constraint only at specific discrete nodes.

Parameters:

Name Type Description Default
nodes Union[list, tuple]

List of node indices where the constraint should be enforced

required

Returns:

Type Description

NodalConstraint wrapping this constraint with node specification

Source code in openscvx/symbolic/expr/constraint.py
def at(self, nodes: Union[list, tuple]):
    """Apply this constraint only at specific discrete nodes.

    Args:
        nodes: List of node indices where the constraint should be enforced

    Returns:
        NodalConstraint wrapping this constraint with node specification
    """
    if isinstance(nodes, int):
        nodes = [nodes]
    return NodalConstraint(self, list(nodes))
canonicalize() -> Expr

Canonicalize constraint to standard form: (lhs - rhs) {op} 0.

This works for both Equality and Inequality by using type(self) to construct the appropriate subclass type.

Source code in openscvx/symbolic/expr/constraint.py
def canonicalize(self) -> "Expr":
    """Canonicalize constraint to standard form: (lhs - rhs) {op} 0.

    This works for both Equality and Inequality by using type(self) to
    construct the appropriate subclass type.
    """
    diff = Sub(self.lhs, self.rhs)
    canon_diff = diff.canonicalize()
    new_constraint = type(self)(canon_diff, Constant(np.array(0)))
    new_constraint.is_convex = self.is_convex  # Preserve convex flag
    return new_constraint
check_shape() -> Tuple[int, ...]

Check that constraint operands are broadcastable. Returns scalar shape.

Source code in openscvx/symbolic/expr/constraint.py
def check_shape(self) -> Tuple[int, ...]:
    """Check that constraint operands are broadcastable. Returns scalar shape."""
    L_shape = self.lhs.check_shape()
    R_shape = self.rhs.check_shape()

    # Figure out their broadcasted shape (or error if incompatible)
    try:
        np.broadcast_shapes(L_shape, R_shape)
    except ValueError as e:
        constraint_type = type(self).__name__
        raise ValueError(f"{constraint_type} not broadcastable: {L_shape} vs {R_shape}") from e

    # Allow vector constraints - they're interpreted element-wise
    # Return () as constraints always produce a scalar
    return ()
convex() -> Constraint

Mark this constraint as convex for CVXPy lowering.

Returns:

Type Description
Constraint

Self with convex flag set to True (enables method chaining)

Source code in openscvx/symbolic/expr/constraint.py
def convex(self) -> "Constraint":
    """Mark this constraint as convex for CVXPy lowering.

    Returns:
        Self with convex flag set to True (enables method chaining)
    """
    self.is_convex = True
    return self
over(interval: tuple[int, int], penalty: str = 'squared_relu', idx: Optional[int] = None, check_nodally: bool = False)

Apply this constraint over a continuous interval using CTCS.

Parameters:

Name Type Description Default
interval tuple[int, int]

Tuple of (start, end) node indices for the continuous interval

required
penalty str

Penalty function type ("squared_relu", "huber", "smooth_relu")

'squared_relu'
idx Optional[int]

Optional grouping index for multiple augmented states

None
check_nodally bool

Whether to also enforce this constraint nodally

False

Returns:

Type Description

CTCS constraint wrapping this constraint with interval specification

Source code in openscvx/symbolic/expr/constraint.py
def over(
    self,
    interval: tuple[int, int],
    penalty: str = "squared_relu",
    idx: Optional[int] = None,
    check_nodally: bool = False,
):
    """Apply this constraint over a continuous interval using CTCS.

    Args:
        interval: Tuple of (start, end) node indices for the continuous interval
        penalty: Penalty function type ("squared_relu", "huber", "smooth_relu")
        idx: Optional grouping index for multiple augmented states
        check_nodally: Whether to also enforce this constraint nodally

    Returns:
        CTCS constraint wrapping this constraint with interval specification
    """
    return CTCS(self, penalty=penalty, nodes=interval, idx=idx, check_nodally=check_nodally)

Control

Bases: Variable

Control input variable for trajectory optimization problems.

Control represents control input variables (actuator commands) in a trajectory optimization problem. Unlike State variables which evolve according to dynamics, Controls are direct decision variables that the optimizer can freely adjust (within specified bounds) at each time step to influence the system dynamics.

Controls are conceptually similar to State variables but simpler - they don't have boundary conditions (initial/final specifications) since controls are typically not constrained at the endpoints. Like States, Controls support:

  • Min/max bounds to enforce actuator limits
  • Initial trajectory guesses to help the optimizer converge

Common examples of control inputs include:

  • Thrust magnitude and direction for spacecraft/rockets
  • Throttle settings for engines
  • Steering angles for vehicles
  • Torques for robotic manipulators
  • Force/acceleration commands

Attributes:

Name Type Description
name str

Unique name identifier for this control variable

_shape tuple[int, ...]

Shape of the control vector (typically 1D like (3,) for 3D thrust)

_slice slice | None

Internal slice information for variable indexing

_min ndarray | None

Minimum bounds for each element of the control

_max ndarray | None

Maximum bounds for each element of the control

_guess ndarray | None

Initial guess for the control trajectory (n_points, n_controls)

Example

Scalar throttle control bounded [0, 1]:

throttle = Control("throttle", shape=(1,))
throttle.min = [0.0]
throttle.max = [1.0]
throttle.guess = np.full((50, 1), 0.5)  # Start at 50% throttle

3D thrust vector for spacecraft:

thrust = Control("thrust", shape=(3,))
thrust.min = [-10, -10, 0]    # No downward thrust
thrust.max = [10, 10, 50]     # Limited thrust
thrust.guess = np.zeros((50, 3))  # Initialize with zero thrust

2D steering control (left/right, forward/backward):

steer = Control("steer", shape=(2,))
steer.min = [-1, -1]
steer.max = [1, 1]
steer.guess = np.linspace([0, 0], [0, 1], 50)  # Gradual acceleration
Source code in openscvx/symbolic/expr/control.py
class Control(Variable):
    """Control input variable for trajectory optimization problems.

    Control represents control input variables (actuator commands) in a trajectory
    optimization problem. Unlike State variables which evolve according to dynamics,
    Controls are direct decision variables that the optimizer can freely adjust
    (within specified bounds) at each time step to influence the system dynamics.

    Controls are conceptually similar to State variables but simpler - they don't
    have boundary conditions (initial/final specifications) since controls are
    typically not constrained at the endpoints. Like States, Controls support:

    - Min/max bounds to enforce actuator limits
    - Initial trajectory guesses to help the optimizer converge

    Common examples of control inputs include:

    - Thrust magnitude and direction for spacecraft/rockets
    - Throttle settings for engines
    - Steering angles for vehicles
    - Torques for robotic manipulators
    - Force/acceleration commands

    Attributes:
        name (str): Unique name identifier for this control variable
        _shape (tuple[int, ...]): Shape of the control vector (typically 1D like (3,) for 3D thrust)
        _slice (slice | None): Internal slice information for variable indexing
        _min (np.ndarray | None): Minimum bounds for each element of the control
        _max (np.ndarray | None): Maximum bounds for each element of the control
        _guess (np.ndarray | None): Initial guess for the control trajectory (n_points, n_controls)

    Example:
        Scalar throttle control bounded [0, 1]:

            throttle = Control("throttle", shape=(1,))
            throttle.min = [0.0]
            throttle.max = [1.0]
            throttle.guess = np.full((50, 1), 0.5)  # Start at 50% throttle

        3D thrust vector for spacecraft:

            thrust = Control("thrust", shape=(3,))
            thrust.min = [-10, -10, 0]    # No downward thrust
            thrust.max = [10, 10, 50]     # Limited thrust
            thrust.guess = np.zeros((50, 3))  # Initialize with zero thrust

        2D steering control (left/right, forward/backward):

            steer = Control("steer", shape=(2,))
            steer.min = [-1, -1]
            steer.max = [1, 1]
            steer.guess = np.linspace([0, 0], [0, 1], 50)  # Gradual acceleration
    """

    def __init__(self, name, shape):
        """Initialize a Control object.

        Args:
            name: Name identifier for the control variable
            shape: Shape of the control vector (typically 1D tuple like (3,))
        """
        super().__init__(name, shape)
        self._scaling_min = None
        self._scaling_max = None

    @property
    def scaling_min(self):
        """Get the scaling minimum bounds for the control variables.

        Returns:
            Array of scaling minimum values for each control variable element, or None if not set.
        """
        return self._scaling_min

    @scaling_min.setter
    def scaling_min(self, val):
        """Set the scaling minimum bounds for the control variables.

        Args:
            val: Array of scaling minimum values, must match the control shape exactly

        Raises:
            ValueError: If the shape doesn't match the control shape
        """
        if val is None:
            self._scaling_min = None
            return
        val = np.asarray(val, dtype=float)
        if val.shape != self.shape:
            raise ValueError(
                f"Scaling min shape {val.shape} does not match Control shape {self.shape}"
            )
        self._scaling_min = val

    @property
    def scaling_max(self):
        """Get the scaling maximum bounds for the control variables.

        Returns:
            Array of scaling maximum values for each control variable element, or None if not set.
        """
        return self._scaling_max

    @scaling_max.setter
    def scaling_max(self, val):
        """Set the scaling maximum bounds for the control variables.

        Args:
            val: Array of scaling maximum values, must match the control shape exactly

        Raises:
            ValueError: If the shape doesn't match the control shape
        """
        if val is None:
            self._scaling_max = None
            return
        val = np.asarray(val, dtype=float)
        if val.shape != self.shape:
            raise ValueError(
                f"Scaling max shape {val.shape} does not match Control shape {self.shape}"
            )
        self._scaling_max = val

    def __repr__(self):
        """String representation of the Control object.

        Returns:
            Concise string showing the control name and shape.
        """
        return f"Control('{self.name}', shape={self.shape})"
scaling_max property writable

Get the scaling maximum bounds for the control variables.

Returns:

Type Description

Array of scaling maximum values for each control variable element, or None if not set.

scaling_min property writable

Get the scaling minimum bounds for the control variables.

Returns:

Type Description

Array of scaling minimum values for each control variable element, or None if not set.

Cos

Bases: Expr

Element-wise cosine function for symbolic expressions.

Computes the cosine of each element in the operand. Preserves the shape of the input expression.

Attributes:

Name Type Description
operand

Expression to apply cosine function to

Example

Define a Cos expression:

theta = Variable("theta", shape=(3,))
cos_theta = Cos(theta)
Source code in openscvx/symbolic/expr/math.py
class Cos(Expr):
    """Element-wise cosine function for symbolic expressions.

    Computes the cosine of each element in the operand. Preserves the shape
    of the input expression.

    Attributes:
        operand: Expression to apply cosine function to

    Example:
        Define a Cos expression:

            theta = Variable("theta", shape=(3,))
            cos_theta = Cos(theta)
    """

    def __init__(self, operand):
        """Initialize a cosine operation.

        Args:
            operand: Expression to apply cosine function to
        """
        self.operand = operand

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Cos(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Cos preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"(cos({self.operand!r}))"
check_shape() -> Tuple[int, ...]

Cos preserves the shape of its operand.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Cos preserves the shape of its operand."""
    return self.operand.check_shape()

CrossNodeConstraint

Bases: Expr

A constraint that couples specific trajectory nodes via .at(k) references.

Unlike NodalConstraint which applies a constraint pattern at multiple nodes (via vmapping), CrossNodeConstraint is a single constraint with fixed node indices embedded in the expression via NodeReference nodes.

CrossNodeConstraint is created automatically when a bare Constraint contains NodeReference nodes (from .at(k) calls). Users should NOT manually wrap cross-node constraints - they are auto-detected during constraint separation.

Key differences from NodalConstraint:

  • NodalConstraint: Same constraint evaluated at multiple nodes via vmapping. Signature: (x, u, node, params) → scalar, vmapped to (N, n_x) inputs.
  • CrossNodeConstraint: Single constraint coupling specific fixed nodes. Signature: (X, U, params) → scalar, operates on full trajectory arrays.

Lowering:

  • Non-convex: Lowered to JAX with automatic differentiation for SCP linearization
  • Convex: Lowered to CVXPy and solved directly by the convex solver

Attributes:

Name Type Description
constraint

The wrapped Constraint containing NodeReference nodes

Example

Rate limit constraint (auto-detected as CrossNodeConstraint):

position = State("pos", shape=(3,))

# This creates a CrossNodeConstraint automatically:
rate_limit = position.at(5) - position.at(4) <= 0.1

# Mark as convex if the constraint is convex:
rate_limit_convex = (position.at(5) - position.at(4) <= 0.1).convex()

Creating multiple cross-node constraints with a loop:

constraints = []
for k in range(1, N):
    # Each iteration creates one CrossNodeConstraint
    rate_limit = position.at(k) - position.at(k-1) <= max_step
    constraints.append(rate_limit)
Note

Do NOT use .at([...]) on cross-node constraints. The nodes are already specified via .at(k) inside the expression. Using .at([...]) will raise an error during constraint separation.

Source code in openscvx/symbolic/expr/constraint.py
class CrossNodeConstraint(Expr):
    """A constraint that couples specific trajectory nodes via .at(k) references.

    Unlike NodalConstraint which applies a constraint pattern at multiple nodes
    (via vmapping), CrossNodeConstraint is a single constraint with fixed node
    indices embedded in the expression via NodeReference nodes.

    CrossNodeConstraint is created automatically when a bare Constraint contains
    NodeReference nodes (from .at(k) calls). Users should NOT manually wrap
    cross-node constraints - they are auto-detected during constraint separation.

    **Key differences from NodalConstraint:**

    - **NodalConstraint**: Same constraint evaluated at multiple nodes via vmapping.
      Signature: (x, u, node, params) → scalar, vmapped to (N, n_x) inputs.
    - **CrossNodeConstraint**: Single constraint coupling specific fixed nodes.
      Signature: (X, U, params) → scalar, operates on full trajectory arrays.

    **Lowering:**

    - **Non-convex**: Lowered to JAX with automatic differentiation for SCP linearization
    - **Convex**: Lowered to CVXPy and solved directly by the convex solver

    Attributes:
        constraint: The wrapped Constraint containing NodeReference nodes

    Example:
        Rate limit constraint (auto-detected as CrossNodeConstraint):

            position = State("pos", shape=(3,))

            # This creates a CrossNodeConstraint automatically:
            rate_limit = position.at(5) - position.at(4) <= 0.1

            # Mark as convex if the constraint is convex:
            rate_limit_convex = (position.at(5) - position.at(4) <= 0.1).convex()

        Creating multiple cross-node constraints with a loop:

            constraints = []
            for k in range(1, N):
                # Each iteration creates one CrossNodeConstraint
                rate_limit = position.at(k) - position.at(k-1) <= max_step
                constraints.append(rate_limit)

    Note:
        Do NOT use .at([...]) on cross-node constraints. The nodes are already
        specified via .at(k) inside the expression. Using .at([...]) will raise
        an error during constraint separation.
    """

    def __init__(self, constraint: Constraint):
        """Initialize a CrossNodeConstraint.

        Args:
            constraint: The Constraint containing NodeReference nodes.
                Must contain at least one NodeReference (from .at(k) calls).

        Raises:
            TypeError: If constraint is not a Constraint instance
        """
        if not isinstance(constraint, Constraint):
            raise TypeError("CrossNodeConstraint must wrap a Constraint")

        self.constraint = constraint

    @property
    def is_convex(self) -> bool:
        """Whether the underlying constraint is marked as convex.

        Returns:
            bool: True if the constraint is convex, False otherwise
        """
        return self.constraint.is_convex

    def children(self):
        """Return the wrapped constraint as the only child.

        Returns:
            list: Single-element list containing the wrapped constraint
        """
        return [self.constraint]

    def canonicalize(self) -> "Expr":
        """Canonicalize the wrapped constraint.

        Returns:
            CrossNodeConstraint: A new CrossNodeConstraint with canonicalized inner constraint
        """
        canon_constraint = self.constraint.canonicalize()
        return CrossNodeConstraint(canon_constraint)

    def check_shape(self) -> Tuple[int, ...]:
        """Validate the wrapped constraint's shape.

        Returns:
            tuple: Empty tuple () representing scalar shape
        """
        self.constraint.check_shape()
        return ()

    def convex(self) -> "CrossNodeConstraint":
        """Mark the underlying constraint as convex for CVXPy lowering.

        Returns:
            Self with underlying constraint's convex flag set to True
        """
        self.constraint.convex()
        return self

    def __repr__(self):
        """String representation of the CrossNodeConstraint.

        Returns:
            str: String showing the wrapped constraint
        """
        return f"CrossNodeConstraint({self.constraint!r})"
is_convex: bool property

Whether the underlying constraint is marked as convex.

Returns:

Name Type Description
bool bool

True if the constraint is convex, False otherwise

canonicalize() -> Expr

Canonicalize the wrapped constraint.

Returns:

Name Type Description
CrossNodeConstraint Expr

A new CrossNodeConstraint with canonicalized inner constraint

Source code in openscvx/symbolic/expr/constraint.py
def canonicalize(self) -> "Expr":
    """Canonicalize the wrapped constraint.

    Returns:
        CrossNodeConstraint: A new CrossNodeConstraint with canonicalized inner constraint
    """
    canon_constraint = self.constraint.canonicalize()
    return CrossNodeConstraint(canon_constraint)
check_shape() -> Tuple[int, ...]

Validate the wrapped constraint's shape.

Returns:

Name Type Description
tuple Tuple[int, ...]

Empty tuple () representing scalar shape

Source code in openscvx/symbolic/expr/constraint.py
def check_shape(self) -> Tuple[int, ...]:
    """Validate the wrapped constraint's shape.

    Returns:
        tuple: Empty tuple () representing scalar shape
    """
    self.constraint.check_shape()
    return ()
children()

Return the wrapped constraint as the only child.

Returns:

Name Type Description
list

Single-element list containing the wrapped constraint

Source code in openscvx/symbolic/expr/constraint.py
def children(self):
    """Return the wrapped constraint as the only child.

    Returns:
        list: Single-element list containing the wrapped constraint
    """
    return [self.constraint]
convex() -> CrossNodeConstraint

Mark the underlying constraint as convex for CVXPy lowering.

Returns:

Type Description
CrossNodeConstraint

Self with underlying constraint's convex flag set to True

Source code in openscvx/symbolic/expr/constraint.py
def convex(self) -> "CrossNodeConstraint":
    """Mark the underlying constraint as convex for CVXPy lowering.

    Returns:
        Self with underlying constraint's convex flag set to True
    """
    self.constraint.convex()
    return self

Diag

Bases: Expr

Diagonal matrix construction from a vector.

Creates a square diagonal matrix from a 1D vector. The vector elements become the diagonal entries, with all off-diagonal entries set to zero. This is analogous to numpy.diag() or jax.numpy.diag().

Note

Currently only supports creating diagonal matrices from vectors. Extracting diagonals from matrices is not yet implemented.

Attributes:

Name Type Description
operand

1D vector expression to place on the diagonal

Example

Define a Diag:

v = Variable("v", shape=(3,))
D = Diag(v)  # Creates a (3, 3) diagonal matrix
Source code in openscvx/symbolic/expr/linalg.py
class Diag(Expr):
    """Diagonal matrix construction from a vector.

    Creates a square diagonal matrix from a 1D vector. The vector elements become
    the diagonal entries, with all off-diagonal entries set to zero. This is
    analogous to numpy.diag() or jax.numpy.diag().

    Note:
        Currently only supports creating diagonal matrices from vectors.
        Extracting diagonals from matrices is not yet implemented.

    Attributes:
        operand: 1D vector expression to place on the diagonal

    Example:
        Define a Diag:

            v = Variable("v", shape=(3,))
            D = Diag(v)  # Creates a (3, 3) diagonal matrix
    """

    def __init__(self, operand):
        """Initialize a diagonal matrix operation.

        Args:
            operand: 1D vector expression to place on the diagonal
        """
        self.operand = to_expr(operand)

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Diag(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Diag converts a vector (n,) to a diagonal matrix (n,n)."""
        operand_shape = self.operand.check_shape()
        if len(operand_shape) != 1:
            raise ValueError(f"Diag expects a 1D vector, got shape {operand_shape}")
        n = operand_shape[0]
        return (n, n)

    def __repr__(self):
        return f"diag({self.operand!r})"
check_shape() -> Tuple[int, ...]

Diag converts a vector (n,) to a diagonal matrix (n,n).

Source code in openscvx/symbolic/expr/linalg.py
def check_shape(self) -> Tuple[int, ...]:
    """Diag converts a vector (n,) to a diagonal matrix (n,n)."""
    operand_shape = self.operand.check_shape()
    if len(operand_shape) != 1:
        raise ValueError(f"Diag expects a 1D vector, got shape {operand_shape}")
    n = operand_shape[0]
    return (n, n)

Div

Bases: Expr

Element-wise division operation for symbolic expressions.

Represents element-wise division (left / right). Supports broadcasting following NumPy rules. Can be created using the / operator on Expr objects.

Attributes:

Name Type Description
left

Numerator expression

right

Denominator expression

Example

Define a Div expression

x = ox.State("x", shape=(3,))
y = ox.State("y", shape=(3,))
z = x / y  # Creates Div(x, y)
Source code in openscvx/symbolic/expr/arithmetic.py
class Div(Expr):
    """Element-wise division operation for symbolic expressions.

    Represents element-wise division (left / right). Supports broadcasting
    following NumPy rules. Can be created using the / operator on Expr objects.

    Attributes:
        left: Numerator expression
        right: Denominator expression

    Example:
        Define a Div expression

            x = ox.State("x", shape=(3,))
            y = ox.State("y", shape=(3,))
            z = x / y  # Creates Div(x, y)
    """

    def __init__(self, left, right):
        """Initialize a division operation.

        Args:
            left: Expression for the numerator
            right: Expression for the denominator
        """
        self.left = left
        self.right = right

    def children(self):
        return [self.left, self.right]

    def canonicalize(self) -> "Expr":
        """Canonicalize division: fold constants if both sides are constants.

        Returns:
            Expr: Canonical form of the division expression
        """
        lhs = self.left.canonicalize()
        rhs = self.right.canonicalize()
        if isinstance(lhs, Constant) and isinstance(rhs, Constant):
            return Constant(lhs.value / rhs.value)
        return Div(lhs, rhs)

    def check_shape(self) -> Tuple[int, ...]:
        """Check shape compatibility and compute broadcasted result shape like NumPy.

        Returns:
            tuple: The broadcasted shape of both operands

        Raises:
            ValueError: If operand shapes are not broadcastable
        """
        shapes = [child.check_shape() for child in self.children()]
        try:
            return np.broadcast_shapes(*shapes)
        except ValueError as e:
            raise ValueError(f"Div shapes not broadcastable: {shapes}") from e

    def __repr__(self):
        return f"({self.left!r} / {self.right!r})"
canonicalize() -> Expr

Canonicalize division: fold constants if both sides are constants.

Returns:

Name Type Description
Expr Expr

Canonical form of the division expression

Source code in openscvx/symbolic/expr/arithmetic.py
def canonicalize(self) -> "Expr":
    """Canonicalize division: fold constants if both sides are constants.

    Returns:
        Expr: Canonical form of the division expression
    """
    lhs = self.left.canonicalize()
    rhs = self.right.canonicalize()
    if isinstance(lhs, Constant) and isinstance(rhs, Constant):
        return Constant(lhs.value / rhs.value)
    return Div(lhs, rhs)
check_shape() -> Tuple[int, ...]

Check shape compatibility and compute broadcasted result shape like NumPy.

Returns:

Name Type Description
tuple Tuple[int, ...]

The broadcasted shape of both operands

Raises:

Type Description
ValueError

If operand shapes are not broadcastable

Source code in openscvx/symbolic/expr/arithmetic.py
def check_shape(self) -> Tuple[int, ...]:
    """Check shape compatibility and compute broadcasted result shape like NumPy.

    Returns:
        tuple: The broadcasted shape of both operands

    Raises:
        ValueError: If operand shapes are not broadcastable
    """
    shapes = [child.check_shape() for child in self.children()]
    try:
        return np.broadcast_shapes(*shapes)
    except ValueError as e:
        raise ValueError(f"Div shapes not broadcastable: {shapes}") from e

Equality

Bases: Constraint

Equality constraint for optimization problems.

Represents an equality constraint: lhs == rhs. Can be created using the == operator on Expr objects.

Example

Define an Equality constraint:

x = ox.State("x", shape=(3,))
constraint = x == 0  # Creates Equality(x, Constant(0))
Source code in openscvx/symbolic/expr/constraint.py
class Equality(Constraint):
    """Equality constraint for optimization problems.

    Represents an equality constraint: lhs == rhs. Can be created using the ==
    operator on Expr objects.

    Example:
        Define an Equality constraint:

            x = ox.State("x", shape=(3,))
            constraint = x == 0  # Creates Equality(x, Constant(0))
    """

    def __repr__(self):
        return f"{self.lhs!r} == {self.rhs!r}"

Exp

Bases: Expr

Element-wise exponential function for symbolic expressions.

Computes e^x for each element in the operand, where e is Euler's number. Preserves the shape of the input expression.

Attributes:

Name Type Description
operand

Expression to apply exponential function to

Example

Define an Exp expression:

x = Variable("x", shape=(3,))
exp_x = Exp(x)
Source code in openscvx/symbolic/expr/math.py
class Exp(Expr):
    """Element-wise exponential function for symbolic expressions.

    Computes e^x for each element in the operand, where e is Euler's number.
    Preserves the shape of the input expression.

    Attributes:
        operand: Expression to apply exponential function to

    Example:
        Define an Exp expression:

            x = Variable("x", shape=(3,))
            exp_x = Exp(x)
    """

    def __init__(self, operand):
        """Initialize an exponential operation.

        Args:
            operand: Expression to apply exponential function to
        """
        self.operand = to_expr(operand)

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Exp(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Exp preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"exp({self.operand!r})"
check_shape() -> Tuple[int, ...]

Exp preserves the shape of its operand.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Exp preserves the shape of its operand."""
    return self.operand.check_shape()

Expr

Base class for symbolic expressions in optimization problems.

Expr is the foundation of the symbolic expression system in openscvx. It represents nodes in an abstract syntax tree (AST) for mathematical expressions. Expressions support:

  • Arithmetic operations: +, -, *, /, @, **
  • Comparison operations: ==, <=, >=
  • Indexing and slicing: []
  • Transposition: .T property
  • Shape checking and validation
  • Canonicalization (algebraic simplification)

All Expr subclasses implement a tree structure where each node can have child expressions accessed via the children() method.

Attributes:

Name Type Description
__array_priority__

Priority for operations with numpy arrays (set to 1000)

Note

When used in operations with numpy arrays, Expr objects take precedence, allowing symbolic expressions to wrap numeric values automatically.

Source code in openscvx/symbolic/expr/expr.py
class Expr:
    """Base class for symbolic expressions in optimization problems.

    Expr is the foundation of the symbolic expression system in openscvx. It represents
    nodes in an abstract syntax tree (AST) for mathematical expressions. Expressions
    support:

    - Arithmetic operations: +, -, *, /, @, **
    - Comparison operations: ==, <=, >=
    - Indexing and slicing: []
    - Transposition: .T property
    - Shape checking and validation
    - Canonicalization (algebraic simplification)

    All Expr subclasses implement a tree structure where each node can have child
    expressions accessed via the children() method.

    Attributes:
        __array_priority__: Priority for operations with numpy arrays (set to 1000)

    Note:
        When used in operations with numpy arrays, Expr objects take precedence,
        allowing symbolic expressions to wrap numeric values automatically.
    """

    # Give Expr objects higher priority than numpy arrays in operations
    __array_priority__ = 1000

    def __le__(self, other):
        from .constraint import Inequality

        return Inequality(self, to_expr(other))

    def __ge__(self, other):
        from .constraint import Inequality

        return Inequality(to_expr(other), self)

    def __eq__(self, other):
        from .constraint import Equality

        return Equality(self, to_expr(other))

    def __add__(self, other):
        from .arithmetic import Add

        return Add(self, to_expr(other))

    def __radd__(self, other):
        from .arithmetic import Add

        return Add(to_expr(other), self)

    def __sub__(self, other):
        from .arithmetic import Sub

        return Sub(self, to_expr(other))

    def __rsub__(self, other):
        # e.g. 5 - a  ⇒ Sub(Constant(5), a)
        from .arithmetic import Sub

        return Sub(to_expr(other), self)

    def __truediv__(self, other):
        from .arithmetic import Div

        return Div(self, to_expr(other))

    def __rtruediv__(self, other):
        # e.g. 10 / a
        from .arithmetic import Div

        return Div(to_expr(other), self)

    def __mul__(self, other):
        from .arithmetic import Mul

        return Mul(self, to_expr(other))

    def __rmul__(self, other):
        from .arithmetic import Mul

        return Mul(to_expr(other), self)

    def __matmul__(self, other):
        from .arithmetic import MatMul

        return MatMul(self, to_expr(other))

    def __rmatmul__(self, other):
        from .arithmetic import MatMul

        return MatMul(to_expr(other), self)

    def __rle__(self, other):
        # other <= self  =>  Inequality(other, self)
        from .constraint import Inequality

        return Inequality(to_expr(other), self)

    def __rge__(self, other):
        # other >= self  =>  Inequality(self, other)
        from .constraint import Inequality

        return Inequality(self, to_expr(other))

    def __req__(self, other):
        # other == self  =>  Equality(other, self)
        from .constraint import Equality

        return Equality(to_expr(other), self)

    def __neg__(self):
        from .arithmetic import Neg

        return Neg(self)

    def __pow__(self, other):
        from .arithmetic import Power

        return Power(self, to_expr(other))

    def __rpow__(self, other):
        from .arithmetic import Power

        return Power(to_expr(other), self)

    def __getitem__(self, idx):
        from .array import Index

        return Index(self, idx)

    @property
    def T(self):
        """Transpose property for matrix expressions.

        Returns:
            Transpose: A Transpose expression wrapping this expression

        Example:
            Create a transpose:

                A = ox.State("A", shape=(3, 4))
                A_T = A.T  # Creates Transpose(A), result shape (4, 3)
        """
        from .linalg import Transpose

        return Transpose(self)

    def at(self, k: int) -> "NodeReference":
        """Reference this expression at a specific trajectory node.

        This method enables inter-node constraints where you can reference
        the value of an expression at different time steps. Common patterns
        include rate limits and multi-step dependencies.

        Args:
            k: Absolute node index (integer) in the trajectory.
               Can be positive (0, 1, 2, ...) or negative (-1 for last node).

        Returns:
            NodeReference: An expression representing this expression at node k

        Example:
            Rate limit constraint (applied across trajectory using a loop):

                position = State("pos", shape=(3,))

                # Create rate limit for each node
                constraints = [
                    (ox.linalg.Norm(position.at(k) - position.at(k-1)) <= 0.1).at([k])
                    for k in range(1, N)
                ]

            Multi-step dependency:

                state = State("x", shape=(1,))

                # Fibonacci-like recurrence
                constraints = [
                    (state.at(k) == state.at(k-1) + state.at(k-2)).at([k])
                    for k in range(2, N)
                ]

        Performance Note:
            Cross-node constraints use dense Jacobian storage which can be memory-intensive
            for large N (>100 nodes). See LoweredCrossNodeConstraint documentation for
            details on memory usage and future sparse Jacobian support.
        """
        return NodeReference(self, k)

    def children(self):
        """Return the child expressions of this node.

        Returns:
            list: List of child Expr objects. Empty list for leaf nodes.
        """
        return []

    def canonicalize(self) -> "Expr":
        """
        Return a canonical (simplified) form of this expression.

        Canonicalization performs algebraic simplifications such as:
        - Constant folding (e.g., 2 + 3 → 5)
        - Identity elimination (e.g., x + 0 → x, x * 1 → x)
        - Flattening nested operations (e.g., Add(Add(a, b), c) → Add(a, b, c))
        - Algebraic rewrites (e.g., constraints to standard form)

        Returns:
            Expr: A canonical version of this expression

        Raises:
            NotImplementedError: If canonicalization is not implemented for this node type
        """
        raise NotImplementedError(f"canonicalize() not implemented for {self.__class__.__name__}")

    def check_shape(self) -> Tuple[int, ...]:
        """
        Compute and validate the shape of this expression.

        This method:
        1. Recursively checks shapes of all child expressions
        2. Validates that operations are shape-compatible (e.g., broadcasting rules)
        3. Returns the output shape of this expression

        For example:
        - A Parameter with shape (3, 4) returns (3, 4)
        - MatMul of (3, 4) @ (4, 5) returns (3, 5)
        - Sum of any shape returns () (scalar)
        - Add broadcasts shapes like NumPy

        Returns:
            tuple: The shape of this expression as a tuple of integers.
                   Empty tuple () represents a scalar.

        Raises:
            NotImplementedError: If shape checking is not implemented for this node type
            ValueError: If the expression has invalid shapes (e.g., incompatible dimensions)
        """
        raise NotImplementedError(f"check_shape() not implemented for {self.__class__.__name__}")

    def pretty(self, indent=0):
        """Generate a pretty-printed string representation of the expression tree.

        Creates an indented, hierarchical view of the expression tree structure,
        useful for debugging and visualization.

        Args:
            indent: Current indentation level (default: 0)

        Returns:
            str: Multi-line string representation of the expression tree

        Example:
            Pretty print an expression:

                expr = (x + y) * z
                print(expr.pretty())
                # Mul
                #   Add
                #     State
                #     State
                #   State
        """
        pad = "  " * indent
        pad = "  " * indent
        lines = [f"{pad}{self.__class__.__name__}"]
        for child in self.children():
            lines.append(child.pretty(indent + 1))
        return "\n".join(lines)

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Contribute this expression's structural identity to a hash.

        This method is used to compute a structural hash of the expression tree
        that is name-invariant (same structure = same hash regardless of variable names).

        The default implementation hashes the class name and recursively hashes all
        children. Subclasses with additional attributes (like Norm.ord, Index.index)
        should override this to include those attributes.

        Args:
            hasher: A hashlib hash object to update
        """
        # Hash the class name to distinguish different node types
        hasher.update(self.__class__.__name__.encode())
        # Recursively hash all children
        for child in self.children():
            child._hash_into(hasher)

    def structural_hash(self) -> bytes:
        """Compute a structural hash of this expression.

        Returns a hash that depends only on the mathematical structure of the
        expression, not on variable names. Two expressions that are structurally
        equivalent (same operations, same variable positions) will have the same hash.

        Returns:
            bytes: SHA-256 digest of the expression structure
        """
        hasher = hashlib.sha256()
        self._hash_into(hasher)
        return hasher.digest()
T property

Transpose property for matrix expressions.

Returns:

Name Type Description
Transpose

A Transpose expression wrapping this expression

Example

Create a transpose:

A = ox.State("A", shape=(3, 4))
A_T = A.T  # Creates Transpose(A), result shape (4, 3)
_hash_into(hasher: hashlib._Hash) -> None

Contribute this expression's structural identity to a hash.

This method is used to compute a structural hash of the expression tree that is name-invariant (same structure = same hash regardless of variable names).

The default implementation hashes the class name and recursively hashes all children. Subclasses with additional attributes (like Norm.ord, Index.index) should override this to include those attributes.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/expr.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Contribute this expression's structural identity to a hash.

    This method is used to compute a structural hash of the expression tree
    that is name-invariant (same structure = same hash regardless of variable names).

    The default implementation hashes the class name and recursively hashes all
    children. Subclasses with additional attributes (like Norm.ord, Index.index)
    should override this to include those attributes.

    Args:
        hasher: A hashlib hash object to update
    """
    # Hash the class name to distinguish different node types
    hasher.update(self.__class__.__name__.encode())
    # Recursively hash all children
    for child in self.children():
        child._hash_into(hasher)
at(k: int) -> NodeReference

Reference this expression at a specific trajectory node.

This method enables inter-node constraints where you can reference the value of an expression at different time steps. Common patterns include rate limits and multi-step dependencies.

Parameters:

Name Type Description Default
k int

Absolute node index (integer) in the trajectory. Can be positive (0, 1, 2, ...) or negative (-1 for last node).

required

Returns:

Name Type Description
NodeReference NodeReference

An expression representing this expression at node k

Example

Rate limit constraint (applied across trajectory using a loop):

position = State("pos", shape=(3,))

# Create rate limit for each node
constraints = [
    (ox.linalg.Norm(position.at(k) - position.at(k-1)) <= 0.1).at([k])
    for k in range(1, N)
]

Multi-step dependency:

state = State("x", shape=(1,))

# Fibonacci-like recurrence
constraints = [
    (state.at(k) == state.at(k-1) + state.at(k-2)).at([k])
    for k in range(2, N)
]
Performance Note

Cross-node constraints use dense Jacobian storage which can be memory-intensive for large N (>100 nodes). See LoweredCrossNodeConstraint documentation for details on memory usage and future sparse Jacobian support.

Source code in openscvx/symbolic/expr/expr.py
def at(self, k: int) -> "NodeReference":
    """Reference this expression at a specific trajectory node.

    This method enables inter-node constraints where you can reference
    the value of an expression at different time steps. Common patterns
    include rate limits and multi-step dependencies.

    Args:
        k: Absolute node index (integer) in the trajectory.
           Can be positive (0, 1, 2, ...) or negative (-1 for last node).

    Returns:
        NodeReference: An expression representing this expression at node k

    Example:
        Rate limit constraint (applied across trajectory using a loop):

            position = State("pos", shape=(3,))

            # Create rate limit for each node
            constraints = [
                (ox.linalg.Norm(position.at(k) - position.at(k-1)) <= 0.1).at([k])
                for k in range(1, N)
            ]

        Multi-step dependency:

            state = State("x", shape=(1,))

            # Fibonacci-like recurrence
            constraints = [
                (state.at(k) == state.at(k-1) + state.at(k-2)).at([k])
                for k in range(2, N)
            ]

    Performance Note:
        Cross-node constraints use dense Jacobian storage which can be memory-intensive
        for large N (>100 nodes). See LoweredCrossNodeConstraint documentation for
        details on memory usage and future sparse Jacobian support.
    """
    return NodeReference(self, k)
canonicalize() -> Expr

Return a canonical (simplified) form of this expression.

Canonicalization performs algebraic simplifications such as: - Constant folding (e.g., 2 + 3 → 5) - Identity elimination (e.g., x + 0 → x, x * 1 → x) - Flattening nested operations (e.g., Add(Add(a, b), c) → Add(a, b, c)) - Algebraic rewrites (e.g., constraints to standard form)

Returns:

Name Type Description
Expr Expr

A canonical version of this expression

Raises:

Type Description
NotImplementedError

If canonicalization is not implemented for this node type

Source code in openscvx/symbolic/expr/expr.py
def canonicalize(self) -> "Expr":
    """
    Return a canonical (simplified) form of this expression.

    Canonicalization performs algebraic simplifications such as:
    - Constant folding (e.g., 2 + 3 → 5)
    - Identity elimination (e.g., x + 0 → x, x * 1 → x)
    - Flattening nested operations (e.g., Add(Add(a, b), c) → Add(a, b, c))
    - Algebraic rewrites (e.g., constraints to standard form)

    Returns:
        Expr: A canonical version of this expression

    Raises:
        NotImplementedError: If canonicalization is not implemented for this node type
    """
    raise NotImplementedError(f"canonicalize() not implemented for {self.__class__.__name__}")
check_shape() -> Tuple[int, ...]

Compute and validate the shape of this expression.

This method: 1. Recursively checks shapes of all child expressions 2. Validates that operations are shape-compatible (e.g., broadcasting rules) 3. Returns the output shape of this expression

For example: - A Parameter with shape (3, 4) returns (3, 4) - MatMul of (3, 4) @ (4, 5) returns (3, 5) - Sum of any shape returns () (scalar) - Add broadcasts shapes like NumPy

Returns:

Name Type Description
tuple Tuple[int, ...]

The shape of this expression as a tuple of integers. Empty tuple () represents a scalar.

Raises:

Type Description
NotImplementedError

If shape checking is not implemented for this node type

ValueError

If the expression has invalid shapes (e.g., incompatible dimensions)

Source code in openscvx/symbolic/expr/expr.py
def check_shape(self) -> Tuple[int, ...]:
    """
    Compute and validate the shape of this expression.

    This method:
    1. Recursively checks shapes of all child expressions
    2. Validates that operations are shape-compatible (e.g., broadcasting rules)
    3. Returns the output shape of this expression

    For example:
    - A Parameter with shape (3, 4) returns (3, 4)
    - MatMul of (3, 4) @ (4, 5) returns (3, 5)
    - Sum of any shape returns () (scalar)
    - Add broadcasts shapes like NumPy

    Returns:
        tuple: The shape of this expression as a tuple of integers.
               Empty tuple () represents a scalar.

    Raises:
        NotImplementedError: If shape checking is not implemented for this node type
        ValueError: If the expression has invalid shapes (e.g., incompatible dimensions)
    """
    raise NotImplementedError(f"check_shape() not implemented for {self.__class__.__name__}")
children()

Return the child expressions of this node.

Returns:

Name Type Description
list

List of child Expr objects. Empty list for leaf nodes.

Source code in openscvx/symbolic/expr/expr.py
def children(self):
    """Return the child expressions of this node.

    Returns:
        list: List of child Expr objects. Empty list for leaf nodes.
    """
    return []
pretty(indent=0)

Generate a pretty-printed string representation of the expression tree.

Creates an indented, hierarchical view of the expression tree structure, useful for debugging and visualization.

Parameters:

Name Type Description Default
indent

Current indentation level (default: 0)

0

Returns:

Name Type Description
str

Multi-line string representation of the expression tree

Example

Pretty print an expression:

expr = (x + y) * z
print(expr.pretty())
# Mul
#   Add
#     State
#     State
#   State
Source code in openscvx/symbolic/expr/expr.py
def pretty(self, indent=0):
    """Generate a pretty-printed string representation of the expression tree.

    Creates an indented, hierarchical view of the expression tree structure,
    useful for debugging and visualization.

    Args:
        indent: Current indentation level (default: 0)

    Returns:
        str: Multi-line string representation of the expression tree

    Example:
        Pretty print an expression:

            expr = (x + y) * z
            print(expr.pretty())
            # Mul
            #   Add
            #     State
            #     State
            #   State
    """
    pad = "  " * indent
    pad = "  " * indent
    lines = [f"{pad}{self.__class__.__name__}"]
    for child in self.children():
        lines.append(child.pretty(indent + 1))
    return "\n".join(lines)
structural_hash() -> bytes

Compute a structural hash of this expression.

Returns a hash that depends only on the mathematical structure of the expression, not on variable names. Two expressions that are structurally equivalent (same operations, same variable positions) will have the same hash.

Returns:

Name Type Description
bytes bytes

SHA-256 digest of the expression structure

Source code in openscvx/symbolic/expr/expr.py
def structural_hash(self) -> bytes:
    """Compute a structural hash of this expression.

    Returns a hash that depends only on the mathematical structure of the
    expression, not on variable names. Two expressions that are structurally
    equivalent (same operations, same variable positions) will have the same hash.

    Returns:
        bytes: SHA-256 digest of the expression structure
    """
    hasher = hashlib.sha256()
    self._hash_into(hasher)
    return hasher.digest()

Hstack

Bases: Expr

Horizontal stacking operation for symbolic expressions.

Concatenates expressions horizontally (along columns for 2D arrays). This is analogous to numpy.hstack() or jax.numpy.hstack().

Behavior depends on input dimensionality: - 1D arrays: Concatenates along axis 0 (making a longer vector) - 2D arrays: Concatenates along axis 1 (columns), rows must match - Higher-D: Concatenates along axis 1, all other dimensions must match

Attributes:

Name Type Description
arrays

List of expressions to stack horizontally

Example

1D case: concatenate vectors:

x = Variable("x", shape=(3,))
y = Variable("y", shape=(2,))
h = Hstack([x, y])  # Result shape (5,)

2D case: concatenate matrices horizontally:

A = Variable("A", shape=(3, 4))
B = Variable("B", shape=(3, 2))
C = Hstack([A, B])  # Result shape (3, 6)
Source code in openscvx/symbolic/expr/array.py
class Hstack(Expr):
    """Horizontal stacking operation for symbolic expressions.

    Concatenates expressions horizontally (along columns for 2D arrays).
    This is analogous to numpy.hstack() or jax.numpy.hstack().

    Behavior depends on input dimensionality:
    - 1D arrays: Concatenates along axis 0 (making a longer vector)
    - 2D arrays: Concatenates along axis 1 (columns), rows must match
    - Higher-D: Concatenates along axis 1, all other dimensions must match

    Attributes:
        arrays: List of expressions to stack horizontally

    Example:
        1D case: concatenate vectors:

            x = Variable("x", shape=(3,))
            y = Variable("y", shape=(2,))
            h = Hstack([x, y])  # Result shape (5,)

        2D case: concatenate matrices horizontally:

            A = Variable("A", shape=(3, 4))
            B = Variable("B", shape=(3, 2))
            C = Hstack([A, B])  # Result shape (3, 6)
    """

    def __init__(self, arrays):
        """Initialize a horizontal stack operation.

        Args:
            arrays: List of expressions to concatenate horizontally
        """
        self.arrays = [to_expr(arr) for arr in arrays]

    def children(self):
        return self.arrays

    def canonicalize(self) -> "Expr":
        arrays = [arr.canonicalize() for arr in self.arrays]
        return Hstack(arrays)

    def check_shape(self) -> Tuple[int, ...]:
        """Horizontal stack concatenates arrays along the second axis (columns)."""
        if not self.arrays:
            raise ValueError("Hstack requires at least one array")

        array_shapes = [arr.check_shape() for arr in self.arrays]

        # All arrays must have the same number of dimensions
        first_ndim = len(array_shapes[0])
        for i, shape in enumerate(array_shapes[1:], 1):
            if len(shape) != first_ndim:
                raise ValueError(
                    f"Hstack array {i} has {len(shape)} dimensions, but array 0 has {first_ndim}"
                )

        # For 1D arrays, hstack concatenates along axis 0
        if first_ndim == 1:
            total_length = sum(shape[0] for shape in array_shapes)
            return (total_length,)

        # For 2D+ arrays, all dimensions except the second must match
        first_shape = array_shapes[0]
        for i, shape in enumerate(array_shapes[1:], 1):
            if shape[0] != first_shape[0]:
                raise ValueError(
                    f"Hstack array {i} has {shape[0]} rows, but array 0 has {first_shape[0]} rows"
                )
            if shape[2:] != first_shape[2:]:
                raise ValueError(
                    f"Hstack array {i} has trailing dimensions {shape[2:]}, "
                    f"but array 0 has {first_shape[2:]}"
                )

        # Result shape: concatenate along axis 1 (columns)
        total_cols = sum(shape[1] for shape in array_shapes)
        return (first_shape[0], total_cols) + first_shape[2:]

    def __repr__(self):
        arrays_repr = ", ".join(repr(arr) for arr in self.arrays)
        return f"Hstack([{arrays_repr}])"
check_shape() -> Tuple[int, ...]

Horizontal stack concatenates arrays along the second axis (columns).

Source code in openscvx/symbolic/expr/array.py
def check_shape(self) -> Tuple[int, ...]:
    """Horizontal stack concatenates arrays along the second axis (columns)."""
    if not self.arrays:
        raise ValueError("Hstack requires at least one array")

    array_shapes = [arr.check_shape() for arr in self.arrays]

    # All arrays must have the same number of dimensions
    first_ndim = len(array_shapes[0])
    for i, shape in enumerate(array_shapes[1:], 1):
        if len(shape) != first_ndim:
            raise ValueError(
                f"Hstack array {i} has {len(shape)} dimensions, but array 0 has {first_ndim}"
            )

    # For 1D arrays, hstack concatenates along axis 0
    if first_ndim == 1:
        total_length = sum(shape[0] for shape in array_shapes)
        return (total_length,)

    # For 2D+ arrays, all dimensions except the second must match
    first_shape = array_shapes[0]
    for i, shape in enumerate(array_shapes[1:], 1):
        if shape[0] != first_shape[0]:
            raise ValueError(
                f"Hstack array {i} has {shape[0]} rows, but array 0 has {first_shape[0]} rows"
            )
        if shape[2:] != first_shape[2:]:
            raise ValueError(
                f"Hstack array {i} has trailing dimensions {shape[2:]}, "
                f"but array 0 has {first_shape[2:]}"
            )

    # Result shape: concatenate along axis 1 (columns)
    total_cols = sum(shape[1] for shape in array_shapes)
    return (first_shape[0], total_cols) + first_shape[2:]

Huber

Bases: Expr

Huber penalty function for symbolic expressions.

The Huber penalty is a smooth approximation to the absolute value function that is quadratic for small values (|x| < delta) and linear for large values (|x| >= delta). This makes it more robust to outliers than squared penalties while maintaining smoothness.

The Huber function is defined as: - (x^2) / (2*delta) for |x| <= delta - |x| - delta/2 for |x| > delta

Attributes:

Name Type Description
x

Expression to apply Huber penalty to

delta

Threshold parameter controlling the transition point (default: 0.25)

Example

Define a Huber penalty expression:

residual = y_measured - y_predicted
penalty = Huber(residual, delta=0.5)
Source code in openscvx/symbolic/expr/math.py
class Huber(Expr):
    """Huber penalty function for symbolic expressions.

    The Huber penalty is a smooth approximation to the absolute value function
    that is quadratic for small values (|x| < delta) and linear for large values
    (|x| >= delta). This makes it more robust to outliers than squared penalties
    while maintaining smoothness.

    The Huber function is defined as:
    - (x^2) / (2*delta)           for |x| <= delta
    - |x| - delta/2               for |x| > delta

    Attributes:
        x: Expression to apply Huber penalty to
        delta: Threshold parameter controlling the transition point (default: 0.25)

    Example:
        Define a Huber penalty expression:

            residual = y_measured - y_predicted
            penalty = Huber(residual, delta=0.5)
    """

    def __init__(self, x, delta: float = 0.25):
        """Initialize a Huber penalty operation.

        Args:
            x: Expression to apply Huber penalty to
            delta: Threshold parameter for quadratic-to-linear transition (default: 0.25)
        """
        self.x = to_expr(x)
        self.delta = float(delta)

    def children(self):
        return [self.x]

    def canonicalize(self) -> "Expr":
        """Canonicalize the operand but preserve delta parameter."""
        x = self.x.canonicalize()
        return Huber(x, delta=self.delta)

    def check_shape(self) -> Tuple[int, ...]:
        """Huber penalty preserves the shape of x."""
        return self.x.check_shape()

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash Huber including its delta parameter.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"Huber")
        # Hash delta as bytes
        hasher.update(struct.pack(">d", self.delta))
        # Hash the operand
        self.x._hash_into(hasher)

    def __repr__(self):
        return f"huber({self.x!r}, delta={self.delta})"
_hash_into(hasher: hashlib._Hash) -> None

Hash Huber including its delta parameter.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/math.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash Huber including its delta parameter.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"Huber")
    # Hash delta as bytes
    hasher.update(struct.pack(">d", self.delta))
    # Hash the operand
    self.x._hash_into(hasher)
canonicalize() -> Expr

Canonicalize the operand but preserve delta parameter.

Source code in openscvx/symbolic/expr/math.py
def canonicalize(self) -> "Expr":
    """Canonicalize the operand but preserve delta parameter."""
    x = self.x.canonicalize()
    return Huber(x, delta=self.delta)
check_shape() -> Tuple[int, ...]

Huber penalty preserves the shape of x.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Huber penalty preserves the shape of x."""
    return self.x.check_shape()

Index

Bases: Expr

Indexing and slicing operation for symbolic expressions.

Represents indexing or slicing of an expression using NumPy-style indexing. Can be created using square bracket notation on Expr objects.

Attributes:

Name Type Description
base

Expression to index into

index

Index specification (int, slice, or tuple of indices/slices)

Example

Define an Index expression:

x = ox.State("x", shape=(10,))
y = x[0:5]  # Creates Index(x, slice(0, 5))
z = x[3]    # Creates Index(x, 3)
Source code in openscvx/symbolic/expr/array.py
class Index(Expr):
    """Indexing and slicing operation for symbolic expressions.

    Represents indexing or slicing of an expression using NumPy-style indexing.
    Can be created using square bracket notation on Expr objects.

    Attributes:
        base: Expression to index into
        index: Index specification (int, slice, or tuple of indices/slices)

    Example:
        Define an Index expression:

            x = ox.State("x", shape=(10,))
            y = x[0:5]  # Creates Index(x, slice(0, 5))
            z = x[3]    # Creates Index(x, 3)
    """

    def __init__(self, base: Expr, index: Union[int, slice, tuple]):
        """Initialize an indexing operation.

        Args:
            base: Expression to index into
            index: NumPy-style index (int, slice, or tuple of indices/slices)
        """
        self.base = base
        self.index = index

    def children(self):
        return [self.base]

    def canonicalize(self) -> "Expr":
        """Canonicalize index by canonicalizing the base expression.

        Returns:
            Expr: Canonical form of the indexing expression
        """
        base = self.base.canonicalize()
        return Index(base, self.index)

    def check_shape(self) -> Tuple[int, ...]:
        """Compute the shape after indexing."""
        base_shape = self.base.check_shape()
        dummy = np.zeros(base_shape)
        try:
            result = dummy[self.index]
        except Exception as e:
            raise ValueError(f"Bad index {self.index} for shape {base_shape}") from e
        return result.shape

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash Index including its index specification.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"Index")
        # Hash the index specification (convert to string for generality)
        hasher.update(repr(self.index).encode())
        # Hash the base expression
        self.base._hash_into(hasher)

    def __repr__(self):
        return f"{self.base!r}[{self.index!r}]"
_hash_into(hasher: hashlib._Hash) -> None

Hash Index including its index specification.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/array.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash Index including its index specification.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"Index")
    # Hash the index specification (convert to string for generality)
    hasher.update(repr(self.index).encode())
    # Hash the base expression
    self.base._hash_into(hasher)
canonicalize() -> Expr

Canonicalize index by canonicalizing the base expression.

Returns:

Name Type Description
Expr Expr

Canonical form of the indexing expression

Source code in openscvx/symbolic/expr/array.py
def canonicalize(self) -> "Expr":
    """Canonicalize index by canonicalizing the base expression.

    Returns:
        Expr: Canonical form of the indexing expression
    """
    base = self.base.canonicalize()
    return Index(base, self.index)
check_shape() -> Tuple[int, ...]

Compute the shape after indexing.

Source code in openscvx/symbolic/expr/array.py
def check_shape(self) -> Tuple[int, ...]:
    """Compute the shape after indexing."""
    base_shape = self.base.check_shape()
    dummy = np.zeros(base_shape)
    try:
        result = dummy[self.index]
    except Exception as e:
        raise ValueError(f"Bad index {self.index} for shape {base_shape}") from e
    return result.shape

Inequality

Bases: Constraint

Inequality constraint for optimization problems.

Represents an inequality constraint: lhs <= rhs. Can be created using the <= operator on Expr objects.

Example

Define an Inequality constraint:

x = ox.State("x", shape=(3,))
constraint = x <= 10  # Creates Inequality(x, Constant(10))
Source code in openscvx/symbolic/expr/constraint.py
class Inequality(Constraint):
    """Inequality constraint for optimization problems.

    Represents an inequality constraint: lhs <= rhs. Can be created using the <=
    operator on Expr objects.

    Example:
        Define an Inequality constraint:

            x = ox.State("x", shape=(3,))
            constraint = x <= 10  # Creates Inequality(x, Constant(10))
    """

    def __repr__(self):
        return f"{self.lhs!r} <= {self.rhs!r}"

Leaf

Bases: Expr

Base class for leaf nodes (terminal expressions) in the symbolic expression tree.

Leaf nodes represent named symbolic variables that don't have child expressions. This includes Parameters, Variables, States, and Controls.

Attributes:

Name Type Description
name str

Name identifier for the leaf node

_shape tuple

Shape of the leaf node

Source code in openscvx/symbolic/expr/expr.py
class Leaf(Expr):
    """
    Base class for leaf nodes (terminal expressions) in the symbolic expression tree.

    Leaf nodes represent named symbolic variables that don't have child expressions.
    This includes Parameters, Variables, States, and Controls.

    Attributes:
        name (str): Name identifier for the leaf node
        _shape (tuple): Shape of the leaf node
    """

    def __init__(self, name: str, shape: tuple = ()):
        """Initialize a Leaf node.

        Args:
            name (str): Name identifier for the leaf node
            shape (tuple): Shape of the leaf node
        """
        super().__init__()
        self.name = name
        self._shape = shape

    @property
    def shape(self):
        """Get the shape of the leaf node.

        Returns:
            tuple: Shape of the leaf node
        """
        return self._shape

    def children(self):
        """Leaf nodes have no children.

        Returns:
            list: Empty list since leaf nodes are terminal
        """
        return []

    def canonicalize(self) -> "Expr":
        """Leaf nodes are already in canonical form.

        Returns:
            Expr: Returns self since leaf nodes are already canonical
        """
        return self

    def check_shape(self) -> Tuple[int, ...]:
        """Return the shape of this leaf node.

        Returns:
            tuple: The shape of the leaf node
        """
        return self._shape

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash leaf node by class name and shape.

        This base implementation hashes the class name and shape. Subclasses
        like Variable and Parameter override this to add their specific
        canonical identifiers (_slice for Variables, value for Parameters).

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(self.__class__.__name__.encode())
        hasher.update(str(self._shape).encode())

    def __repr__(self):
        """String representation of the leaf node.

        Returns:
            str: A string describing the leaf node
        """
        return f"{self.__class__.__name__}('{self.name}', shape={self.shape})"
shape property

Get the shape of the leaf node.

Returns:

Name Type Description
tuple

Shape of the leaf node

_hash_into(hasher: hashlib._Hash) -> None

Hash leaf node by class name and shape.

This base implementation hashes the class name and shape. Subclasses like Variable and Parameter override this to add their specific canonical identifiers (_slice for Variables, value for Parameters).

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/expr.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash leaf node by class name and shape.

    This base implementation hashes the class name and shape. Subclasses
    like Variable and Parameter override this to add their specific
    canonical identifiers (_slice for Variables, value for Parameters).

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(self.__class__.__name__.encode())
    hasher.update(str(self._shape).encode())
canonicalize() -> Expr

Leaf nodes are already in canonical form.

Returns:

Name Type Description
Expr Expr

Returns self since leaf nodes are already canonical

Source code in openscvx/symbolic/expr/expr.py
def canonicalize(self) -> "Expr":
    """Leaf nodes are already in canonical form.

    Returns:
        Expr: Returns self since leaf nodes are already canonical
    """
    return self
check_shape() -> Tuple[int, ...]

Return the shape of this leaf node.

Returns:

Name Type Description
tuple Tuple[int, ...]

The shape of the leaf node

Source code in openscvx/symbolic/expr/expr.py
def check_shape(self) -> Tuple[int, ...]:
    """Return the shape of this leaf node.

    Returns:
        tuple: The shape of the leaf node
    """
    return self._shape
children()

Leaf nodes have no children.

Returns:

Name Type Description
list

Empty list since leaf nodes are terminal

Source code in openscvx/symbolic/expr/expr.py
def children(self):
    """Leaf nodes have no children.

    Returns:
        list: Empty list since leaf nodes are terminal
    """
    return []

Log

Bases: Expr

Element-wise natural logarithm function for symbolic expressions.

Computes the natural logarithm (base e) of each element in the operand. Preserves the shape of the input expression.

Attributes:

Name Type Description
operand

Expression to apply logarithm to

Example

Define a Log expression:

x = Variable("x", shape=(3,))
log_x = Log(x)
Source code in openscvx/symbolic/expr/math.py
class Log(Expr):
    """Element-wise natural logarithm function for symbolic expressions.

    Computes the natural logarithm (base e) of each element in the operand.
    Preserves the shape of the input expression.

    Attributes:
        operand: Expression to apply logarithm to

    Example:
        Define a Log expression:

            x = Variable("x", shape=(3,))
            log_x = Log(x)
    """

    def __init__(self, operand):
        """Initialize a natural logarithm operation.

        Args:
            operand: Expression to apply logarithm to
        """
        self.operand = to_expr(operand)

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Log(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Log preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"log({self.operand!r})"
check_shape() -> Tuple[int, ...]

Log preserves the shape of its operand.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Log preserves the shape of its operand."""
    return self.operand.check_shape()

LogSumExp

Bases: Expr

Log-sum-exp function for symbolic expressions.

Computes the log-sum-exp (LSE) of multiple operands, which is a smooth, differentiable approximation to the maximum function. The log-sum-exp is defined as:

logsumexp(x, x, ..., xₙ) = log(exp(x) + exp(x) + ... + exp(xₙ))

This function is numerically stable and is commonly used in optimization as a smooth alternative to the non-differentiable maximum function. It satisfies the inequality:

max(x, x, ..., xₙ)  logsumexp(x, x, ..., xₙ)  max(x, x, ..., xₙ) + log(n)

The log-sum-exp is convex and is particularly useful for: - Smooth approximations of maximum constraints - Soft maximum operations in neural networks - Relaxing logical OR operations in STL specifications

Attributes:

Name Type Description
operands

List of expressions to compute log-sum-exp over

Example

Define a LogSumExp expression:

x = Variable("x", shape=(3,))
y = Variable("y", shape=(3,))
z = Variable("z", shape=(3,))
lse = LogSumExp(x, y, z)  # Smooth approximation to max(x, y, z)

Use in STL relaxation:

import openscvx as ox
# Relax: Or(φ₁, φ₂) using log-sum-exp
phi1 = ox.Norm(x - goal1) - 0.5
phi2 = ox.Norm(x - goal2) - 0.5
relaxed_or = LogSumExp(phi1, phi2) >= 0
Source code in openscvx/symbolic/expr/math.py
class LogSumExp(Expr):
    """Log-sum-exp function for symbolic expressions.

    Computes the log-sum-exp (LSE) of multiple operands, which is a smooth,
    differentiable approximation to the maximum function. The log-sum-exp is
    defined as:

        logsumexp(x₁, x₂, ..., xₙ) = log(exp(x₁) + exp(x₂) + ... + exp(xₙ))

    This function is numerically stable and is commonly used in optimization
    as a smooth alternative to the non-differentiable maximum function. It
    satisfies the inequality:

        max(x₁, x₂, ..., xₙ) ≤ logsumexp(x₁, x₂, ..., xₙ) ≤ max(x₁, x₂, ..., xₙ) + log(n)

    The log-sum-exp is convex and is particularly useful for:
    - Smooth approximations of maximum constraints
    - Soft maximum operations in neural networks
    - Relaxing logical OR operations in STL specifications

    Attributes:
        operands: List of expressions to compute log-sum-exp over

    Example:
        Define a LogSumExp expression:

            x = Variable("x", shape=(3,))
            y = Variable("y", shape=(3,))
            z = Variable("z", shape=(3,))
            lse = LogSumExp(x, y, z)  # Smooth approximation to max(x, y, z)

        Use in STL relaxation:

            import openscvx as ox
            # Relax: Or(φ₁, φ₂) using log-sum-exp
            phi1 = ox.Norm(x - goal1) - 0.5
            phi2 = ox.Norm(x - goal2) - 0.5
            relaxed_or = LogSumExp(phi1, phi2) >= 0
    """

    def __init__(self, *args):
        """Initialize a log-sum-exp operation.

        Args:
            *args: Two or more expressions to compute log-sum-exp over

        Raises:
            ValueError: If fewer than two operands are provided
        """
        if len(args) < 2:
            raise ValueError("LogSumExp requires two or more operands")
        self.operands = [to_expr(a) for a in args]

    def children(self):
        return list(self.operands)

    def canonicalize(self) -> "Expr":
        """Canonicalize log-sum-exp: flatten nested LogSumExp, fold constants."""
        from .expr import Constant

        operands = []
        const_vals = []

        for op in self.operands:
            c = op.canonicalize()
            if isinstance(c, LogSumExp):
                operands.extend(c.operands)
            elif isinstance(c, Constant):
                const_vals.append(c.value)
            else:
                operands.append(c)

        # If we have constants, compute their log-sum-exp and keep it
        if const_vals:
            # For constants, we can compute logsumexp directly
            # logsumexp(c1, c2, ..., cn) = log(sum(exp(ci)))
            exp_vals = [np.exp(v) for v in const_vals]
            lse_const = np.log(np.sum(exp_vals))
            operands.append(Constant(lse_const))

        if not operands:
            raise ValueError("LogSumExp must have at least one operand after canonicalization")
        if len(operands) == 1:
            return operands[0]
        return LogSumExp(*operands)

    def check_shape(self) -> Tuple[int, ...]:
        """LogSumExp broadcasts shapes like NumPy, preserving element-wise shape."""
        shapes = [child.check_shape() for child in self.children()]
        try:
            return np.broadcast_shapes(*shapes)
        except ValueError as e:
            raise ValueError(f"LogSumExp shapes not broadcastable: {shapes}") from e

    def __repr__(self):
        inner = ", ".join(repr(op) for op in self.operands)
        return f"logsumexp({inner})"
canonicalize() -> Expr

Canonicalize log-sum-exp: flatten nested LogSumExp, fold constants.

Source code in openscvx/symbolic/expr/math.py
def canonicalize(self) -> "Expr":
    """Canonicalize log-sum-exp: flatten nested LogSumExp, fold constants."""
    from .expr import Constant

    operands = []
    const_vals = []

    for op in self.operands:
        c = op.canonicalize()
        if isinstance(c, LogSumExp):
            operands.extend(c.operands)
        elif isinstance(c, Constant):
            const_vals.append(c.value)
        else:
            operands.append(c)

    # If we have constants, compute their log-sum-exp and keep it
    if const_vals:
        # For constants, we can compute logsumexp directly
        # logsumexp(c1, c2, ..., cn) = log(sum(exp(ci)))
        exp_vals = [np.exp(v) for v in const_vals]
        lse_const = np.log(np.sum(exp_vals))
        operands.append(Constant(lse_const))

    if not operands:
        raise ValueError("LogSumExp must have at least one operand after canonicalization")
    if len(operands) == 1:
        return operands[0]
    return LogSumExp(*operands)
check_shape() -> Tuple[int, ...]

LogSumExp broadcasts shapes like NumPy, preserving element-wise shape.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """LogSumExp broadcasts shapes like NumPy, preserving element-wise shape."""
    shapes = [child.check_shape() for child in self.children()]
    try:
        return np.broadcast_shapes(*shapes)
    except ValueError as e:
        raise ValueError(f"LogSumExp shapes not broadcastable: {shapes}") from e

MatMul

Bases: Expr

Matrix multiplication operation for symbolic expressions.

Represents matrix multiplication following standard linear algebra rules. Can be created using the @ operator on Expr objects. Handles: - Matrix @ Matrix: (m,n) @ (n,k) -> (m,k) - Matrix @ Vector: (m,n) @ (n,) -> (m,) - Vector @ Matrix: (m,) @ (m,n) -> (n,) - Vector @ Vector: (m,) @ (m,) -> scalar

Attributes:

Name Type Description
left

Left-hand side expression

right

Right-hand side expression

Example

Define a MatMul expression:

A = ox.State("A", shape=(3, 4))
x = ox.State("x", shape=(4,))
y = A @ x  # Creates MatMul(A, x), result shape (3,)
Source code in openscvx/symbolic/expr/arithmetic.py
class MatMul(Expr):
    """Matrix multiplication operation for symbolic expressions.

    Represents matrix multiplication following standard linear algebra rules.
    Can be created using the @ operator on Expr objects. Handles:
    - Matrix @ Matrix: (m,n) @ (n,k) -> (m,k)
    - Matrix @ Vector: (m,n) @ (n,) -> (m,)
    - Vector @ Matrix: (m,) @ (m,n) -> (n,)
    - Vector @ Vector: (m,) @ (m,) -> scalar

    Attributes:
        left: Left-hand side expression
        right: Right-hand side expression

    Example:
        Define a MatMul expression:

            A = ox.State("A", shape=(3, 4))
            x = ox.State("x", shape=(4,))
            y = A @ x  # Creates MatMul(A, x), result shape (3,)
    """

    def __init__(self, left, right):
        """Initialize a matrix multiplication operation.

        Args:
            left: Left-hand side expression for matrix multiplication
            right: Right-hand side expression for matrix multiplication
        """
        self.left = left
        self.right = right

    def children(self):
        return [self.left, self.right]

    def canonicalize(self) -> "Expr":
        left = self.left.canonicalize()
        right = self.right.canonicalize()
        return MatMul(left, right)

    def check_shape(self) -> Tuple[int, ...]:
        """Check matrix multiplication shape compatibility and return result shape."""
        L, R = self.left.check_shape(), self.right.check_shape()

        # Handle different matmul cases:
        # Matrix @ Matrix: (m,n) @ (n,k) -> (m,k)
        # Matrix @ Vector: (m,n) @ (n,) -> (m,)
        # Vector @ Matrix: (m,) @ (m,n) -> (n,)
        # Vector @ Vector: (m,) @ (m,) -> ()

        if len(L) == 0 or len(R) == 0:
            raise ValueError(f"MatMul requires at least 1D operands: {L} @ {R}")

        if len(L) == 1 and len(R) == 1:
            # Vector @ Vector -> scalar
            if L[0] != R[0]:
                raise ValueError(f"MatMul incompatible: {L} @ {R}")
            return ()
        elif len(L) == 1:
            # Vector @ Matrix: (m,) @ (m,n) -> (n,)
            if len(R) < 2 or L[0] != R[-2]:
                raise ValueError(f"MatMul incompatible: {L} @ {R}")
            return R[-1:]
        elif len(R) == 1:
            # Matrix @ Vector: (m,n) @ (n,) -> (m,)
            if len(L) < 2 or L[-1] != R[0]:
                raise ValueError(f"MatMul incompatible: {L} @ {R}")
            return L[:-1]
        else:
            # Matrix @ Matrix: (...,m,n) @ (...,n,k) -> (...,m,k)
            if len(L) < 2 or len(R) < 2 or L[-1] != R[-2]:
                raise ValueError(f"MatMul incompatible: {L} @ {R}")
            return L[:-1] + (R[-1],)

    def __repr__(self):
        return f"({self.left!r} * {self.right!r})"
check_shape() -> Tuple[int, ...]

Check matrix multiplication shape compatibility and return result shape.

Source code in openscvx/symbolic/expr/arithmetic.py
def check_shape(self) -> Tuple[int, ...]:
    """Check matrix multiplication shape compatibility and return result shape."""
    L, R = self.left.check_shape(), self.right.check_shape()

    # Handle different matmul cases:
    # Matrix @ Matrix: (m,n) @ (n,k) -> (m,k)
    # Matrix @ Vector: (m,n) @ (n,) -> (m,)
    # Vector @ Matrix: (m,) @ (m,n) -> (n,)
    # Vector @ Vector: (m,) @ (m,) -> ()

    if len(L) == 0 or len(R) == 0:
        raise ValueError(f"MatMul requires at least 1D operands: {L} @ {R}")

    if len(L) == 1 and len(R) == 1:
        # Vector @ Vector -> scalar
        if L[0] != R[0]:
            raise ValueError(f"MatMul incompatible: {L} @ {R}")
        return ()
    elif len(L) == 1:
        # Vector @ Matrix: (m,) @ (m,n) -> (n,)
        if len(R) < 2 or L[0] != R[-2]:
            raise ValueError(f"MatMul incompatible: {L} @ {R}")
        return R[-1:]
    elif len(R) == 1:
        # Matrix @ Vector: (m,n) @ (n,) -> (m,)
        if len(L) < 2 or L[-1] != R[0]:
            raise ValueError(f"MatMul incompatible: {L} @ {R}")
        return L[:-1]
    else:
        # Matrix @ Matrix: (...,m,n) @ (...,n,k) -> (...,m,k)
        if len(L) < 2 or len(R) < 2 or L[-1] != R[-2]:
            raise ValueError(f"MatMul incompatible: {L} @ {R}")
        return L[:-1] + (R[-1],)

Max

Bases: Expr

Element-wise maximum function for symbolic expressions.

Computes the element-wise maximum across two or more operands. Supports broadcasting following NumPy rules. During canonicalization, nested Max operations are flattened and constants are folded.

Attributes:

Name Type Description
operands

List of expressions to compute maximum over

Example

Define a Max expression:

x = Variable("x", shape=(3,))
y = Variable("y", shape=(3,))
max_xy = Max(x, y, 0)  # Element-wise max(x, y, 0)
Source code in openscvx/symbolic/expr/math.py
class Max(Expr):
    """Element-wise maximum function for symbolic expressions.

    Computes the element-wise maximum across two or more operands. Supports
    broadcasting following NumPy rules. During canonicalization, nested Max
    operations are flattened and constants are folded.

    Attributes:
        operands: List of expressions to compute maximum over

    Example:
        Define a Max expression:

            x = Variable("x", shape=(3,))
            y = Variable("y", shape=(3,))
            max_xy = Max(x, y, 0)  # Element-wise max(x, y, 0)
    """

    def __init__(self, *args):
        """Initialize a maximum operation.

        Args:
            *args: Two or more expressions to compute maximum over

        Raises:
            ValueError: If fewer than two operands are provided
        """
        if len(args) < 2:
            raise ValueError("Max requires two or more operands")
        self.operands = [to_expr(a) for a in args]

    def children(self):
        return list(self.operands)

    def canonicalize(self) -> "Expr":
        """Canonicalize max: flatten nested Max, fold constants."""
        from .expr import Constant

        operands = []
        const_vals = []

        for op in self.operands:
            c = op.canonicalize()
            if isinstance(c, Max):
                operands.extend(c.operands)
            elif isinstance(c, Constant):
                const_vals.append(c.value)
            else:
                operands.append(c)

        # If we have constants, compute their max and keep it
        if const_vals:
            max_const = np.maximum.reduce(const_vals)
            operands.append(Constant(max_const))

        if not operands:
            raise ValueError("Max must have at least one operand after canonicalization")
        if len(operands) == 1:
            return operands[0]
        return Max(*operands)

    def check_shape(self) -> Tuple[int, ...]:
        """Max broadcasts shapes like NumPy."""
        shapes = [child.check_shape() for child in self.children()]
        try:
            return np.broadcast_shapes(*shapes)
        except ValueError as e:
            raise ValueError(f"Max shapes not broadcastable: {shapes}") from e

    def __repr__(self):
        inner = ", ".join(repr(op) for op in self.operands)
        return f"max({inner})"
canonicalize() -> Expr

Canonicalize max: flatten nested Max, fold constants.

Source code in openscvx/symbolic/expr/math.py
def canonicalize(self) -> "Expr":
    """Canonicalize max: flatten nested Max, fold constants."""
    from .expr import Constant

    operands = []
    const_vals = []

    for op in self.operands:
        c = op.canonicalize()
        if isinstance(c, Max):
            operands.extend(c.operands)
        elif isinstance(c, Constant):
            const_vals.append(c.value)
        else:
            operands.append(c)

    # If we have constants, compute their max and keep it
    if const_vals:
        max_const = np.maximum.reduce(const_vals)
        operands.append(Constant(max_const))

    if not operands:
        raise ValueError("Max must have at least one operand after canonicalization")
    if len(operands) == 1:
        return operands[0]
    return Max(*operands)
check_shape() -> Tuple[int, ...]

Max broadcasts shapes like NumPy.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Max broadcasts shapes like NumPy."""
    shapes = [child.check_shape() for child in self.children()]
    try:
        return np.broadcast_shapes(*shapes)
    except ValueError as e:
        raise ValueError(f"Max shapes not broadcastable: {shapes}") from e

Mul

Bases: Expr

Element-wise multiplication operation for symbolic expressions.

Represents element-wise (Hadamard) multiplication of two or more expressions. Supports broadcasting following NumPy rules. Can be created using the * operator on Expr objects. For matrix multiplication, use MatMul or the @ operator.

Attributes:

Name Type Description
factors

List of expression operands to multiply together

Example

Define a Mul expression:

x = ox.State("x", shape=(3,))
y = ox.State("y", shape=(3,))
z = x * y * 2  # Creates Mul(x, y, Constant(2))
Source code in openscvx/symbolic/expr/arithmetic.py
class Mul(Expr):
    """Element-wise multiplication operation for symbolic expressions.

    Represents element-wise (Hadamard) multiplication of two or more expressions.
    Supports broadcasting following NumPy rules. Can be created using the * operator
    on Expr objects. For matrix multiplication, use MatMul or the @ operator.

    Attributes:
        factors: List of expression operands to multiply together

    Example:
        Define a Mul expression:

            x = ox.State("x", shape=(3,))
            y = ox.State("y", shape=(3,))
            z = x * y * 2  # Creates Mul(x, y, Constant(2))
    """

    def __init__(self, *args):
        """Initialize an element-wise multiplication operation.

        Args:
            *args: Two or more expressions to multiply together

        Raises:
            ValueError: If fewer than two operands are provided
        """
        if len(args) < 2:
            raise ValueError("Mul requires two or more operands")
        self.factors = [to_expr(a) for a in args]

    def children(self):
        return list(self.factors)

    def canonicalize(self) -> "Expr":
        """Canonicalize multiplication: flatten, fold constants, and eliminating ones.

        Returns:
            Expr: Canonical form of the multiplication expression
        """
        factors = []
        const_vals = []

        for f in self.factors:
            c = f.canonicalize()
            if isinstance(c, Mul):
                factors.extend(c.factors)
            elif isinstance(c, Constant):
                const_vals.append(c.value)
            else:
                factors.append(c)

        if const_vals:
            # Multiply constants element-wise (broadcasting), not reducing with prod
            prod = const_vals[0]
            for val in const_vals[1:]:
                prod = prod * val

            # If prod != 1, keep it
            # Check both scalar and array cases
            is_identity = False
            if isinstance(prod, np.ndarray):
                is_identity = np.all(prod == 1)
            else:
                is_identity = prod == 1

            if not is_identity:
                factors.append(Constant(prod))

        if not factors:
            return Constant(np.array(1))
        if len(factors) == 1:
            return factors[0]
        return Mul(*factors)

    def check_shape(self) -> Tuple[int, ...]:
        """Check shape compatibility and compute broadcasted result shape like NumPy.


        Returns:
            tuple: The broadcasted shape of all operands

        Raises:
            ValueError: If operand shapes are not broadcastable
        """
        shapes = [child.check_shape() for child in self.children()]
        try:
            return np.broadcast_shapes(*shapes)
        except ValueError as e:
            raise ValueError(f"Mul shapes not broadcastable: {shapes}") from e

    def __repr__(self):
        inner = " * ".join(repr(e) for e in self.factors)
        return f"({inner})"
canonicalize() -> Expr

Canonicalize multiplication: flatten, fold constants, and eliminating ones.

Returns:

Name Type Description
Expr Expr

Canonical form of the multiplication expression

Source code in openscvx/symbolic/expr/arithmetic.py
def canonicalize(self) -> "Expr":
    """Canonicalize multiplication: flatten, fold constants, and eliminating ones.

    Returns:
        Expr: Canonical form of the multiplication expression
    """
    factors = []
    const_vals = []

    for f in self.factors:
        c = f.canonicalize()
        if isinstance(c, Mul):
            factors.extend(c.factors)
        elif isinstance(c, Constant):
            const_vals.append(c.value)
        else:
            factors.append(c)

    if const_vals:
        # Multiply constants element-wise (broadcasting), not reducing with prod
        prod = const_vals[0]
        for val in const_vals[1:]:
            prod = prod * val

        # If prod != 1, keep it
        # Check both scalar and array cases
        is_identity = False
        if isinstance(prod, np.ndarray):
            is_identity = np.all(prod == 1)
        else:
            is_identity = prod == 1

        if not is_identity:
            factors.append(Constant(prod))

    if not factors:
        return Constant(np.array(1))
    if len(factors) == 1:
        return factors[0]
    return Mul(*factors)
check_shape() -> Tuple[int, ...]

Check shape compatibility and compute broadcasted result shape like NumPy.

Returns:

Name Type Description
tuple Tuple[int, ...]

The broadcasted shape of all operands

Raises:

Type Description
ValueError

If operand shapes are not broadcastable

Source code in openscvx/symbolic/expr/arithmetic.py
def check_shape(self) -> Tuple[int, ...]:
    """Check shape compatibility and compute broadcasted result shape like NumPy.


    Returns:
        tuple: The broadcasted shape of all operands

    Raises:
        ValueError: If operand shapes are not broadcastable
    """
    shapes = [child.check_shape() for child in self.children()]
    try:
        return np.broadcast_shapes(*shapes)
    except ValueError as e:
        raise ValueError(f"Mul shapes not broadcastable: {shapes}") from e

Neg

Bases: Expr

Negation operation for symbolic expressions.

Represents element-wise negation (unary minus). Can be created using the unary - operator on Expr objects.

Attributes:

Name Type Description
operand

Expression to negate

Example

Define a Neg expression:

x = ox.State("x", shape=(3,))
y = -x  # Creates Neg(x)
Source code in openscvx/symbolic/expr/arithmetic.py
class Neg(Expr):
    """Negation operation for symbolic expressions.

    Represents element-wise negation (unary minus). Can be created using the
    unary - operator on Expr objects.

    Attributes:
        operand: Expression to negate

    Example:
        Define a Neg expression:

            x = ox.State("x", shape=(3,))
            y = -x  # Creates Neg(x)
    """

    def __init__(self, operand):
        """Initialize a negation operation.

        Args:
            operand: Expression to negate
        """
        self.operand = operand

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        """Canonicalize negation: fold constant negations.

        Returns:
            Expr: Canonical form of the negation expression
        """
        o = self.operand.canonicalize()
        if isinstance(o, Constant):
            return Constant(-o.value)
        return Neg(o)

    def check_shape(self) -> Tuple[int, ...]:
        """Negation preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"(-{self.operand!r})"
canonicalize() -> Expr

Canonicalize negation: fold constant negations.

Returns:

Name Type Description
Expr Expr

Canonical form of the negation expression

Source code in openscvx/symbolic/expr/arithmetic.py
def canonicalize(self) -> "Expr":
    """Canonicalize negation: fold constant negations.

    Returns:
        Expr: Canonical form of the negation expression
    """
    o = self.operand.canonicalize()
    if isinstance(o, Constant):
        return Constant(-o.value)
    return Neg(o)
check_shape() -> Tuple[int, ...]

Negation preserves the shape of its operand.

Source code in openscvx/symbolic/expr/arithmetic.py
def check_shape(self) -> Tuple[int, ...]:
    """Negation preserves the shape of its operand."""
    return self.operand.check_shape()

NodalConstraint

Bases: Expr

Wrapper for constraints enforced only at specific discrete trajectory nodes.

NodalConstraint allows selective enforcement of constraints at specific time points (nodes) in a discretized trajectory, rather than enforcing them at every node. This is useful for:

  • Specifying waypoint constraints (e.g., pass through point X at node 10)
  • Boundary conditions at non-standard locations
  • Reducing computational cost by checking constraints less frequently
  • Enforcing periodic constraints (e.g., every 5th node)

The wrapper maintains clean separation between the constraint's mathematical definition and the specification of where it should be applied during optimization.

Note

Bare Constraint objects (without .at() or .over()) are automatically converted to NodalConstraints applied at all nodes during preprocessing.

Attributes:

Name Type Description
constraint

The wrapped Constraint (Equality or Inequality) to enforce

nodes

List of integer node indices where the constraint is enforced

Example

Enforce position constraint only at nodes 0, 10, and 20:

x = State("x", shape=(3,))
target = [10, 5, 0]
constraint = (x == target).at([0, 10, 20])

Equivalent using NodalConstraint directly:

constraint = NodalConstraint(x == target, nodes=[0, 10, 20])

Periodic constraint enforcement (every 10th node):

velocity_limit = (vel <= 100).at(list(range(0, 100, 10)))

Bare constraints are automatically applied at all nodes. These are equivalent:

constraint1 = vel <= 100  # Auto-converted to all nodes
constraint2 = (vel <= 100).at(list(range(n_nodes)))
Source code in openscvx/symbolic/expr/constraint.py
class NodalConstraint(Expr):
    """Wrapper for constraints enforced only at specific discrete trajectory nodes.

    NodalConstraint allows selective enforcement of constraints at specific time points
    (nodes) in a discretized trajectory, rather than enforcing them at every node.
    This is useful for:

    - Specifying waypoint constraints (e.g., pass through point X at node 10)
    - Boundary conditions at non-standard locations
    - Reducing computational cost by checking constraints less frequently
    - Enforcing periodic constraints (e.g., every 5th node)

    The wrapper maintains clean separation between the constraint's mathematical
    definition and the specification of where it should be applied during optimization.

    Note:
        Bare Constraint objects (without .at() or .over()) are automatically converted
        to NodalConstraints applied at all nodes during preprocessing.

    Attributes:
        constraint: The wrapped Constraint (Equality or Inequality) to enforce
        nodes: List of integer node indices where the constraint is enforced

    Example:
        Enforce position constraint only at nodes 0, 10, and 20:

            x = State("x", shape=(3,))
            target = [10, 5, 0]
            constraint = (x == target).at([0, 10, 20])

        Equivalent using NodalConstraint directly:

            constraint = NodalConstraint(x == target, nodes=[0, 10, 20])

        Periodic constraint enforcement (every 10th node):

            velocity_limit = (vel <= 100).at(list(range(0, 100, 10)))

        Bare constraints are automatically applied at all nodes.
        These are equivalent:

            constraint1 = vel <= 100  # Auto-converted to all nodes
            constraint2 = (vel <= 100).at(list(range(n_nodes)))
    """

    def __init__(self, constraint: Constraint, nodes: list[int]):
        """Initialize a NodalConstraint.

        Args:
            constraint: The Constraint (Equality or Inequality) to enforce at specified nodes
            nodes: List of integer node indices where the constraint should be enforced.
                Automatically converts numpy integers to Python integers.

        Raises:
            TypeError: If constraint is not a Constraint instance
            TypeError: If nodes is not a list
            TypeError: If any node index is not an integer

        Note:
            Bounds checking for cross-node constraints (those containing NodeReference)
            is performed later in the pipeline when N is known, via
            validate_cross_node_constraint_bounds() in preprocessing.py.
        """
        if not isinstance(constraint, Constraint):
            raise TypeError("NodalConstraint must wrap a Constraint")
        if not isinstance(nodes, list):
            raise TypeError("nodes must be a list of integers")

        # Convert numpy integers to Python integers
        converted_nodes = []
        for n in nodes:
            if isinstance(n, np.integer):
                converted_nodes.append(int(n))
            elif isinstance(n, int):
                converted_nodes.append(n)
            else:
                raise TypeError("all node indices must be integers")

        self.constraint = constraint
        self.nodes = converted_nodes

    def children(self):
        """Return the wrapped constraint as the only child.

        Returns:
            list: Single-element list containing the wrapped constraint
        """
        return [self.constraint]

    def canonicalize(self) -> "Expr":
        """Canonicalize the wrapped constraint while preserving node specification.

        Returns:
            NodalConstraint: A new NodalConstraint with canonicalized inner constraint
        """
        canon_constraint = self.constraint.canonicalize()
        return NodalConstraint(canon_constraint, self.nodes)

    def check_shape(self) -> Tuple[int, ...]:
        """Validate the wrapped constraint's shape.

        NodalConstraint wraps a constraint without changing its computational meaning,
        only specifying where it should be applied. Like all constraints, it produces
        a scalar result.

        Returns:
            tuple: Empty tuple () representing scalar shape
        """
        # Validate the wrapped constraint's shape
        self.constraint.check_shape()

        # NodalConstraint produces a scalar like any constraint
        return ()

    def convex(self) -> "NodalConstraint":
        """Mark the underlying constraint as convex for CVXPy lowering.

        Returns:
            Self with underlying constraint's convex flag set to True (enables method chaining)

        Example:
            Mark a constraint as convex:
                constraint = (x <= 10).at([0, 5, 10]).convex()
        """
        self.constraint.convex()
        return self

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash NodalConstraint including its node list.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"NodalConstraint")
        # Hash the nodes list
        for node in self.nodes:
            hasher.update(struct.pack(">i", node))
        hasher.update(b"|")  # Separator to distinguish node counts
        # Hash the wrapped constraint
        self.constraint._hash_into(hasher)

    def __repr__(self):
        """String representation of the NodalConstraint.

        Returns:
            str: String showing the wrapped constraint and node indices
        """
        return f"NodalConstraint({self.constraint!r}, nodes={self.nodes})"
_hash_into(hasher: hashlib._Hash) -> None

Hash NodalConstraint including its node list.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/constraint.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash NodalConstraint including its node list.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"NodalConstraint")
    # Hash the nodes list
    for node in self.nodes:
        hasher.update(struct.pack(">i", node))
    hasher.update(b"|")  # Separator to distinguish node counts
    # Hash the wrapped constraint
    self.constraint._hash_into(hasher)
canonicalize() -> Expr

Canonicalize the wrapped constraint while preserving node specification.

Returns:

Name Type Description
NodalConstraint Expr

A new NodalConstraint with canonicalized inner constraint

Source code in openscvx/symbolic/expr/constraint.py
def canonicalize(self) -> "Expr":
    """Canonicalize the wrapped constraint while preserving node specification.

    Returns:
        NodalConstraint: A new NodalConstraint with canonicalized inner constraint
    """
    canon_constraint = self.constraint.canonicalize()
    return NodalConstraint(canon_constraint, self.nodes)
check_shape() -> Tuple[int, ...]

Validate the wrapped constraint's shape.

NodalConstraint wraps a constraint without changing its computational meaning, only specifying where it should be applied. Like all constraints, it produces a scalar result.

Returns:

Name Type Description
tuple Tuple[int, ...]

Empty tuple () representing scalar shape

Source code in openscvx/symbolic/expr/constraint.py
def check_shape(self) -> Tuple[int, ...]:
    """Validate the wrapped constraint's shape.

    NodalConstraint wraps a constraint without changing its computational meaning,
    only specifying where it should be applied. Like all constraints, it produces
    a scalar result.

    Returns:
        tuple: Empty tuple () representing scalar shape
    """
    # Validate the wrapped constraint's shape
    self.constraint.check_shape()

    # NodalConstraint produces a scalar like any constraint
    return ()
children()

Return the wrapped constraint as the only child.

Returns:

Name Type Description
list

Single-element list containing the wrapped constraint

Source code in openscvx/symbolic/expr/constraint.py
def children(self):
    """Return the wrapped constraint as the only child.

    Returns:
        list: Single-element list containing the wrapped constraint
    """
    return [self.constraint]
convex() -> NodalConstraint

Mark the underlying constraint as convex for CVXPy lowering.

Returns:

Type Description
NodalConstraint

Self with underlying constraint's convex flag set to True (enables method chaining)

Example

Mark a constraint as convex: constraint = (x <= 10).at([0, 5, 10]).convex()

Source code in openscvx/symbolic/expr/constraint.py
def convex(self) -> "NodalConstraint":
    """Mark the underlying constraint as convex for CVXPy lowering.

    Returns:
        Self with underlying constraint's convex flag set to True (enables method chaining)

    Example:
        Mark a constraint as convex:
            constraint = (x <= 10).at([0, 5, 10]).convex()
    """
    self.constraint.convex()
    return self

NodeReference

Bases: Expr

Reference to a variable at a specific trajectory node.

NodeReference enables inter-node constraints by allowing you to reference the value of a state or control variable at a specific discrete time point (node) in the trajectory. This is essential for expressing temporal relationships such as:

  • Rate limits and smoothness constraints
  • Multi-step dependencies and recurrence relations
  • Constraints coupling specific nodes

Attributes:

Name Type Description
base

The expression (typically a Leaf like State or Control) being referenced

node_idx

Trajectory node index (integer, can be negative for end-indexing)

Example

Rate limit across trajectory:

position = State("pos", shape=(3,))

# Create rate limit constraints for all nodes
constraints = [
    (ox.linalg.Norm(position.at(k) - position.at(k-1)) <= 0.1).at([k])
    for k in range(1, N)
]

Multi-step dependency:

state = State("x", shape=(1,))

# Fibonacci-like recurrence at each node
constraints = [
    (state.at(k) == state.at(k-1) + state.at(k-2)).at([k])
    for k in range(2, N)
]

Coupling specific nodes:

# Constrain distance between nodes 5 and 10
coupling = (position.at(10) - position.at(5) <= threshold).at([10])
Performance Note

Cross-node constraints use dense Jacobian storage. For details on memory usage and performance implications, see LoweredCrossNodeConstraint documentation.

Note

NodeReference is typically created via the .at(k) method on expressions rather than constructed directly.

Source code in openscvx/symbolic/expr/expr.py
class NodeReference(Expr):
    """Reference to a variable at a specific trajectory node.

    NodeReference enables inter-node constraints by allowing you to reference
    the value of a state or control variable at a specific discrete time point
    (node) in the trajectory. This is essential for expressing temporal relationships
    such as:

    - Rate limits and smoothness constraints
    - Multi-step dependencies and recurrence relations
    - Constraints coupling specific nodes

    Attributes:
        base: The expression (typically a Leaf like State or Control) being referenced
        node_idx: Trajectory node index (integer, can be negative for end-indexing)

    Example:
        Rate limit across trajectory:

            position = State("pos", shape=(3,))

            # Create rate limit constraints for all nodes
            constraints = [
                (ox.linalg.Norm(position.at(k) - position.at(k-1)) <= 0.1).at([k])
                for k in range(1, N)
            ]

        Multi-step dependency:

            state = State("x", shape=(1,))

            # Fibonacci-like recurrence at each node
            constraints = [
                (state.at(k) == state.at(k-1) + state.at(k-2)).at([k])
                for k in range(2, N)
            ]

        Coupling specific nodes:

            # Constrain distance between nodes 5 and 10
            coupling = (position.at(10) - position.at(5) <= threshold).at([10])

    Performance Note:
        Cross-node constraints use dense Jacobian storage. For details on memory
        usage and performance implications, see LoweredCrossNodeConstraint documentation.

    Note:
        NodeReference is typically created via the `.at(k)` method on expressions
        rather than constructed directly.
    """

    def __init__(self, base: Expr, node_idx: int):
        """Initialize a NodeReference.

        Args:
            base: Expression to reference at a specific node (typically a Leaf)
            node_idx: Absolute trajectory node index (integer)
                     Supports negative indexing (e.g., -1 for last node)

        Raises:
            TypeError: If node_idx is not an integer
        """
        if not isinstance(node_idx, int):
            raise TypeError(f"Node index must be an integer, got {type(node_idx).__name__}")

        self.node_idx = node_idx
        self.base = base

    def children(self):
        """Return the base expression as the only child.

        Returns:
            list: Single-element list containing the base expression
        """
        return [self.base]

    def canonicalize(self) -> "Expr":
        """Canonicalize by canonicalizing the base expression.

        Returns:
            NodeReference: A new NodeReference with canonicalized base
        """
        canon_base = self.base.canonicalize()
        return NodeReference(canon_base, self.node_idx)

    def check_shape(self) -> Tuple[int, ...]:
        """Return the shape of the base expression.

        NodeReference doesn't change the shape of the underlying expression,
        it just references it at a specific time point.

        Returns:
            tuple: The shape of the base expression
        """
        return self.base.check_shape()

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash NodeReference including its node index.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"NodeReference")
        # Hash the node index (signed int)
        hasher.update(struct.pack(">i", self.node_idx))
        # Hash the base expression
        self.base._hash_into(hasher)

    def __repr__(self):
        """String representation of the NodeReference.

        Returns:
            str: String showing the base expression and node index
        """
        return f"{self.base!r}.at({self.node_idx})"
_hash_into(hasher: hashlib._Hash) -> None

Hash NodeReference including its node index.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/expr.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash NodeReference including its node index.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"NodeReference")
    # Hash the node index (signed int)
    hasher.update(struct.pack(">i", self.node_idx))
    # Hash the base expression
    self.base._hash_into(hasher)
canonicalize() -> Expr

Canonicalize by canonicalizing the base expression.

Returns:

Name Type Description
NodeReference Expr

A new NodeReference with canonicalized base

Source code in openscvx/symbolic/expr/expr.py
def canonicalize(self) -> "Expr":
    """Canonicalize by canonicalizing the base expression.

    Returns:
        NodeReference: A new NodeReference with canonicalized base
    """
    canon_base = self.base.canonicalize()
    return NodeReference(canon_base, self.node_idx)
check_shape() -> Tuple[int, ...]

Return the shape of the base expression.

NodeReference doesn't change the shape of the underlying expression, it just references it at a specific time point.

Returns:

Name Type Description
tuple Tuple[int, ...]

The shape of the base expression

Source code in openscvx/symbolic/expr/expr.py
def check_shape(self) -> Tuple[int, ...]:
    """Return the shape of the base expression.

    NodeReference doesn't change the shape of the underlying expression,
    it just references it at a specific time point.

    Returns:
        tuple: The shape of the base expression
    """
    return self.base.check_shape()
children()

Return the base expression as the only child.

Returns:

Name Type Description
list

Single-element list containing the base expression

Source code in openscvx/symbolic/expr/expr.py
def children(self):
    """Return the base expression as the only child.

    Returns:
        list: Single-element list containing the base expression
    """
    return [self.base]

Norm

Bases: Expr

Norm operation for symbolic expressions (reduction to scalar).

Computes the norm of an expression according to the specified order parameter. This is a reduction operation that always produces a scalar result regardless of the input shape. Supports various norm types following NumPy/SciPy conventions.

Attributes:

Name Type Description
operand

Expression to compute norm of

ord

Norm order specification (default: "fro" for Frobenius norm) - "fro": Frobenius norm (default) - "inf": Infinity norm - 1: L1 norm (sum of absolute values) - 2: L2 norm (Euclidean norm) - Other values as supported by the backend

Example

Define Norms:

x = Variable("x", shape=(3,))
euclidean_norm = Norm(x, ord=2)  # L2 norm, result is scalar
A = Variable("A", shape=(3, 4))
frobenius_norm = Norm(A)  # Frobenius norm, result is scalar
Source code in openscvx/symbolic/expr/linalg.py
class Norm(Expr):
    """Norm operation for symbolic expressions (reduction to scalar).

    Computes the norm of an expression according to the specified order parameter.
    This is a reduction operation that always produces a scalar result regardless
    of the input shape. Supports various norm types following NumPy/SciPy conventions.

    Attributes:
        operand: Expression to compute norm of
        ord: Norm order specification (default: "fro" for Frobenius norm)
            - "fro": Frobenius norm (default)
            - "inf": Infinity norm
            - 1: L1 norm (sum of absolute values)
            - 2: L2 norm (Euclidean norm)
            - Other values as supported by the backend

    Example:
        Define Norms:

            x = Variable("x", shape=(3,))
            euclidean_norm = Norm(x, ord=2)  # L2 norm, result is scalar
            A = Variable("A", shape=(3, 4))
            frobenius_norm = Norm(A)  # Frobenius norm, result is scalar
    """

    def __init__(self, operand, ord="fro"):
        """Initialize a norm operation.

        Args:
            operand: Expression to compute norm of
            ord: Norm order specification (default: "fro")
        """
        self.operand = to_expr(operand)
        self.ord = ord  # Can be "fro", "inf", 1, 2, etc.

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        """Canonicalize the operand but preserve the ord parameter."""
        canon_operand = self.operand.canonicalize()
        return Norm(canon_operand, ord=self.ord)

    def check_shape(self) -> Tuple[int, ...]:
        """Norm reduces any shape to a scalar."""
        # Validate that the operand has a valid shape
        self.operand.check_shape()
        # Norm always produces a scalar regardless of input shape
        return ()

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash Norm including its ord parameter.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"Norm")
        # Hash the ord parameter
        hasher.update(repr(self.ord).encode())
        # Hash the operand
        self.operand._hash_into(hasher)

    def __repr__(self):
        return f"norm({self.operand!r}, ord={self.ord!r})"
_hash_into(hasher: hashlib._Hash) -> None

Hash Norm including its ord parameter.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/linalg.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash Norm including its ord parameter.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"Norm")
    # Hash the ord parameter
    hasher.update(repr(self.ord).encode())
    # Hash the operand
    self.operand._hash_into(hasher)
canonicalize() -> Expr

Canonicalize the operand but preserve the ord parameter.

Source code in openscvx/symbolic/expr/linalg.py
def canonicalize(self) -> "Expr":
    """Canonicalize the operand but preserve the ord parameter."""
    canon_operand = self.operand.canonicalize()
    return Norm(canon_operand, ord=self.ord)
check_shape() -> Tuple[int, ...]

Norm reduces any shape to a scalar.

Source code in openscvx/symbolic/expr/linalg.py
def check_shape(self) -> Tuple[int, ...]:
    """Norm reduces any shape to a scalar."""
    # Validate that the operand has a valid shape
    self.operand.check_shape()
    # Norm always produces a scalar regardless of input shape
    return ()

Or

Bases: Expr

Logical OR operation for disjunctive constraints.

Represents a logical disjunction (OR) between multiple constraint expressions. This is particularly useful in STL-based trajectory optimization for expressing choices or alternatives in task specifications. The Or operation is typically relaxed using smooth approximations (e.g., LogSumExp) during optimization.

The Or operation allows expressing constraints like:

  • "Reach either goal A OR goal B"
  • "Avoid obstacle 1 OR obstacle 2" (at least one must be satisfied)
  • "Use path 1 OR path 2 OR path 3"

During optimization, the disjunction is typically approximated using: Or(φ₁, φ₂, ..., φₙ) ≈ LSE(φ₁, φ₂, ..., φₙ) ≥ 0

where LSE is the LogSumExp (smooth maximum) function.

Attributes:

Name Type Description
operands

List of expressions representing the disjunctive clauses

Example

Use Or STL operator to enforce that robot must reach either of two goal regions:

import openscvx as ox
x = ox.State("x", shape=(2,))
goal_a = ox.Parameter("goal_a", shape=(2,), value=[1.0, 1.0])
goal_b = ox.Parameter("goal_b", shape=(2,), value=[-1.0, -1.0])
# Robot is within 0.5 units of either goal
reach_a = 0.25 - ox.Norm(x - goal_a)**2
reach_b = 0.25 - ox.Norm(x - goal_b)**2
reach_either = ox.Or(reach_a, reach_b)
Note

The Or operation produces a scalar result even when operands are vector expressions, as it represents a single logical proposition.

See Also

LogSumExp: Common smooth approximation for OR operations Max: Hard maximum (non-smooth alternative)

Source code in openscvx/symbolic/expr/stl.py
class Or(Expr):
    """Logical OR operation for disjunctive constraints.

    Represents a logical disjunction (OR) between multiple constraint expressions.
    This is particularly useful in STL-based trajectory optimization for expressing
    choices or alternatives in task specifications. The Or operation is typically
    relaxed using smooth approximations (e.g., LogSumExp) during optimization.

    The Or operation allows expressing constraints like:

    - "Reach either goal A OR goal B"
    - "Avoid obstacle 1 OR obstacle 2" (at least one must be satisfied)
    - "Use path 1 OR path 2 OR path 3"

    During optimization, the disjunction is typically approximated using:
        Or(φ₁, φ₂, ..., φₙ) ≈ LSE(φ₁, φ₂, ..., φₙ) ≥ 0

    where LSE is the LogSumExp (smooth maximum) function.

    Attributes:
        operands: List of expressions representing the disjunctive clauses

    Example:
        Use Or STL operator to enforce that robot must reach either of two goal regions:

            import openscvx as ox
            x = ox.State("x", shape=(2,))
            goal_a = ox.Parameter("goal_a", shape=(2,), value=[1.0, 1.0])
            goal_b = ox.Parameter("goal_b", shape=(2,), value=[-1.0, -1.0])
            # Robot is within 0.5 units of either goal
            reach_a = 0.25 - ox.Norm(x - goal_a)**2
            reach_b = 0.25 - ox.Norm(x - goal_b)**2
            reach_either = ox.Or(reach_a, reach_b)

    Note:
        The Or operation produces a scalar result even when operands are vector
        expressions, as it represents a single logical proposition.

    See Also:
        LogSumExp: Common smooth approximation for OR operations
        Max: Hard maximum (non-smooth alternative)
    """

    def __init__(self, *operands):
        """Initialize a logical OR operation.

        Args:
            *operands: Two or more expressions to combine with logical OR.
                      Each operand typically represents a constraint or condition.

        Raises:
            ValueError: If fewer than two operands are provided
        """
        if len(operands) < 2:
            raise ValueError("Or requires at least two operands")
        self.operands = [to_expr(op) for op in operands]

    def children(self):
        return self.operands

    def canonicalize(self) -> "Expr":
        """Canonicalize by flattening nested Or expressions.

        Flattens nested Or operations into a single flat Or with all clauses
        at the same level. For example: Or(a, Or(b, c)) → Or(a, b, c).
        Also canonicalizes all operands recursively.

        Returns:
            Expr: Canonical form of the Or expression. If only one operand
                  remains after canonicalization, returns that operand directly.
        """
        operands = []

        for operand in self.operands:
            canonicalized = operand.canonicalize()
            if isinstance(canonicalized, Or):
                # Flatten nested Or: Or(a, Or(b, c)) -> Or(a, b, c)
                operands.extend(canonicalized.operands)
            else:
                operands.append(canonicalized)

        # Return simplified Or expression
        if len(operands) == 1:
            return operands[0]
        return Or(*operands)

    def check_shape(self) -> Tuple[int, ...]:
        """Validate operand shapes and return result shape.

        Checks that all operands have compatible (broadcastable) shapes. The Or
        operation supports broadcasting, allowing mixing of scalars and vectors.

        Returns:
            tuple: Empty tuple () indicating a scalar result, as Or represents
                   a single logical proposition

        Raises:
            ValueError: If fewer than two operands exist
            ValueError: If operand shapes are not broadcastable
        """
        if len(self.operands) < 2:
            raise ValueError("Or requires at least two operands")

        # Validate all operands and get their shapes
        operand_shapes = [operand.check_shape() for operand in self.operands]

        # For logical operations, all operands should be broadcastable
        # This allows mixing scalars with vectors for element-wise operations
        try:
            result_shape = operand_shapes[0]
            for shape in operand_shapes[1:]:
                result_shape = np.broadcast_shapes(result_shape, shape)
        except ValueError as e:
            raise ValueError(f"Or operands not broadcastable: {operand_shapes}") from e

        # Or produces a scalar result (like constraints)
        return ()

    def __repr__(self):
        operands_repr = " | ".join(repr(op) for op in self.operands)
        return f"Or({operands_repr})"
canonicalize() -> Expr

Canonicalize by flattening nested Or expressions.

Flattens nested Or operations into a single flat Or with all clauses at the same level. For example: Or(a, Or(b, c)) → Or(a, b, c). Also canonicalizes all operands recursively.

Returns:

Name Type Description
Expr Expr

Canonical form of the Or expression. If only one operand remains after canonicalization, returns that operand directly.

Source code in openscvx/symbolic/expr/stl.py
def canonicalize(self) -> "Expr":
    """Canonicalize by flattening nested Or expressions.

    Flattens nested Or operations into a single flat Or with all clauses
    at the same level. For example: Or(a, Or(b, c)) → Or(a, b, c).
    Also canonicalizes all operands recursively.

    Returns:
        Expr: Canonical form of the Or expression. If only one operand
              remains after canonicalization, returns that operand directly.
    """
    operands = []

    for operand in self.operands:
        canonicalized = operand.canonicalize()
        if isinstance(canonicalized, Or):
            # Flatten nested Or: Or(a, Or(b, c)) -> Or(a, b, c)
            operands.extend(canonicalized.operands)
        else:
            operands.append(canonicalized)

    # Return simplified Or expression
    if len(operands) == 1:
        return operands[0]
    return Or(*operands)
check_shape() -> Tuple[int, ...]

Validate operand shapes and return result shape.

Checks that all operands have compatible (broadcastable) shapes. The Or operation supports broadcasting, allowing mixing of scalars and vectors.

Returns:

Name Type Description
tuple Tuple[int, ...]

Empty tuple () indicating a scalar result, as Or represents a single logical proposition

Raises:

Type Description
ValueError

If fewer than two operands exist

ValueError

If operand shapes are not broadcastable

Source code in openscvx/symbolic/expr/stl.py
def check_shape(self) -> Tuple[int, ...]:
    """Validate operand shapes and return result shape.

    Checks that all operands have compatible (broadcastable) shapes. The Or
    operation supports broadcasting, allowing mixing of scalars and vectors.

    Returns:
        tuple: Empty tuple () indicating a scalar result, as Or represents
               a single logical proposition

    Raises:
        ValueError: If fewer than two operands exist
        ValueError: If operand shapes are not broadcastable
    """
    if len(self.operands) < 2:
        raise ValueError("Or requires at least two operands")

    # Validate all operands and get their shapes
    operand_shapes = [operand.check_shape() for operand in self.operands]

    # For logical operations, all operands should be broadcastable
    # This allows mixing scalars with vectors for element-wise operations
    try:
        result_shape = operand_shapes[0]
        for shape in operand_shapes[1:]:
            result_shape = np.broadcast_shapes(result_shape, shape)
    except ValueError as e:
        raise ValueError(f"Or operands not broadcastable: {operand_shapes}") from e

    # Or produces a scalar result (like constraints)
    return ()

Parameter

Bases: Leaf

Parameter that can be changed at runtime without recompilation.

Parameters are symbolic variables with initial values that can be updated through the problem's parameter dictionary. They allow for efficient parameter sweeps without needing to recompile the optimization problem.

Example

obs_center = ox.Parameter("obs_center", shape=(3,), value=np.array([1.0, 0.0, 0.0]))

Later: problem.parameters["obs_center"] = new_value
Source code in openscvx/symbolic/expr/expr.py
class Parameter(Leaf):
    """Parameter that can be changed at runtime without recompilation.

    Parameters are symbolic variables with initial values that can be updated
    through the problem's parameter dictionary. They allow for efficient
    parameter sweeps without needing to recompile the optimization problem.

    Example:
        obs_center = ox.Parameter("obs_center", shape=(3,), value=np.array([1.0, 0.0, 0.0]))
        # Later: problem.parameters["obs_center"] = new_value
    """

    def __init__(self, name: str, shape: tuple = (), value=None):
        """Initialize a Parameter node.

        Args:
            name (str): Name identifier for the parameter
            shape (tuple): Shape of the parameter (default: scalar)
            value: Initial value for the parameter (required)
        """
        super().__init__(name, shape)
        if value is None:
            raise ValueError(f"Parameter '{name}' requires an initial value")
        self.value = np.asarray(value, dtype=float)

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash Parameter by its shape only (value-invariant).

        Parameters are hashed by shape only, not by value. This allows the same
        compiled solver to be reused across parameter sweeps - only the structure
        matters for compilation, not the actual values.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"Parameter")
        hasher.update(str(self._shape).encode())
_hash_into(hasher: hashlib._Hash) -> None

Hash Parameter by its shape only (value-invariant).

Parameters are hashed by shape only, not by value. This allows the same compiled solver to be reused across parameter sweeps - only the structure matters for compilation, not the actual values.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/expr.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash Parameter by its shape only (value-invariant).

    Parameters are hashed by shape only, not by value. This allows the same
    compiled solver to be reused across parameter sweeps - only the structure
    matters for compilation, not the actual values.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"Parameter")
    hasher.update(str(self._shape).encode())

PositivePart

Bases: Expr

Positive part function for symbolic expressions.

Computes max(x, 0) element-wise, effectively zeroing out negative values while preserving positive values. This is also known as the ReLU (Rectified Linear Unit) function and is commonly used as a penalty function building block in optimization.

Attributes:

Name Type Description
x

Expression to apply positive part function to

Example

Define a PositivePart expression:

constraint_violation = x - 10
penalty = PositivePart(constraint_violation)  # Penalizes x > 10
Source code in openscvx/symbolic/expr/math.py
class PositivePart(Expr):
    """Positive part function for symbolic expressions.

    Computes max(x, 0) element-wise, effectively zeroing out negative values
    while preserving positive values. This is also known as the ReLU (Rectified
    Linear Unit) function and is commonly used as a penalty function building
    block in optimization.

    Attributes:
        x: Expression to apply positive part function to

    Example:
        Define a PositivePart expression:

            constraint_violation = x - 10
            penalty = PositivePart(constraint_violation)  # Penalizes x > 10
    """

    def __init__(self, x):
        """Initialize a positive part operation.

        Args:
            x: Expression to apply positive part function to
        """
        self.x = to_expr(x)

    def children(self):
        return [self.x]

    def canonicalize(self) -> "Expr":
        x = self.x.canonicalize()
        return PositivePart(x)

    def check_shape(self) -> Tuple[int, ...]:
        """pos(x) = max(x, 0) preserves the shape of x."""
        return self.x.check_shape()

    def __repr__(self):
        return f"pos({self.x!r})"
check_shape() -> Tuple[int, ...]

pos(x) = max(x, 0) preserves the shape of x.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """pos(x) = max(x, 0) preserves the shape of x."""
    return self.x.check_shape()

Power

Bases: Expr

Element-wise power operation for symbolic expressions.

Represents element-wise exponentiation (base ** exponent). Supports broadcasting following NumPy rules. Can be created using the ** operator on Expr objects.

Attributes:

Name Type Description
base

Base expression

exponent

Exponent expression

Example

Define a Power expression:

x = ox.State("x", shape=(3,))
y = x ** 2  # Creates Power(x, Constant(2))
Source code in openscvx/symbolic/expr/arithmetic.py
class Power(Expr):
    """Element-wise power operation for symbolic expressions.

    Represents element-wise exponentiation (base ** exponent). Supports broadcasting
    following NumPy rules. Can be created using the ** operator on Expr objects.

    Attributes:
        base: Base expression
        exponent: Exponent expression

    Example:
        Define a Power expression:

            x = ox.State("x", shape=(3,))
            y = x ** 2  # Creates Power(x, Constant(2))
    """

    def __init__(self, base, exponent):
        """Initialize a power operation.

        Args:
            base: Base expression
            exponent: Exponent expression
        """
        self.base = to_expr(base)
        self.exponent = to_expr(exponent)

    def children(self):
        return [self.base, self.exponent]

    def canonicalize(self) -> "Expr":
        """Canonicalize power by canonicalizing base and exponent.

        Returns:
            Expr: Canonical form of the power expression
        """
        base = self.base.canonicalize()
        exponent = self.exponent.canonicalize()
        return Power(base, exponent)

    def check_shape(self) -> Tuple[int, ...]:
        shapes = [child.check_shape() for child in self.children()]
        try:
            return np.broadcast_shapes(*shapes)
        except ValueError as e:
            raise ValueError(f"Power shapes not broadcastable: {shapes}") from e

    def __repr__(self):
        return f"({self.base!r})**({self.exponent!r})"
canonicalize() -> Expr

Canonicalize power by canonicalizing base and exponent.

Returns:

Name Type Description
Expr Expr

Canonical form of the power expression

Source code in openscvx/symbolic/expr/arithmetic.py
def canonicalize(self) -> "Expr":
    """Canonicalize power by canonicalizing base and exponent.

    Returns:
        Expr: Canonical form of the power expression
    """
    base = self.base.canonicalize()
    exponent = self.exponent.canonicalize()
    return Power(base, exponent)

QDCM

Bases: Expr

Quaternion to Direction Cosine Matrix (DCM) conversion.

Converts a unit quaternion representation to a 3x3 direction cosine matrix (also known as a rotation matrix). This operation is commonly used in 6-DOF spacecraft dynamics, aircraft simulation, and robotics applications.

The quaternion is expected in scalar-last format: [qx, qy, qz, qw] where qw is the scalar component. The resulting DCM can be used to transform vectors from one reference frame to another.

Attributes:

Name Type Description
q

Quaternion expression with shape (4,)

Example

Use the QDCM to rotate a vector:

import openscvx as ox
q = ox.State("q", shape=(4,))
dcm = ox.QDCM(q)  # Creates rotation matrix, shape (3, 3)
v_body = ox.Variable("v_body", shape=(3,))
v_inertial = dcm @ v_body
Note

The input quaternion does not need to be normalized; the implementation automatically handles normalization during evaluation.

Source code in openscvx/symbolic/expr/spatial.py
class QDCM(Expr):
    """Quaternion to Direction Cosine Matrix (DCM) conversion.

    Converts a unit quaternion representation to a 3x3 direction cosine matrix
    (also known as a rotation matrix). This operation is commonly used in 6-DOF
    spacecraft dynamics, aircraft simulation, and robotics applications.

    The quaternion is expected in scalar-last format: [qx, qy, qz, qw] where
    qw is the scalar component. The resulting DCM can be used to transform vectors
    from one reference frame to another.

    Attributes:
        q: Quaternion expression with shape (4,)

    Example:
        Use the QDCM to rotate a vector:

            import openscvx as ox
            q = ox.State("q", shape=(4,))
            dcm = ox.QDCM(q)  # Creates rotation matrix, shape (3, 3)
            v_body = ox.Variable("v_body", shape=(3,))
            v_inertial = dcm @ v_body

    Note:
        The input quaternion does not need to be normalized; the implementation
        automatically handles normalization during evaluation.
    """

    def __init__(self, q):
        """Initialize a quaternion to DCM conversion.

        Args:
            q: Quaternion expression with shape (4,) in [qx, qy, qz, qw] format
        """
        self.q = to_expr(q)

    def children(self):
        return [self.q]

    def canonicalize(self) -> "Expr":
        q = self.q.canonicalize()
        return QDCM(q)

    def check_shape(self) -> Tuple[int, ...]:
        """Check that input is a quaternion and return DCM shape.

        Returns:
            tuple: Shape (3, 3) for the resulting direction cosine matrix

        Raises:
            ValueError: If quaternion does not have shape (4,)
        """
        q_shape = self.q.check_shape()
        if q_shape != (4,):
            raise ValueError(f"QDCM expects quaternion with shape (4,), got {q_shape}")
        return (3, 3)

    def __repr__(self):
        return f"qdcm({self.q!r})"
check_shape() -> Tuple[int, ...]

Check that input is a quaternion and return DCM shape.

Returns:

Name Type Description
tuple Tuple[int, ...]

Shape (3, 3) for the resulting direction cosine matrix

Raises:

Type Description
ValueError

If quaternion does not have shape (4,)

Source code in openscvx/symbolic/expr/spatial.py
def check_shape(self) -> Tuple[int, ...]:
    """Check that input is a quaternion and return DCM shape.

    Returns:
        tuple: Shape (3, 3) for the resulting direction cosine matrix

    Raises:
        ValueError: If quaternion does not have shape (4,)
    """
    q_shape = self.q.check_shape()
    if q_shape != (4,):
        raise ValueError(f"QDCM expects quaternion with shape (4,), got {q_shape}")
    return (3, 3)

SSM

Bases: Expr

Angular rate vector to 3x3 skew-symmetric matrix (cross product matrix).

Constructs the 3x3 skew-symmetric matrix [ω]x that represents the cross product operation. For any 3D vector v, the cross product ω x v can be computed as the matrix-vector product [ω]x @ v.

The resulting matrix has the form

⎡ 0 -ωz ωy ⎤ ⎢ ωz 0 -ωx ⎥ ⎣-ωy ωx 0 ⎦

This operation is widely used in: - Rigid body dynamics (angular momentum calculations) - DCM time derivatives: Ṙ = [ω]x @ R - Velocity kinematics in robotics - Coriolis and centrifugal acceleration terms

Attributes:

Name Type Description
w

Angular velocity or 3D vector expression with shape (3,)

Example

Use the SSM to compute the rotation matrix derivative:

import openscvx as ox
omega = ox.Control("omega", shape=(3,))
R = ox.State("R", shape=(3, 3))  # Direction cosine matrix
# DCM time derivative
R_dot = ox.SSM(omega) @ R
Note

The skew-symmetric property ensures that [ω]xᵀ = -[ω]x, which is important for preserving orthogonality in DCM propagation.

See Also

SSMP: 4x4 skew-symmetric matrix for quaternion dynamics

Source code in openscvx/symbolic/expr/spatial.py
class SSM(Expr):
    """Angular rate vector to 3x3 skew-symmetric matrix (cross product matrix).

    Constructs the 3x3 skew-symmetric matrix [ω]x that represents the cross
    product operation. For any 3D vector v, the cross product ω x v can be
    computed as the matrix-vector product [ω]x @ v.

    The resulting matrix has the form:
        ⎡  0  -ωz   ωy ⎤
        ⎢ ωz    0  -ωx ⎥
        ⎣-ωy   ωx    0 ⎦

    This operation is widely used in:
    - Rigid body dynamics (angular momentum calculations)
    - DCM time derivatives: Ṙ = [ω]x @ R
    - Velocity kinematics in robotics
    - Coriolis and centrifugal acceleration terms

    Attributes:
        w: Angular velocity or 3D vector expression with shape (3,)

    Example:
        Use the SSM to compute the rotation matrix derivative:

            import openscvx as ox
            omega = ox.Control("omega", shape=(3,))
            R = ox.State("R", shape=(3, 3))  # Direction cosine matrix
            # DCM time derivative
            R_dot = ox.SSM(omega) @ R

    Note:
        The skew-symmetric property ensures that [ω]xᵀ = -[ω]x, which is
        important for preserving orthogonality in DCM propagation.

    See Also:
        SSMP: 4x4 skew-symmetric matrix for quaternion dynamics
    """

    def __init__(self, w):
        """Initialize a vector to skew-symmetric matrix conversion.

        Args:
            w: 3D vector expression with shape (3,) in [ωx, ωy, ωz] format
        """
        self.w = to_expr(w)

    def children(self):
        return [self.w]

    def canonicalize(self) -> "Expr":
        w = self.w.canonicalize()
        return SSM(w)

    def check_shape(self) -> Tuple[int, ...]:
        """Check that input is a 3D vector and return matrix shape.

        Returns:
            tuple: Shape (3, 3) for the resulting skew-symmetric matrix

        Raises:
            ValueError: If input vector does not have shape (3,)
        """
        w_shape = self.w.check_shape()
        if w_shape != (3,):
            raise ValueError(f"SSM expects angular velocity with shape (3,), got {w_shape}")
        return (3, 3)

    def __repr__(self):
        return f"ssm({self.w!r})"
check_shape() -> Tuple[int, ...]

Check that input is a 3D vector and return matrix shape.

Returns:

Name Type Description
tuple Tuple[int, ...]

Shape (3, 3) for the resulting skew-symmetric matrix

Raises:

Type Description
ValueError

If input vector does not have shape (3,)

Source code in openscvx/symbolic/expr/spatial.py
def check_shape(self) -> Tuple[int, ...]:
    """Check that input is a 3D vector and return matrix shape.

    Returns:
        tuple: Shape (3, 3) for the resulting skew-symmetric matrix

    Raises:
        ValueError: If input vector does not have shape (3,)
    """
    w_shape = self.w.check_shape()
    if w_shape != (3,):
        raise ValueError(f"SSM expects angular velocity with shape (3,), got {w_shape}")
    return (3, 3)

SSMP

Bases: Expr

Angular rate to 4x4 skew-symmetric matrix for quaternion dynamics.

Constructs the 4x4 skew-symmetric matrix Ω(ω) used in quaternion kinematic differential equations. This matrix relates angular velocity to the time derivative of the quaternion:

 = (1/2) * Ω(ω) @ q
The resulting matrix has the form

⎡ 0 ωz -ωy ωx ⎤ ⎢-ωz 0 ωx ωy ⎥ ⎢ ωy -ωx 0 ωz ⎥ ⎣-ωx -ωy -ωz 0 ⎦

This is particularly useful for formulating quaternion-based attitude dynamics in spacecraft and aircraft trajectory optimization problems.

Attributes:

Name Type Description
w

Angular velocity vector expression with shape (3,)

Example

Use the SSMP to compute the quaternion derivative:

import openscvx as ox
omega = ox.Control("omega", shape=(3,))
q = ox.State("q", shape=(4,))
# Quaternion kinematic equation
q_dot = 0.5 * ox.SSMP(omega) @ q
See Also

SSM: 3x3 skew-symmetric matrix for cross product operations

Source code in openscvx/symbolic/expr/spatial.py
class SSMP(Expr):
    """Angular rate to 4x4 skew-symmetric matrix for quaternion dynamics.

    Constructs the 4x4 skew-symmetric matrix Ω(ω) used in quaternion kinematic
    differential equations. This matrix relates angular velocity to the time
    derivative of the quaternion:

        q̇ = (1/2) * Ω(ω) @ q

    The resulting matrix has the form:
        ⎡  0   ωz  -ωy   ωx ⎤
        ⎢-ωz    0   ωx   ωy ⎥
        ⎢ ωy  -ωx    0   ωz ⎥
        ⎣-ωx  -ωy  -ωz    0 ⎦

    This is particularly useful for formulating quaternion-based attitude
    dynamics in spacecraft and aircraft trajectory optimization problems.

    Attributes:
        w: Angular velocity vector expression with shape (3,)

    Example:
        Use the SSMP to compute the quaternion derivative:

            import openscvx as ox
            omega = ox.Control("omega", shape=(3,))
            q = ox.State("q", shape=(4,))
            # Quaternion kinematic equation
            q_dot = 0.5 * ox.SSMP(omega) @ q

    See Also:
        SSM: 3x3 skew-symmetric matrix for cross product operations
    """

    def __init__(self, w):
        """Initialize an angular velocity to skew-symmetric matrix conversion.

        Args:
            w: Angular velocity vector expression with shape (3,) in [ωx, ωy, ωz] format
        """
        self.w = to_expr(w)

    def children(self):
        return [self.w]

    def canonicalize(self) -> "Expr":
        w = self.w.canonicalize()
        return SSMP(w)

    def check_shape(self) -> Tuple[int, ...]:
        """Check that input is a 3D angular velocity and return matrix shape.

        Returns:
            tuple: Shape (4, 4) for the resulting skew-symmetric matrix

        Raises:
            ValueError: If angular velocity does not have shape (3,)
        """
        w_shape = self.w.check_shape()
        if w_shape != (3,):
            raise ValueError(f"SSMP expects angular velocity with shape (3,), got {w_shape}")
        return (4, 4)

    def __repr__(self):
        return f"ssmp({self.w!r})"
check_shape() -> Tuple[int, ...]

Check that input is a 3D angular velocity and return matrix shape.

Returns:

Name Type Description
tuple Tuple[int, ...]

Shape (4, 4) for the resulting skew-symmetric matrix

Raises:

Type Description
ValueError

If angular velocity does not have shape (3,)

Source code in openscvx/symbolic/expr/spatial.py
def check_shape(self) -> Tuple[int, ...]:
    """Check that input is a 3D angular velocity and return matrix shape.

    Returns:
        tuple: Shape (4, 4) for the resulting skew-symmetric matrix

    Raises:
        ValueError: If angular velocity does not have shape (3,)
    """
    w_shape = self.w.check_shape()
    if w_shape != (3,):
        raise ValueError(f"SSMP expects angular velocity with shape (3,), got {w_shape}")
    return (4, 4)

Sin

Bases: Expr

Element-wise sine function for symbolic expressions.

Computes the sine of each element in the operand. Preserves the shape of the input expression.

Attributes:

Name Type Description
operand

Expression to apply sine function to

Example

Define a Sin expression:

theta = Variable("theta", shape=(3,))
sin_theta = Sin(theta)
Source code in openscvx/symbolic/expr/math.py
class Sin(Expr):
    """Element-wise sine function for symbolic expressions.

    Computes the sine of each element in the operand. Preserves the shape
    of the input expression.

    Attributes:
        operand: Expression to apply sine function to

    Example:
        Define a Sin expression:

            theta = Variable("theta", shape=(3,))
            sin_theta = Sin(theta)
    """

    def __init__(self, operand):
        """Initialize a sine operation.

        Args:
            operand: Expression to apply sine function to
        """
        self.operand = operand

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Sin(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Sin preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"(sin({self.operand!r}))"
check_shape() -> Tuple[int, ...]

Sin preserves the shape of its operand.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Sin preserves the shape of its operand."""
    return self.operand.check_shape()

SmoothReLU

Bases: Expr

Smooth approximation to the ReLU (positive part) function.

Computes a smooth, differentiable approximation to max(x, 0) using the formula: sqrt(max(x, 0)^2 + c^2) - c

The parameter c controls the smoothness: smaller values give a sharper transition, while larger values produce a smoother approximation. As c approaches 0, this converges to the standard ReLU function.

This is particularly useful in optimization contexts where smooth gradients are required, such as in penalty methods for constraint handling (CTCS).

Attributes:

Name Type Description
x

Expression to apply smooth ReLU to

c

Smoothing parameter (default: 1e-8)

Example

Define a smooth ReLU expression:

constraint_violation = x - 10
penalty = SmoothReLU(constraint_violation, c=1e-6)
Source code in openscvx/symbolic/expr/math.py
class SmoothReLU(Expr):
    """Smooth approximation to the ReLU (positive part) function.

    Computes a smooth, differentiable approximation to max(x, 0) using the formula:
    sqrt(max(x, 0)^2 + c^2) - c

    The parameter c controls the smoothness: smaller values give a sharper
    transition, while larger values produce a smoother approximation. As c
    approaches 0, this converges to the standard ReLU function.

    This is particularly useful in optimization contexts where smooth gradients
    are required, such as in penalty methods for constraint handling (CTCS).

    Attributes:
        x: Expression to apply smooth ReLU to
        c: Smoothing parameter (default: 1e-8)

    Example:
        Define a smooth ReLU expression:

            constraint_violation = x - 10
            penalty = SmoothReLU(constraint_violation, c=1e-6)
    """

    def __init__(self, x, c: float = 1e-8):
        """Initialize a smooth ReLU operation.

        Args:
            x: Expression to apply smooth ReLU to
            c: Smoothing parameter controlling transition sharpness (default: 1e-8)
        """
        self.x = to_expr(x)
        self.c = float(c)

    def children(self):
        return [self.x]

    def canonicalize(self) -> "Expr":
        """Canonicalize the operand but preserve c parameter."""
        x = self.x.canonicalize()
        return SmoothReLU(x, c=self.c)

    def check_shape(self) -> Tuple[int, ...]:
        """Smooth ReLU preserves the shape of x."""
        return self.x.check_shape()

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash SmoothReLU including its c parameter.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(b"SmoothReLU")
        # Hash c as bytes
        hasher.update(struct.pack(">d", self.c))
        # Hash the operand
        self.x._hash_into(hasher)

    def __repr__(self):
        return f"smooth_relu({self.x!r}, c={self.c})"
_hash_into(hasher: hashlib._Hash) -> None

Hash SmoothReLU including its c parameter.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/math.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash SmoothReLU including its c parameter.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(b"SmoothReLU")
    # Hash c as bytes
    hasher.update(struct.pack(">d", self.c))
    # Hash the operand
    self.x._hash_into(hasher)
canonicalize() -> Expr

Canonicalize the operand but preserve c parameter.

Source code in openscvx/symbolic/expr/math.py
def canonicalize(self) -> "Expr":
    """Canonicalize the operand but preserve c parameter."""
    x = self.x.canonicalize()
    return SmoothReLU(x, c=self.c)
check_shape() -> Tuple[int, ...]

Smooth ReLU preserves the shape of x.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Smooth ReLU preserves the shape of x."""
    return self.x.check_shape()

Sqrt

Bases: Expr

Element-wise square root function for symbolic expressions.

Computes the square root of each element in the operand. Preserves the shape of the input expression.

Attributes:

Name Type Description
operand

Expression to apply square root to

Example

Define a Sqrt expression:

x = Variable("x", shape=(3,))
sqrt_x = Sqrt(x)
Source code in openscvx/symbolic/expr/math.py
class Sqrt(Expr):
    """Element-wise square root function for symbolic expressions.

    Computes the square root of each element in the operand. Preserves the
    shape of the input expression.

    Attributes:
        operand: Expression to apply square root to

    Example:
        Define a Sqrt expression:

            x = Variable("x", shape=(3,))
            sqrt_x = Sqrt(x)
    """

    def __init__(self, operand):
        """Initialize a square root operation.

        Args:
            operand: Expression to apply square root to
        """
        self.operand = to_expr(operand)

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Sqrt(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Sqrt preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"sqrt({self.operand!r})"
check_shape() -> Tuple[int, ...]

Sqrt preserves the shape of its operand.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Sqrt preserves the shape of its operand."""
    return self.operand.check_shape()

Square

Bases: Expr

Element-wise square function for symbolic expressions.

Computes the square (x^2) of each element in the operand. Preserves the shape of the input expression. This is more efficient than using Power(x, 2) for some optimization backends.

Attributes:

Name Type Description
x

Expression to square

Example

Define a Square expression:

v = Variable("v", shape=(3,))
v_squared = Square(v)  # Equivalent to v ** 2
Source code in openscvx/symbolic/expr/math.py
class Square(Expr):
    """Element-wise square function for symbolic expressions.

    Computes the square (x^2) of each element in the operand. Preserves the
    shape of the input expression. This is more efficient than using Power(x, 2)
    for some optimization backends.

    Attributes:
        x: Expression to square

    Example:
        Define a Square expression:

            v = Variable("v", shape=(3,))
            v_squared = Square(v)  # Equivalent to v ** 2
    """

    def __init__(self, x):
        """Initialize a square operation.

        Args:
            x: Expression to square
        """
        self.x = to_expr(x)

    def children(self):
        return [self.x]

    def canonicalize(self) -> "Expr":
        x = self.x.canonicalize()
        return Square(x)

    def check_shape(self) -> Tuple[int, ...]:
        """x^2 preserves the shape of x."""
        return self.x.check_shape()

    def __repr__(self):
        return f"({self.x!r})^2"
check_shape() -> Tuple[int, ...]

x^2 preserves the shape of x.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """x^2 preserves the shape of x."""
    return self.x.check_shape()

Stack

Bases: Expr

Stack expressions vertically to create a higher-dimensional array.

Stacks a list of expressions along a new first dimension. All input expressions must have the same shape. The result has shape (num_rows, *row_shape).

This is similar to numpy.array([row1, row2, ...]) or jax.numpy.stack(rows, axis=0).

Attributes:

Name Type Description
rows

List of expressions to stack, each representing a "row"

Example

Leverage stack to combine expressions:

x = Variable("x", shape=(3,))
y = Variable("y", shape=(3,))
z = Variable("z", shape=(3,))
stacked = Stack([x, y, z])  # Creates shape (3, 3)
# Equivalent to: [[x[0], x[1], x[2]],
#                 [y[0], y[1], y[2]],
#                 [z[0], z[1], z[2]]]
Source code in openscvx/symbolic/expr/array.py
class Stack(Expr):
    """Stack expressions vertically to create a higher-dimensional array.

    Stacks a list of expressions along a new first dimension. All input expressions
    must have the same shape. The result has shape (num_rows, *row_shape).

    This is similar to numpy.array([row1, row2, ...]) or jax.numpy.stack(rows, axis=0).

    Attributes:
        rows: List of expressions to stack, each representing a "row"

    Example:
        Leverage stack to combine expressions:

            x = Variable("x", shape=(3,))
            y = Variable("y", shape=(3,))
            z = Variable("z", shape=(3,))
            stacked = Stack([x, y, z])  # Creates shape (3, 3)
            # Equivalent to: [[x[0], x[1], x[2]],
            #                 [y[0], y[1], y[2]],
            #                 [z[0], z[1], z[2]]]
    """

    def __init__(self, rows):
        """Initialize a stack operation.

        Args:
            rows: List of expressions to stack along a new first dimension.
                  All expressions must have the same shape.
        """
        # rows should be a list of expressions representing each row
        self.rows = [to_expr(row) for row in rows]

    def children(self):
        return self.rows

    def canonicalize(self) -> "Expr":
        rows = [row.canonicalize() for row in self.rows]
        return Stack(rows)

    def check_shape(self) -> Tuple[int, ...]:
        """Stack creates a 2D matrix from 1D rows."""
        if not self.rows:
            raise ValueError("Stack requires at least one row")

        # All rows should have the same shape
        row_shapes = [row.check_shape() for row in self.rows]

        # Verify all rows have the same shape
        first_shape = row_shapes[0]
        for i, shape in enumerate(row_shapes[1:], 1):
            if shape != first_shape:
                raise ValueError(
                    f"Stack row {i} has shape {shape}, but row 0 has shape {first_shape}"
                )

        # Result shape is (num_rows, *row_shape)
        return (len(self.rows),) + first_shape

    def __repr__(self):
        rows_repr = ", ".join(repr(row) for row in self.rows)
        return f"Stack([{rows_repr}])"
check_shape() -> Tuple[int, ...]

Stack creates a 2D matrix from 1D rows.

Source code in openscvx/symbolic/expr/array.py
def check_shape(self) -> Tuple[int, ...]:
    """Stack creates a 2D matrix from 1D rows."""
    if not self.rows:
        raise ValueError("Stack requires at least one row")

    # All rows should have the same shape
    row_shapes = [row.check_shape() for row in self.rows]

    # Verify all rows have the same shape
    first_shape = row_shapes[0]
    for i, shape in enumerate(row_shapes[1:], 1):
        if shape != first_shape:
            raise ValueError(
                f"Stack row {i} has shape {shape}, but row 0 has shape {first_shape}"
            )

    # Result shape is (num_rows, *row_shape)
    return (len(self.rows),) + first_shape

State

Bases: Variable

State variable with boundary conditions for trajectory optimization.

State represents a dynamic state variable in a trajectory optimization problem. Unlike control inputs, states evolve according to dynamics constraints and can have boundary conditions specified at the initial and final time points. Like all Variables, States also support min/max bounds and initial trajectory guesses to help guide the optimization solver toward good solutions.

States support four types of boundary conditions:

  • fixed: State value is constrained to a specific value
  • free: State value is optimized within the specified bounds
  • minimize: Adds a term to the objective function to minimize the state value
  • maximize: Adds a term to the objective function to maximize the state value

Each element of a multi-dimensional state can have different boundary condition types, allowing for fine-grained control over the optimization.

Attributes:

Name Type Description
name str

Unique name identifier for this state variable

_shape tuple[int, ...]

Shape of the state vector (typically 1D like (3,) for 3D position)

_slice slice | None

Internal slice information for variable indexing

_min ndarray | None

Minimum bounds for state variables

_max ndarray | None

Maximum bounds for state variables

_guess ndarray | None

Initial trajectory guess

_initial ndarray | None

Initial state values with boundary condition types

initial_type ndarray | None

Array of boundary condition types for initial state

_final ndarray | None

Final state values with boundary condition types

final_type ndarray | None

Array of boundary condition types for final state

Example

Scalar time state with fixed initial time, minimize final time:

time = State("time", (1,))
time.min = [0.0]
time.max = [10.0]
time.initial = [("fixed", 0.0)]
time.final = [("minimize", 5.0)]

3D position state with mixed boundary conditions:

pos = State("pos", (3,))
pos.min = [0, 0, 10]
pos.max = [10, 10, 200]
pos.initial = [0, ("free", 1), 50]  # x fixed, y free, z fixed
pos.final = [10, ("free", 5), ("maximize", 150)]  # Maximize final altitude
Source code in openscvx/symbolic/expr/state.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
class State(Variable):
    """State variable with boundary conditions for trajectory optimization.

    State represents a dynamic state variable in a trajectory optimization problem.
    Unlike control inputs, states evolve according to dynamics constraints and can
    have boundary conditions specified at the initial and final time points.
    Like all Variables, States also support min/max bounds and initial trajectory
    guesses to help guide the optimization solver toward good solutions.

    States support four types of boundary conditions:

    - **fixed**: State value is constrained to a specific value
    - **free**: State value is optimized within the specified bounds
    - **minimize**: Adds a term to the objective function to minimize the state value
    - **maximize**: Adds a term to the objective function to maximize the state value

    Each element of a multi-dimensional state can have different boundary condition
    types, allowing for fine-grained control over the optimization.

    Attributes:
        name (str): Unique name identifier for this state variable
        _shape (tuple[int, ...]): Shape of the state vector (typically 1D like (3,) for 3D position)
        _slice (slice | None): Internal slice information for variable indexing
        _min (np.ndarray | None): Minimum bounds for state variables
        _max (np.ndarray | None): Maximum bounds for state variables
        _guess (np.ndarray | None): Initial trajectory guess
        _initial (np.ndarray | None): Initial state values with boundary condition types
        initial_type (np.ndarray | None): Array of boundary condition types for initial state
        _final (np.ndarray | None): Final state values with boundary condition types
        final_type (np.ndarray | None): Array of boundary condition types for final state

    Example:
        Scalar time state with fixed initial time, minimize final time:

            time = State("time", (1,))
            time.min = [0.0]
            time.max = [10.0]
            time.initial = [("fixed", 0.0)]
            time.final = [("minimize", 5.0)]

        3D position state with mixed boundary conditions:

            pos = State("pos", (3,))
            pos.min = [0, 0, 10]
            pos.max = [10, 10, 200]
            pos.initial = [0, ("free", 1), 50]  # x fixed, y free, z fixed
            pos.final = [10, ("free", 5), ("maximize", 150)]  # Maximize final altitude
    """

    def __init__(self, name, shape):
        """Initialize a State object.

        Args:
            name: Name identifier for the state variable
            shape: Shape of the state vector (typically 1D tuple)
        """
        super().__init__(name, shape)
        self._initial = None
        self.initial_type = None
        self._final = None
        self.final_type = None
        self._scaling_min = None
        self._scaling_max = None

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash State including boundary condition types.

        Extends Variable._hash_into to include the structural metadata that
        affects the compiled problem: boundary condition types (fixed, free,
        minimize, maximize). Values are not hashed as they are runtime parameters.

        Args:
            hasher: A hashlib hash object to update
        """
        # Hash the base Variable attributes (class name, shape, slice)
        super()._hash_into(hasher)
        # Hash boundary condition types (these affect constraint structure)
        if self.initial_type is not None:
            hasher.update(b"initial_type:")
            hasher.update(str(self.initial_type.tolist()).encode())
        if self.final_type is not None:
            hasher.update(b"final_type:")
            hasher.update(str(self.final_type.tolist()).encode())

    @property
    def min(self):
        """Get the minimum bounds for the state variables.

        Returns:
            Array of minimum values for each state variable element.

        Example:
            Get lower bounds:

                pos = State("pos", (3,))
                pos.min = [0, 0, 10]
                print(pos.min)  # [0. 0. 10.]
        """
        return self._min

    @min.setter
    def min(self, val):
        """Set the minimum bounds for the state variables.

        Bounds are validated against any fixed initial/final conditions to ensure
        consistency.

        Args:
            val: Array of minimum values, must match the state shape exactly

        Raises:
            ValueError: If the shape doesn't match the state shape, or if fixed
                boundary conditions violate the bounds

        Example:
            Set lower bounds:

                pos = State("pos", (3,))
                pos.min = [0, 0, 10]
                pos.initial = [0, 5, 15]  # Must satisfy: 0>=0, 5>=0, 15>=10
        """
        val = np.asarray(val, dtype=float)
        if val.shape != self.shape:
            raise ValueError(f"Min shape {val.shape} does not match State shape {self.shape}")
        self._min = val
        self._check_bounds_against_initial_final()

    @property
    def max(self):
        """Get the maximum bounds for the state variables.

        Returns:
            Array of maximum values for each state variable element.

        Example:
            Get upper bounds:

                vel = State("vel", (3,))
                vel.max = [10, 10, 5]
                print(vel.max)  # [10. 10. 5.]
        """
        return self._max

    @max.setter
    def max(self, val):
        """Set the maximum bounds for the state variables.

        Bounds are validated against any fixed initial/final conditions to ensure
        consistency.

        Args:
            val: Array of maximum values, must match the state shape exactly

        Raises:
            ValueError: If the shape doesn't match the state shape, or if fixed
                boundary conditions violate the bounds

        Example:
            Set upper bounds:

                vel = State("vel", (3,))
                vel.max = [10, 10, 5]
                vel.final = [8, 9, 4]  # Must satisfy: 8<=10, 9<=10, 4<=5
        """
        val = np.asarray(val, dtype=float)
        if val.shape != self.shape:
            raise ValueError(f"Max shape {val.shape} does not match State shape {self.shape}")
        self._max = val
        self._check_bounds_against_initial_final()

    def _check_bounds_against_initial_final(self):
        """Validate that fixed boundary conditions respect min/max bounds.

        This internal method is automatically called when bounds or boundary
        conditions are set to ensure consistency.

        Raises:
            ValueError: If any fixed initial or final value violates the min/max bounds
        """
        for field_name, data, types in [
            ("initial", self._initial, self.initial_type),
            ("final", self._final, self.final_type),
        ]:
            if data is None or types is None:
                continue
            for i, val in np.ndenumerate(data):
                if types[i] != "Fix":
                    continue
                min_i = self._min[i] if self._min is not None else -np.inf
                max_i = self._max[i] if self._max is not None else np.inf
                if val < min_i:
                    raise ValueError(
                        f"{field_name.capitalize()} Fixed value at index {i[0]} is lower then the "
                        f"min: {val} < {min_i}"
                    )
                if val > max_i:
                    raise ValueError(
                        f"{field_name.capitalize()} Fixed value at index {i[0]} is greater then "
                        f"the max: {val} > {max_i}"
                    )

    @property
    def initial(self):
        """Get the initial state boundary condition values.

        Returns:
            Array of initial state values (regardless of boundary condition type),
            or None if not set.

        Note:
            Use `initial_type` to see the boundary condition types for each element.

        Example:
            Get initial state boundary conditions:

                x = State("x", (2,))
                x.initial = [0, ("free", 1)]
                print(x.initial)  # [0. 1.]
                print(x.initial_type)  # ['Fix' 'Free']
        """
        return self._initial

    @initial.setter
    def initial(self, arr):
        """Set the initial state boundary conditions.

        Each element can be specified as either a simple number (defaults to "fixed")
        or a tuple of (type, value) where type specifies the boundary condition.

        Args:
            arr: Array-like of initial conditions. Each element can be:
                - A number: Defaults to fixed boundary condition at that value
                - A tuple (type, value): Where type is one of:
                    - "fixed": Constrain state to this exact value
                    - "free": Let optimizer choose within bounds, initialize at value
                    - "minimize": Add objective term to minimize, initialize at value
                    - "maximize": Add objective term to maximize, initialize at value

        Raises:
            ValueError: If the shape doesn't match the state shape, if boundary
                condition type is invalid, or if fixed values violate bounds

        Example:
            Set initial state boundary conditions:

                pos = State("pos", (3,))
                pos.min = [0, 0, 0]
                pos.max = [10, 10, 10]
                # x fixed at 0, y free (starts at 5), z fixed at 2
                pos.initial = [0, ("free", 5), 2]

            Can also minimize/maximize boundary values:

                time = State("t", (1,))
                time.initial = [("minimize", 0)]  # Minimize initial time
        """
        # Convert to list first to handle mixed types properly
        if not isinstance(arr, (list, tuple)):
            arr = np.asarray(arr)
            if arr.shape != self.shape:
                raise ValueError(f"Shape mismatch: {arr.shape} != {self.shape}")
            arr = arr.tolist()

        # Ensure we have the right number of elements
        if len(arr) != self.shape[0]:
            raise ValueError(f"Length mismatch: got {len(arr)} elements, expected {self.shape[0]}")

        self._initial = np.zeros(self.shape, dtype=float)
        self.initial_type = np.full(self.shape, "Fix", dtype=object)

        for i, v in enumerate(arr):
            if isinstance(v, tuple) and len(v) == 2:
                # Tuple API: (type, value)
                bc_type_str, bc_value = v
                try:
                    bc_type = BoundaryType(bc_type_str)  # Validates the string
                except ValueError:
                    valid_types = [t.value for t in BoundaryType]
                    raise ValueError(
                        f"Invalid boundary condition type: {bc_type_str}. "
                        f"Valid types are: {valid_types}"
                    )
                self._initial[i] = float(bc_value)
                self.initial_type[i] = bc_type.value.capitalize()
            elif isinstance(v, (int, float, np.number)):
                # Simple number defaults to fixed
                self._initial[i] = float(v)
                self.initial_type[i] = "Fix"
            else:
                raise ValueError(
                    f"Invalid boundary condition format: {v}. "
                    f"Use a number (defaults to fixed) or tuple ('type', value) "
                    f"where type is 'fixed', 'free', 'minimize', or 'maximize'."
                )

        self._check_bounds_against_initial_final()

    @property
    def final(self):
        """Get the final state boundary condition values.

        Returns:
            Array of final state values (regardless of boundary condition type),
            or None if not set.

        Note:
            Use `final_type` to see the boundary condition types for each element.

        Example:
            Get final state boundary conditions:

                x = State("x", (2,))
                x.final = [10, ("minimize", 0)]
                print(x.final)  # [10. 0.]
                print(x.final_type)  # ['Fix' 'Minimize']
        """
        return self._final

    @final.setter
    def final(self, arr):
        """Set the final state boundary conditions.

        Each element can be specified as either a simple number (defaults to "fixed")
        or a tuple of (type, value) where type specifies the boundary condition.

        Args:
            arr: Array-like of final conditions. Each element can be:
                - A number: Defaults to fixed boundary condition at that value
                - A tuple (type, value): Where type is one of:
                    - "fixed": Constrain state to this exact value
                    - "free": Let optimizer choose within bounds, initialize at value
                    - "minimize": Add objective term to minimize, initialize at value
                    - "maximize": Add objective term to maximize, initialize at value

        Raises:
            ValueError: If the shape doesn't match the state shape, if boundary
                condition type is invalid, or if fixed values violate bounds

        Example:
            Set final state boundary conditionis:

                pos = State("pos", (3,))
                pos.min = [0, 0, 0]
                pos.max = [10, 10, 10]
                # x fixed at 10, y free (starts at 5), z maximize altitude
                pos.final = [10, ("free", 5), ("maximize", 8)]

            Minimize final time in time-optimal problem:

                time = State("t", (1,))
                time.final = [("minimize", 10)]
        """
        # Convert to list first to handle mixed types properly
        if not isinstance(arr, (list, tuple)):
            arr = np.asarray(arr)
            if arr.shape != self.shape:
                raise ValueError(f"Shape mismatch: {arr.shape} != {self.shape}")
            arr = arr.tolist()

        # Ensure we have the right number of elements
        if len(arr) != self.shape[0]:
            raise ValueError(f"Length mismatch: got {len(arr)} elements, expected {self.shape[0]}")

        self._final = np.zeros(self.shape, dtype=float)
        self.final_type = np.full(self.shape, "Fix", dtype=object)

        for i, v in enumerate(arr):
            if isinstance(v, tuple) and len(v) == 2:
                # Tuple API: (type, value)
                bc_type_str, bc_value = v
                try:
                    bc_type = BoundaryType(bc_type_str)  # Validates the string
                except ValueError:
                    valid_types = [t.value for t in BoundaryType]
                    raise ValueError(
                        f"Invalid boundary condition type: {bc_type_str}. "
                        f"Valid types are: {valid_types}"
                    )
                self._final[i] = float(bc_value)
                self.final_type[i] = bc_type.value.capitalize()
            elif isinstance(v, (int, float, np.number)):
                # Simple number defaults to fixed
                self._final[i] = float(v)
                self.final_type[i] = "Fix"
            else:
                raise ValueError(
                    f"Invalid boundary condition format: {v}. "
                    f"Use a number (defaults to fixed) or tuple ('type', value) "
                    f"where type is 'fixed', 'free', 'minimize', or 'maximize'."
                )

        self._check_bounds_against_initial_final()

    @property
    def scaling_min(self):
        """Get the scaling minimum bounds for the state variables.

        Returns:
            Array of scaling minimum values for each state variable element, or None if not set.
        """
        return self._scaling_min

    @scaling_min.setter
    def scaling_min(self, val):
        """Set the scaling minimum bounds for the state variables.

        Args:
            val: Array of scaling minimum values, must match the state shape exactly

        Raises:
            ValueError: If the shape doesn't match the state shape
        """
        if val is None:
            self._scaling_min = None
            return
        val = np.asarray(val, dtype=float)
        if val.shape != self.shape:
            raise ValueError(
                f"Scaling min shape {val.shape} does not match State shape {self.shape}"
            )
        self._scaling_min = val

    @property
    def scaling_max(self):
        """Get the scaling maximum bounds for the state variables.

        Returns:
            Array of scaling maximum values for each state variable element, or None if not set.
        """
        return self._scaling_max

    @scaling_max.setter
    def scaling_max(self, val):
        """Set the scaling maximum bounds for the state variables.

        Args:
            val: Array of scaling maximum values, must match the state shape exactly

        Raises:
            ValueError: If the shape doesn't match the state shape
        """
        if val is None:
            self._scaling_max = None
            return
        val = np.asarray(val, dtype=float)
        if val.shape != self.shape:
            raise ValueError(
                f"Scaling max shape {val.shape} does not match State shape {self.shape}"
            )
        self._scaling_max = val

    def __repr__(self):
        """String representation of the State object.

        Returns:
            Concise string showing the state name and shape.
        """
        return f"State('{self.name}', shape={self.shape})"
final property writable

Get the final state boundary condition values.

Returns:

Type Description

Array of final state values (regardless of boundary condition type),

or None if not set.

Note

Use final_type to see the boundary condition types for each element.

Example

Get final state boundary conditions:

x = State("x", (2,))
x.final = [10, ("minimize", 0)]
print(x.final)  # [10. 0.]
print(x.final_type)  # ['Fix' 'Minimize']
initial property writable

Get the initial state boundary condition values.

Returns:

Type Description

Array of initial state values (regardless of boundary condition type),

or None if not set.

Note

Use initial_type to see the boundary condition types for each element.

Example

Get initial state boundary conditions:

x = State("x", (2,))
x.initial = [0, ("free", 1)]
print(x.initial)  # [0. 1.]
print(x.initial_type)  # ['Fix' 'Free']
max property writable

Get the maximum bounds for the state variables.

Returns:

Type Description

Array of maximum values for each state variable element.

Example

Get upper bounds:

vel = State("vel", (3,))
vel.max = [10, 10, 5]
print(vel.max)  # [10. 10. 5.]
min property writable

Get the minimum bounds for the state variables.

Returns:

Type Description

Array of minimum values for each state variable element.

Example

Get lower bounds:

pos = State("pos", (3,))
pos.min = [0, 0, 10]
print(pos.min)  # [0. 0. 10.]
scaling_max property writable

Get the scaling maximum bounds for the state variables.

Returns:

Type Description

Array of scaling maximum values for each state variable element, or None if not set.

scaling_min property writable

Get the scaling minimum bounds for the state variables.

Returns:

Type Description

Array of scaling minimum values for each state variable element, or None if not set.

_check_bounds_against_initial_final()

Validate that fixed boundary conditions respect min/max bounds.

This internal method is automatically called when bounds or boundary conditions are set to ensure consistency.

Raises:

Type Description
ValueError

If any fixed initial or final value violates the min/max bounds

Source code in openscvx/symbolic/expr/state.py
def _check_bounds_against_initial_final(self):
    """Validate that fixed boundary conditions respect min/max bounds.

    This internal method is automatically called when bounds or boundary
    conditions are set to ensure consistency.

    Raises:
        ValueError: If any fixed initial or final value violates the min/max bounds
    """
    for field_name, data, types in [
        ("initial", self._initial, self.initial_type),
        ("final", self._final, self.final_type),
    ]:
        if data is None or types is None:
            continue
        for i, val in np.ndenumerate(data):
            if types[i] != "Fix":
                continue
            min_i = self._min[i] if self._min is not None else -np.inf
            max_i = self._max[i] if self._max is not None else np.inf
            if val < min_i:
                raise ValueError(
                    f"{field_name.capitalize()} Fixed value at index {i[0]} is lower then the "
                    f"min: {val} < {min_i}"
                )
            if val > max_i:
                raise ValueError(
                    f"{field_name.capitalize()} Fixed value at index {i[0]} is greater then "
                    f"the max: {val} > {max_i}"
                )
_hash_into(hasher: hashlib._Hash) -> None

Hash State including boundary condition types.

Extends Variable._hash_into to include the structural metadata that affects the compiled problem: boundary condition types (fixed, free, minimize, maximize). Values are not hashed as they are runtime parameters.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/state.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash State including boundary condition types.

    Extends Variable._hash_into to include the structural metadata that
    affects the compiled problem: boundary condition types (fixed, free,
    minimize, maximize). Values are not hashed as they are runtime parameters.

    Args:
        hasher: A hashlib hash object to update
    """
    # Hash the base Variable attributes (class name, shape, slice)
    super()._hash_into(hasher)
    # Hash boundary condition types (these affect constraint structure)
    if self.initial_type is not None:
        hasher.update(b"initial_type:")
        hasher.update(str(self.initial_type.tolist()).encode())
    if self.final_type is not None:
        hasher.update(b"final_type:")
        hasher.update(str(self.final_type.tolist()).encode())

Sub

Bases: Expr

Subtraction operation for symbolic expressions.

Represents element-wise subtraction (left - right). Supports broadcasting following NumPy rules. Can be created using the - operator on Expr objects.

Attributes:

Name Type Description
left

Left-hand side expression (minuend)

right

Right-hand side expression (subtrahend)

Example

Define a Sub expression:

x = ox.State("x", shape=(3,))
y = ox.State("y", shape=(3,))
z = x - y  # Creates Sub(x, y)
Source code in openscvx/symbolic/expr/arithmetic.py
class Sub(Expr):
    """Subtraction operation for symbolic expressions.

    Represents element-wise subtraction (left - right). Supports broadcasting
    following NumPy rules. Can be created using the - operator on Expr objects.

    Attributes:
        left: Left-hand side expression (minuend)
        right: Right-hand side expression (subtrahend)

    Example:
        Define a Sub expression:

            x = ox.State("x", shape=(3,))
            y = ox.State("y", shape=(3,))
            z = x - y  # Creates Sub(x, y)
    """

    def __init__(self, left, right):
        """Initialize a subtraction operation.

        Args:
            left: Expression to subtract from (minuend)
            right: Expression to subtract (subtrahend)
        """
        self.left = left
        self.right = right

    def children(self):
        return [self.left, self.right]

    def canonicalize(self) -> "Expr":
        """Canonicalize subtraction: fold constants if both sides are constants.

        Returns:
            Expr: Canonical form of the subtraction expression
        """
        left = self.left.canonicalize()
        right = self.right.canonicalize()
        if isinstance(left, Constant) and isinstance(right, Constant):
            return Constant(left.value - right.value)
        return Sub(left, right)

    def check_shape(self) -> Tuple[int, ...]:
        """Check shape compatibility and compute broadcasted result shape like NumPy.

        Returns:
            tuple: The broadcasted shape of all operands

        Raises:
            ValueError: If operand shapes are not broadcastable
        """
        shapes = [child.check_shape() for child in self.children()]
        try:
            return np.broadcast_shapes(*shapes)
        except ValueError as e:
            raise ValueError(f"Sub shapes not broadcastable: {shapes}") from e

    def __repr__(self):
        return f"({self.left!r} - {self.right!r})"
canonicalize() -> Expr

Canonicalize subtraction: fold constants if both sides are constants.

Returns:

Name Type Description
Expr Expr

Canonical form of the subtraction expression

Source code in openscvx/symbolic/expr/arithmetic.py
def canonicalize(self) -> "Expr":
    """Canonicalize subtraction: fold constants if both sides are constants.

    Returns:
        Expr: Canonical form of the subtraction expression
    """
    left = self.left.canonicalize()
    right = self.right.canonicalize()
    if isinstance(left, Constant) and isinstance(right, Constant):
        return Constant(left.value - right.value)
    return Sub(left, right)
check_shape() -> Tuple[int, ...]

Check shape compatibility and compute broadcasted result shape like NumPy.

Returns:

Name Type Description
tuple Tuple[int, ...]

The broadcasted shape of all operands

Raises:

Type Description
ValueError

If operand shapes are not broadcastable

Source code in openscvx/symbolic/expr/arithmetic.py
def check_shape(self) -> Tuple[int, ...]:
    """Check shape compatibility and compute broadcasted result shape like NumPy.

    Returns:
        tuple: The broadcasted shape of all operands

    Raises:
        ValueError: If operand shapes are not broadcastable
    """
    shapes = [child.check_shape() for child in self.children()]
    try:
        return np.broadcast_shapes(*shapes)
    except ValueError as e:
        raise ValueError(f"Sub shapes not broadcastable: {shapes}") from e

Sum

Bases: Expr

Sum reduction operation for symbolic expressions.

Sums all elements of an expression, reducing it to a scalar. This is a reduction operation that collapses all dimensions.

Attributes:

Name Type Description
operand

Expression whose elements will be summed

Example

Define a Sum expression::

x = ox.State("x", shape=(3, 4))
total = Sum(x)  # Creates Sum(x), result shape ()
Source code in openscvx/symbolic/expr/linalg.py
class Sum(Expr):
    """Sum reduction operation for symbolic expressions.

    Sums all elements of an expression, reducing it to a scalar. This is a
    reduction operation that collapses all dimensions.

    Attributes:
        operand: Expression whose elements will be summed

    Example:
        Define a Sum expression::

            x = ox.State("x", shape=(3, 4))
            total = Sum(x)  # Creates Sum(x), result shape ()
    """

    def __init__(self, operand):
        """Initialize a sum reduction operation.

        Args:
            operand: Expression to sum over all elements
        """
        self.operand = to_expr(operand)

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        """Canonicalize sum: canonicalize the operand.

        Returns:
            Expr: Canonical form of the sum expression
        """
        operand = self.operand.canonicalize()
        return Sum(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Sum reduces any shape to a scalar."""
        # Validate that the operand has a valid shape
        self.operand.check_shape()
        # Sum always produces a scalar regardless of input shape
        return ()

    def __repr__(self):
        return f"sum({self.operand!r})"
canonicalize() -> Expr

Canonicalize sum: canonicalize the operand.

Returns:

Name Type Description
Expr Expr

Canonical form of the sum expression

Source code in openscvx/symbolic/expr/linalg.py
def canonicalize(self) -> "Expr":
    """Canonicalize sum: canonicalize the operand.

    Returns:
        Expr: Canonical form of the sum expression
    """
    operand = self.operand.canonicalize()
    return Sum(operand)
check_shape() -> Tuple[int, ...]

Sum reduces any shape to a scalar.

Source code in openscvx/symbolic/expr/linalg.py
def check_shape(self) -> Tuple[int, ...]:
    """Sum reduces any shape to a scalar."""
    # Validate that the operand has a valid shape
    self.operand.check_shape()
    # Sum always produces a scalar regardless of input shape
    return ()

Tan

Bases: Expr

Element-wise tangent function for symbolic expressions.

Computes the tangent of each element in the operand. Preserves the shape of the input expression.

Attributes:

Name Type Description
operand

Expression to apply tangent function to

Example

Define a Tan expression:

theta = Variable("theta", shape=(3,))
tan_theta = Tan(theta)
Note

Tan is only supported for JAX lowering. CVXPy lowering will raise NotImplementedError since tangent is not DCP-compliant.

Source code in openscvx/symbolic/expr/math.py
class Tan(Expr):
    """Element-wise tangent function for symbolic expressions.

    Computes the tangent of each element in the operand. Preserves the shape
    of the input expression.

    Attributes:
        operand: Expression to apply tangent function to

    Example:
        Define a Tan expression:

            theta = Variable("theta", shape=(3,))
            tan_theta = Tan(theta)

    Note:
        Tan is only supported for JAX lowering. CVXPy lowering will raise
        NotImplementedError since tangent is not DCP-compliant.
    """

    def __init__(self, operand):
        """Initialize a tangent operation.

        Args:
            operand: Expression to apply tangent function to
        """
        self.operand = operand

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        operand = self.operand.canonicalize()
        return Tan(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Tan preserves the shape of its operand."""
        return self.operand.check_shape()

    def __repr__(self):
        return f"(tan({self.operand!r}))"
check_shape() -> Tuple[int, ...]

Tan preserves the shape of its operand.

Source code in openscvx/symbolic/expr/math.py
def check_shape(self) -> Tuple[int, ...]:
    """Tan preserves the shape of its operand."""
    return self.operand.check_shape()

Transpose

Bases: Expr

Matrix transpose operation for symbolic expressions.

Transposes the last two dimensions of an expression. For matrices, this swaps rows and columns. For higher-dimensional arrays, it swaps the last two axes. Scalars and vectors are unchanged by transposition.

The canonicalization includes an optimization that eliminates double transposes: (A.T).T simplifies to A.

Attributes:

Name Type Description
operand

Expression to transpose

Example

Define Tranpose expressions:

A = Variable("A", shape=(3, 4))
A_T = Transpose(A)  # or A.T, result shape (4, 3)
v = Variable("v", shape=(5,))
v_T = Transpose(v)  # result shape (5,) - vectors unchanged
Source code in openscvx/symbolic/expr/linalg.py
class Transpose(Expr):
    """Matrix transpose operation for symbolic expressions.

    Transposes the last two dimensions of an expression. For matrices, this swaps
    rows and columns. For higher-dimensional arrays, it swaps the last two axes.
    Scalars and vectors are unchanged by transposition.

    The canonicalization includes an optimization that eliminates double transposes:
    (A.T).T simplifies to A.

    Attributes:
        operand: Expression to transpose

    Example:
        Define Tranpose expressions:

            A = Variable("A", shape=(3, 4))
            A_T = Transpose(A)  # or A.T, result shape (4, 3)
            v = Variable("v", shape=(5,))
            v_T = Transpose(v)  # result shape (5,) - vectors unchanged
    """

    def __init__(self, operand):
        """Initialize a transpose operation.

        Args:
            operand: Expression to transpose
        """
        self.operand = to_expr(operand)

    def children(self):
        return [self.operand]

    def canonicalize(self) -> "Expr":
        """Canonicalize the operand with double transpose optimization."""
        operand = self.operand.canonicalize()

        # Double transpose optimization: (A.T).T = A
        if isinstance(operand, Transpose):
            return operand.operand

        return Transpose(operand)

    def check_shape(self) -> Tuple[int, ...]:
        """Matrix transpose operation swaps the last two dimensions."""
        operand_shape = self.operand.check_shape()

        if len(operand_shape) == 0:
            # Scalar transpose is the scalar itself
            return ()
        elif len(operand_shape) == 1:
            # Vector transpose is the vector itself (row vector remains row vector)
            return operand_shape
        elif len(operand_shape) == 2:
            # Matrix transpose: (m,n) -> (n,m)
            return (operand_shape[1], operand_shape[0])
        else:
            # Higher-dimensional array: transpose last two dimensions
            # (..., m, n) -> (..., n, m)
            return operand_shape[:-2] + (operand_shape[-1], operand_shape[-2])

    def __repr__(self):
        return f"({self.operand!r}).T"
canonicalize() -> Expr

Canonicalize the operand with double transpose optimization.

Source code in openscvx/symbolic/expr/linalg.py
def canonicalize(self) -> "Expr":
    """Canonicalize the operand with double transpose optimization."""
    operand = self.operand.canonicalize()

    # Double transpose optimization: (A.T).T = A
    if isinstance(operand, Transpose):
        return operand.operand

    return Transpose(operand)
check_shape() -> Tuple[int, ...]

Matrix transpose operation swaps the last two dimensions.

Source code in openscvx/symbolic/expr/linalg.py
def check_shape(self) -> Tuple[int, ...]:
    """Matrix transpose operation swaps the last two dimensions."""
    operand_shape = self.operand.check_shape()

    if len(operand_shape) == 0:
        # Scalar transpose is the scalar itself
        return ()
    elif len(operand_shape) == 1:
        # Vector transpose is the vector itself (row vector remains row vector)
        return operand_shape
    elif len(operand_shape) == 2:
        # Matrix transpose: (m,n) -> (n,m)
        return (operand_shape[1], operand_shape[0])
    else:
        # Higher-dimensional array: transpose last two dimensions
        # (..., m, n) -> (..., n, m)
        return operand_shape[:-2] + (operand_shape[-1], operand_shape[-2])

Variable

Bases: Leaf

Base class for decision variables in optimization problems.

Variable represents decision variables (free parameters) in an optimization problem. These are values that the optimizer can adjust to minimize the objective function while satisfying constraints. Variables can have bounds (min/max) and initial guesses to guide the optimization process.

Unlike Parameters (which are fixed values that can be changed between solves), Variables are optimized by the solver. In trajectory optimization, Variables typically represent discretized state or control trajectories.

Note

Variable is typically not instantiated directly. Instead, use the specialized subclasses State (for state variables with boundary conditions) or Control (for control inputs). These provide additional functionality specific to trajectory optimization.

Attributes:

Name Type Description
name str

Name identifier for the variable

_shape tuple[int, ...]

Shape of the variable as a tuple (typically 1D)

_slice slice | None

Internal slice information for variable indexing

_min ndarray | None

Minimum bounds for each element of the variable

_max ndarray | None

Maximum bounds for each element of the variable

_guess ndarray | None

Initial guess for the variable trajectory (n_points, n_vars)

Example
Typically, use State or Control instead of Variable directly:

pos = openscvx.State("pos", shape=(3,)) u = openscvx.Control("u", shape=(2,))

Source code in openscvx/symbolic/expr/variable.py
class Variable(Leaf):
    """Base class for decision variables in optimization problems.

    Variable represents decision variables (free parameters) in an optimization problem.
    These are values that the optimizer can adjust to minimize the objective function
    while satisfying constraints. Variables can have bounds (min/max) and initial guesses
    to guide the optimization process.

    Unlike Parameters (which are fixed values that can be changed between solves),
    Variables are optimized by the solver. In trajectory optimization, Variables typically
    represent discretized state or control trajectories.

    Note:
        Variable is typically not instantiated directly. Instead, use the specialized
        subclasses State (for state variables with boundary conditions) or Control
        (for control inputs). These provide additional functionality specific to
        trajectory optimization.

    Attributes:
        name (str): Name identifier for the variable
        _shape (tuple[int, ...]): Shape of the variable as a tuple (typically 1D)
        _slice (slice | None): Internal slice information for variable indexing
        _min (np.ndarray | None): Minimum bounds for each element of the variable
        _max (np.ndarray | None): Maximum bounds for each element of the variable
        _guess (np.ndarray | None): Initial guess for the variable trajectory (n_points, n_vars)

    Example:
            # Typically, use State or Control instead of Variable directly:
            pos = openscvx.State("pos", shape=(3,))
            u = openscvx.Control("u", shape=(2,))
    """

    def __init__(self, name, shape):
        """Initialize a Variable object.

        Args:
            name: Name identifier for the variable
            shape: Shape of the variable as a tuple (typically 1D like (3,) for 3D vector)
        """
        super().__init__(name, shape)
        self._slice = None
        self._min = None
        self._max = None
        self._guess = None

    def __repr__(self):
        return f"Var({self.name!r})"

    def _hash_into(self, hasher: "hashlib._Hash") -> None:
        """Hash Variable using its slice (canonical position, name-invariant).

        Instead of hashing the variable name, we hash the _slice attribute
        which represents the variable's canonical position in the unified
        state/control vector. This ensures that two problems with the same
        structure but different variable names produce the same hash.

        Args:
            hasher: A hashlib hash object to update
        """
        hasher.update(self.__class__.__name__.encode())
        hasher.update(str(self._shape).encode())
        # Hash the slice (canonical position) - this is name-invariant
        if self._slice is not None:
            hasher.update(f"slice:{self._slice.start}:{self._slice.stop}".encode())
        else:
            raise RuntimeError(
                f"Cannot hash Variable '{self.name}' without _slice attribute. "
                "Hashing should only be called on preprocessed problems where "
                "all Variables have been assigned canonical slice positions."
            )

    @property
    def min(self):
        """Get the minimum bounds (lower bounds) for the variable.

        Returns:
            Array of minimum values for each element of the variable, or None if unbounded.

        Example:
                pos = Variable("pos", shape=(3,))
                pos.min = [-10, -10, 0]
                print(pos.min)  # [-10., -10., 0.]
        """
        return self._min

    @min.setter
    def min(self, arr):
        """Set the minimum bounds (lower bounds) for the variable.

        The bounds are applied element-wise to each component of the variable.
        Scalars will be broadcast to match the variable shape.

        Args:
            arr: Array of minimum values, must be broadcastable to shape (n,)
                where n is the variable dimension

        Raises:
            ValueError: If the shape of arr doesn't match the variable shape

        Example:
                pos = Variable("pos", shape=(3,))
                pos.min = -10  # Broadcasts to [-10, -10, -10]
                pos.min = [-5, -10, 0]  # Element-wise bounds
        """
        arr = np.asarray(arr, dtype=float)
        if arr.ndim != 1 or arr.shape[0] != self.shape[0]:
            raise ValueError(
                f"{self.__class__.__name__} min must be 1D with shape ({self.shape[0]},), got"
                f" {arr.shape}"
            )
        self._min = arr

    @property
    def max(self):
        """Get the maximum bounds (upper bounds) for the variable.

        Returns:
            Array of maximum values for each element of the variable, or None if unbounded.

        Example:
                vel = Variable("vel", shape=(3,))
                vel.max = [10, 10, 5]
                print(vel.max)  # [10., 10., 5.]
        """
        return self._max

    @max.setter
    def max(self, arr):
        """Set the maximum bounds (upper bounds) for the variable.

        The bounds are applied element-wise to each component of the variable.
        Scalars will be broadcast to match the variable shape.

        Args:
            arr: Array of maximum values, must be broadcastable to shape (n,)
                where n is the variable dimension

        Raises:
            ValueError: If the shape of arr doesn't match the variable shape

        Example:
                vel = Variable("vel", shape=(3,))
                vel.max = 10  # Broadcasts to [10, 10, 10]
                vel.max = [15, 10, 5]  # Element-wise bounds
        """
        arr = np.asarray(arr, dtype=float)
        if arr.ndim != 1 or arr.shape[0] != self.shape[0]:
            raise ValueError(
                f"{self.__class__.__name__} max must be 1D with shape ({self.shape[0]},), got"
                f" {arr.shape}"
            )
        self._max = arr

    @property
    def guess(self):
        """Get the initial guess for the variable trajectory.

        The guess provides a starting point for the optimizer. A good initial guess
        can significantly improve convergence speed and help avoid local minima.

        Returns:
            2D array of shape (n_points, n_vars) representing the variable trajectory
            over time, or None if no guess is provided.

        Example:
                x = Variable("x", shape=(2,))
                # Linear interpolation from [0,0] to [10,10] over 50 points
                x.guess = np.linspace([0, 0], [10, 10], 50)
                print(x.guess.shape)  # (50, 2)
        """
        return self._guess

    @guess.setter
    def guess(self, arr):
        """Set the initial guess for the variable trajectory.

        The guess should be a 2D array where each row represents the variable value
        at a particular time point or trajectory node.

        Args:
            arr: 2D array of shape (n_points, n_vars) where n_vars matches the
                variable dimension. Can be fewer points than the final trajectory -
                the solver will interpolate as needed.

        Raises:
            ValueError: If the array is not 2D or if the second dimension doesn't
                match the variable dimension

        Example:
                pos = Variable("pos", shape=(3,))
                # Create a straight-line trajectory from origin to target
                n_points = 50
                pos.guess = np.linspace([0, 0, 0], [10, 5, 3], n_points)
        """
        arr = np.asarray(arr, dtype=float)
        if arr.ndim != 2:
            raise ValueError(
                f"Guess must be a 2D array of shape (n_guess_points, {self.shape[0]}), got shape"
                f" {arr.shape}"
            )
        if arr.shape[1] != self.shape[0]:
            raise ValueError(
                f"Guess must have second dimension equal to variable dimension {self.shape[0]}, got"
                f" {arr.shape[1]}"
            )
        self._guess = arr

    def append(self, other=None, *, min=-np.inf, max=np.inf, guess=0.0):
        """Append a new dimension to this variable or merge with another variable.

        This method extends the variable's dimension by either:
        1. Appending another Variable object (concatenating their dimensions)
        2. Adding a single new scalar dimension with specified bounds and guess

        The bounds and guesses of both variables are concatenated appropriately.

        Args:
            other: Another Variable object to append. If None, adds a single scalar
                dimension with the specified min/max/guess values.
            min: Minimum bound for the new dimension (only used if other is None).
                Defaults to -np.inf (unbounded below).
            max: Maximum bound for the new dimension (only used if other is None).
                Defaults to np.inf (unbounded above).
            guess: Initial guess value for the new dimension (only used if other is None).
                Defaults to 0.0.

        Example:
            Create a 2D variable and extend it to 3D:

                pos_xy = Variable("pos", shape=(2,))
                pos_xy.min = [-10, -10]
                pos_xy.max = [10, 10]
                pos_xy.append(min=0, max=100)  # Add z dimension
                print(pos_xy.shape)  # (3,)
                print(pos_xy.min)  # [-10., -10., 0.]
                print(pos_xy.max)  # [10., 10., 100.]

            Merge two variables:

                pos = Variable("pos", shape=(3,))
                vel = Variable("vel", shape=(3,))
                pos.append(vel)  # Now pos has shape (6,)
        """

        def process_array(val, is_guess=False):
            """Process input array to ensure correct shape and type.

            Args:
                val: Input value to process
                is_guess: Whether the value is a guess array

            Returns:
                Processed array with correct shape and type
            """
            arr = np.asarray(val, dtype=float)
            if is_guess:
                return np.atleast_2d(arr)
            return np.atleast_1d(arr)

        if isinstance(other, Variable):
            self._shape = (self.shape[0] + other.shape[0],)

            if self._min is not None and other._min is not None:
                self._min = np.concatenate([self._min, process_array(other._min)], axis=0)

            if self._max is not None and other._max is not None:
                self._max = np.concatenate([self._max, process_array(other._max)], axis=0)

            if self._guess is not None and other._guess is not None:
                self._guess = np.concatenate(
                    [self._guess, process_array(other._guess, is_guess=True)], axis=1
                )

        else:
            self._shape = (self.shape[0] + 1,)

            if self._min is not None:
                self._min = np.concatenate([self._min, process_array(min)], axis=0)

            if self._max is not None:
                self._max = np.concatenate([self._max, process_array(max)], axis=0)

            if self._guess is not None:
                guess_arr = process_array(guess, is_guess=True)
                if guess_arr.shape[1] != 1:
                    guess_arr = guess_arr.T
                self._guess = np.concatenate([self._guess, guess_arr], axis=1)
guess property writable

Get the initial guess for the variable trajectory.

The guess provides a starting point for the optimizer. A good initial guess can significantly improve convergence speed and help avoid local minima.

Returns:

Type Description

2D array of shape (n_points, n_vars) representing the variable trajectory

over time, or None if no guess is provided.

Example

x = Variable("x", shape=(2,))

Linear interpolation from [0,0] to [10,10] over 50 points

x.guess = np.linspace([0, 0], [10, 10], 50) print(x.guess.shape) # (50, 2)

max property writable

Get the maximum bounds (upper bounds) for the variable.

Returns:

Type Description

Array of maximum values for each element of the variable, or None if unbounded.

Example

vel = Variable("vel", shape=(3,)) vel.max = [10, 10, 5] print(vel.max) # [10., 10., 5.]

min property writable

Get the minimum bounds (lower bounds) for the variable.

Returns:

Type Description

Array of minimum values for each element of the variable, or None if unbounded.

Example

pos = Variable("pos", shape=(3,)) pos.min = [-10, -10, 0] print(pos.min) # [-10., -10., 0.]

_hash_into(hasher: hashlib._Hash) -> None

Hash Variable using its slice (canonical position, name-invariant).

Instead of hashing the variable name, we hash the _slice attribute which represents the variable's canonical position in the unified state/control vector. This ensures that two problems with the same structure but different variable names produce the same hash.

Parameters:

Name Type Description Default
hasher _Hash

A hashlib hash object to update

required
Source code in openscvx/symbolic/expr/variable.py
def _hash_into(self, hasher: "hashlib._Hash") -> None:
    """Hash Variable using its slice (canonical position, name-invariant).

    Instead of hashing the variable name, we hash the _slice attribute
    which represents the variable's canonical position in the unified
    state/control vector. This ensures that two problems with the same
    structure but different variable names produce the same hash.

    Args:
        hasher: A hashlib hash object to update
    """
    hasher.update(self.__class__.__name__.encode())
    hasher.update(str(self._shape).encode())
    # Hash the slice (canonical position) - this is name-invariant
    if self._slice is not None:
        hasher.update(f"slice:{self._slice.start}:{self._slice.stop}".encode())
    else:
        raise RuntimeError(
            f"Cannot hash Variable '{self.name}' without _slice attribute. "
            "Hashing should only be called on preprocessed problems where "
            "all Variables have been assigned canonical slice positions."
        )
append(other=None, *, min=-np.inf, max=np.inf, guess=0.0)

Append a new dimension to this variable or merge with another variable.

This method extends the variable's dimension by either: 1. Appending another Variable object (concatenating their dimensions) 2. Adding a single new scalar dimension with specified bounds and guess

The bounds and guesses of both variables are concatenated appropriately.

Parameters:

Name Type Description Default
other

Another Variable object to append. If None, adds a single scalar dimension with the specified min/max/guess values.

None
min

Minimum bound for the new dimension (only used if other is None). Defaults to -np.inf (unbounded below).

-inf
max

Maximum bound for the new dimension (only used if other is None). Defaults to np.inf (unbounded above).

inf
guess

Initial guess value for the new dimension (only used if other is None). Defaults to 0.0.

0.0
Example

Create a 2D variable and extend it to 3D:

pos_xy = Variable("pos", shape=(2,))
pos_xy.min = [-10, -10]
pos_xy.max = [10, 10]
pos_xy.append(min=0, max=100)  # Add z dimension
print(pos_xy.shape)  # (3,)
print(pos_xy.min)  # [-10., -10., 0.]
print(pos_xy.max)  # [10., 10., 100.]

Merge two variables:

pos = Variable("pos", shape=(3,))
vel = Variable("vel", shape=(3,))
pos.append(vel)  # Now pos has shape (6,)
Source code in openscvx/symbolic/expr/variable.py
def append(self, other=None, *, min=-np.inf, max=np.inf, guess=0.0):
    """Append a new dimension to this variable or merge with another variable.

    This method extends the variable's dimension by either:
    1. Appending another Variable object (concatenating their dimensions)
    2. Adding a single new scalar dimension with specified bounds and guess

    The bounds and guesses of both variables are concatenated appropriately.

    Args:
        other: Another Variable object to append. If None, adds a single scalar
            dimension with the specified min/max/guess values.
        min: Minimum bound for the new dimension (only used if other is None).
            Defaults to -np.inf (unbounded below).
        max: Maximum bound for the new dimension (only used if other is None).
            Defaults to np.inf (unbounded above).
        guess: Initial guess value for the new dimension (only used if other is None).
            Defaults to 0.0.

    Example:
        Create a 2D variable and extend it to 3D:

            pos_xy = Variable("pos", shape=(2,))
            pos_xy.min = [-10, -10]
            pos_xy.max = [10, 10]
            pos_xy.append(min=0, max=100)  # Add z dimension
            print(pos_xy.shape)  # (3,)
            print(pos_xy.min)  # [-10., -10., 0.]
            print(pos_xy.max)  # [10., 10., 100.]

        Merge two variables:

            pos = Variable("pos", shape=(3,))
            vel = Variable("vel", shape=(3,))
            pos.append(vel)  # Now pos has shape (6,)
    """

    def process_array(val, is_guess=False):
        """Process input array to ensure correct shape and type.

        Args:
            val: Input value to process
            is_guess: Whether the value is a guess array

        Returns:
            Processed array with correct shape and type
        """
        arr = np.asarray(val, dtype=float)
        if is_guess:
            return np.atleast_2d(arr)
        return np.atleast_1d(arr)

    if isinstance(other, Variable):
        self._shape = (self.shape[0] + other.shape[0],)

        if self._min is not None and other._min is not None:
            self._min = np.concatenate([self._min, process_array(other._min)], axis=0)

        if self._max is not None and other._max is not None:
            self._max = np.concatenate([self._max, process_array(other._max)], axis=0)

        if self._guess is not None and other._guess is not None:
            self._guess = np.concatenate(
                [self._guess, process_array(other._guess, is_guess=True)], axis=1
            )

    else:
        self._shape = (self.shape[0] + 1,)

        if self._min is not None:
            self._min = np.concatenate([self._min, process_array(min)], axis=0)

        if self._max is not None:
            self._max = np.concatenate([self._max, process_array(max)], axis=0)

        if self._guess is not None:
            guess_arr = process_array(guess, is_guess=True)
            if guess_arr.shape[1] != 1:
                guess_arr = guess_arr.T
            self._guess = np.concatenate([self._guess, guess_arr], axis=1)

Vstack

Bases: Expr

Vertical stacking operation for symbolic expressions.

Concatenates expressions vertically (along rows for 2D arrays). This is analogous to numpy.vstack() or jax.numpy.vstack().

All input expressions must have the same number of dimensions, and all dimensions except the first must match. The result concatenates along axis 0 (rows).

Attributes:

Name Type Description
arrays

List of expressions to stack vertically

Example

Stack vectors to create a matrix:

x = Variable("x", shape=(3,))
y = Variable("y", shape=(3,))
v = Vstack([x, y])  # Result shape (2, 3)

Stack matrices vertically:

A = Variable("A", shape=(3, 4))
B = Variable("B", shape=(2, 4))
C = Vstack([A, B])  # Result shape (5, 4)
Source code in openscvx/symbolic/expr/array.py
class Vstack(Expr):
    """Vertical stacking operation for symbolic expressions.

    Concatenates expressions vertically (along rows for 2D arrays).
    This is analogous to numpy.vstack() or jax.numpy.vstack().

    All input expressions must have the same number of dimensions, and all
    dimensions except the first must match. The result concatenates along
    axis 0 (rows).

    Attributes:
        arrays: List of expressions to stack vertically

    Example:
        Stack vectors to create a matrix:

            x = Variable("x", shape=(3,))
            y = Variable("y", shape=(3,))
            v = Vstack([x, y])  # Result shape (2, 3)

        Stack matrices vertically:

            A = Variable("A", shape=(3, 4))
            B = Variable("B", shape=(2, 4))
            C = Vstack([A, B])  # Result shape (5, 4)
    """

    def __init__(self, arrays):
        """Initialize a vertical stack operation.

        Args:
            arrays: List of expressions to concatenate vertically.
                    All must have matching dimensions except the first.
        """
        self.arrays = [to_expr(arr) for arr in arrays]

    def children(self):
        return self.arrays

    def canonicalize(self) -> "Expr":
        arrays = [arr.canonicalize() for arr in self.arrays]
        return Vstack(arrays)

    def check_shape(self) -> Tuple[int, ...]:
        """Vertical stack concatenates arrays along the first axis (rows)."""
        if not self.arrays:
            raise ValueError("Vstack requires at least one array")

        array_shapes = [arr.check_shape() for arr in self.arrays]

        # All arrays must have the same number of dimensions
        first_ndim = len(array_shapes[0])
        for i, shape in enumerate(array_shapes[1:], 1):
            if len(shape) != first_ndim:
                raise ValueError(
                    f"Vstack array {i} has {len(shape)} dimensions, but array 0 has {first_ndim}"
                )

        # All dimensions except the first must match
        first_shape = array_shapes[0]
        for i, shape in enumerate(array_shapes[1:], 1):
            if shape[1:] != first_shape[1:]:
                raise ValueError(
                    f"Vstack array {i} has trailing dimensions {shape[1:]}, "
                    f"but array 0 has {first_shape[1:]}"
                )

        # Result shape: concatenate along axis 0 (rows)
        total_rows = sum(shape[0] for shape in array_shapes)
        return (total_rows,) + first_shape[1:]

    def __repr__(self):
        arrays_repr = ", ".join(repr(arr) for arr in self.arrays)
        return f"Vstack([{arrays_repr}])"
check_shape() -> Tuple[int, ...]

Vertical stack concatenates arrays along the first axis (rows).

Source code in openscvx/symbolic/expr/array.py
def check_shape(self) -> Tuple[int, ...]:
    """Vertical stack concatenates arrays along the first axis (rows)."""
    if not self.arrays:
        raise ValueError("Vstack requires at least one array")

    array_shapes = [arr.check_shape() for arr in self.arrays]

    # All arrays must have the same number of dimensions
    first_ndim = len(array_shapes[0])
    for i, shape in enumerate(array_shapes[1:], 1):
        if len(shape) != first_ndim:
            raise ValueError(
                f"Vstack array {i} has {len(shape)} dimensions, but array 0 has {first_ndim}"
            )

    # All dimensions except the first must match
    first_shape = array_shapes[0]
    for i, shape in enumerate(array_shapes[1:], 1):
        if shape[1:] != first_shape[1:]:
            raise ValueError(
                f"Vstack array {i} has trailing dimensions {shape[1:]}, "
                f"but array 0 has {first_shape[1:]}"
            )

    # Result shape: concatenate along axis 0 (rows)
    total_rows = sum(shape[0] for shape in array_shapes)
    return (total_rows,) + first_shape[1:]

Fixed(value)

Create a fixed boundary condition tuple.

This is a convenience function that returns a tuple ("fixed", value) which can be used to explicitly specify fixed boundary conditions for State or Time objects. Note that plain numbers default to fixed, so this is mainly for clarity.

Parameters:

Name Type Description Default
value

Fixed value for the boundary condition.

required

Returns:

Name Type Description
tuple

("fixed", value) tuple suitable for use in State.initial, State.final, or Time.initial, Time.final.

Example
pos = ox.State("pos", (3,))
pos.final = [ox.Fixed(10.0), ox.Free(5.0), ox.Fixed(2.0)]

# Equivalent to:
pos.final = [10.0, ox.Free(5.0), 2.0]  # Plain numbers default to fixed
Source code in openscvx/symbolic/expr/state.py
def Fixed(value):
    """Create a fixed boundary condition tuple.

    This is a convenience function that returns a tuple ("fixed", value) which
    can be used to explicitly specify fixed boundary conditions for State or Time objects.
    Note that plain numbers default to fixed, so this is mainly for clarity.

    Args:
        value: Fixed value for the boundary condition.

    Returns:
        tuple: ("fixed", value) tuple suitable for use in State.initial, State.final,
            or Time.initial, Time.final.

    Example:
        ```python
        pos = ox.State("pos", (3,))
        pos.final = [ox.Fixed(10.0), ox.Free(5.0), ox.Fixed(2.0)]

        # Equivalent to:
        pos.final = [10.0, ox.Free(5.0), 2.0]  # Plain numbers default to fixed
        ```
    """
    return ("fixed", value)

Free(guess)

Create a free boundary condition tuple.

This is a convenience function that returns a tuple ("free", guess) which can be used to specify free boundary conditions for State or Time objects.

Parameters:

Name Type Description Default
guess

Initial guess value for the free variable.

required

Returns:

Name Type Description
tuple

("free", guess) tuple suitable for use in State.initial, State.final, or Time.initial, Time.final.

Example
pos = ox.State("pos", (3,))
pos.final = [ox.Free(5.0), ox.Free(3.0), 10]  # First two free, third fixed

time = ox.Time(
    initial=0.0,
    final=ox.Free(10.0),
    min=0.0,
    max=20.0
)
Source code in openscvx/symbolic/expr/state.py
def Free(guess):
    """Create a free boundary condition tuple.

    This is a convenience function that returns a tuple ("free", guess) which
    can be used to specify free boundary conditions for State or Time objects.

    Args:
        guess: Initial guess value for the free variable.

    Returns:
        tuple: ("free", guess) tuple suitable for use in State.initial, State.final,
            or Time.initial, Time.final.

    Example:
        ```python
        pos = ox.State("pos", (3,))
        pos.final = [ox.Free(5.0), ox.Free(3.0), 10]  # First two free, third fixed

        time = ox.Time(
            initial=0.0,
            final=ox.Free(10.0),
            min=0.0,
            max=20.0
        )
        ```
    """
    return ("free", guess)

Maximize(guess)

Create a maximize boundary condition tuple.

This is a convenience function that returns a tuple ("maximize", guess) which can be used to specify that a boundary value should be maximized in the objective function for State or Time objects.

Parameters:

Name Type Description Default
guess

Initial guess value for the variable to be maximized.

required

Returns:

Name Type Description
tuple

("maximize", guess) tuple suitable for use in State.initial, State.final, or Time.initial, Time.final.

Example
altitude = ox.State("altitude", (1,))
altitude.final = [ox.Maximize(100.0)]  # Maximize final altitude

time = ox.Time(
    initial=ox.Maximize(0.0),  # Maximize initial time
    final=10.0,
    min=0.0,
    max=20.0
)
Source code in openscvx/symbolic/expr/state.py
def Maximize(guess):
    """Create a maximize boundary condition tuple.

    This is a convenience function that returns a tuple ("maximize", guess) which
    can be used to specify that a boundary value should be maximized in the objective
    function for State or Time objects.

    Args:
        guess: Initial guess value for the variable to be maximized.

    Returns:
        tuple: ("maximize", guess) tuple suitable for use in State.initial, State.final,
            or Time.initial, Time.final.

    Example:
        ```python
        altitude = ox.State("altitude", (1,))
        altitude.final = [ox.Maximize(100.0)]  # Maximize final altitude

        time = ox.Time(
            initial=ox.Maximize(0.0),  # Maximize initial time
            final=10.0,
            min=0.0,
            max=20.0
        )
        ```
    """
    return ("maximize", guess)

Minimize(guess)

Create a minimize boundary condition tuple.

This is a convenience function that returns a tuple ("minimize", guess) which can be used to specify that a boundary value should be minimized in the objective function for State or Time objects.

Parameters:

Name Type Description Default
guess

Initial guess value for the variable to be minimized.

required

Returns:

Name Type Description
tuple

("minimize", guess) tuple suitable for use in State.initial, State.final, or Time.initial, Time.final.

Example
time = ox.Time(
    initial=0.0,
    final=ox.Minimize(10.0),  # Minimize final time
    min=0.0,
    max=20.0
)

fuel = ox.State("fuel", (1,))
fuel.final = [ox.Minimize(0)]  # Minimize final fuel consumption
Source code in openscvx/symbolic/expr/state.py
def Minimize(guess):
    """Create a minimize boundary condition tuple.

    This is a convenience function that returns a tuple ("minimize", guess) which
    can be used to specify that a boundary value should be minimized in the objective
    function for State or Time objects.

    Args:
        guess: Initial guess value for the variable to be minimized.

    Returns:
        tuple: ("minimize", guess) tuple suitable for use in State.initial, State.final,
            or Time.initial, Time.final.

    Example:
        ```python
        time = ox.Time(
            initial=0.0,
            final=ox.Minimize(10.0),  # Minimize final time
            min=0.0,
            max=20.0
        )

        fuel = ox.State("fuel", (1,))
        fuel.final = [ox.Minimize(0)]  # Minimize final fuel consumption
        ```
    """
    return ("minimize", guess)

ctcs(constraint: Constraint, penalty: str = 'squared_relu', nodes: Optional[Tuple[int, int]] = None, idx: Optional[int] = None, check_nodally: bool = False) -> CTCS

Helper function to create CTCS (Continuous-Time Constraint Satisfaction) constraints.

This is a convenience function that creates a CTCS constraint with the same parameters as the CTCS constructor. Useful for functional-style constraint building.

Parameters:

Name Type Description Default
constraint Constraint

The Constraint to enforce continuously

required
penalty str

Penalty function type ('squared_relu', 'huber', or 'smooth_relu'). Defaults to 'squared_relu'.

'squared_relu'
nodes Optional[Tuple[int, int]]

Optional (start, end) tuple of node indices for enforcement interval. None enforces over entire trajectory.

None
idx Optional[int]

Optional grouping index for multiple augmented states

None
check_nodally bool

Whether to also enforce constraint at discrete nodes. Defaults to False.

False

Returns:

Name Type Description
CTCS CTCS

A CTCS constraint wrapping the input constraint

Example

Using the helper function:

from openscvx.symbolic.expr.constraint import ctcs
altitude_constraint = ctcs(
    altitude >= 10,
    penalty="huber",
    nodes=(0, 100),
    check_nodally=True
)

Equivalent to using CTCS constructor:

altitude_constraint = CTCS(altitude >= 10, penalty="huber", nodes=(0, 100))

Also equivalent to using .over() method on constraint:

altitude_constraint = (altitude >= 10).over((0, 100), penalty="huber")
Source code in openscvx/symbolic/expr/constraint.py
def ctcs(
    constraint: Constraint,
    penalty: str = "squared_relu",
    nodes: Optional[Tuple[int, int]] = None,
    idx: Optional[int] = None,
    check_nodally: bool = False,
) -> CTCS:
    """Helper function to create CTCS (Continuous-Time Constraint Satisfaction) constraints.

    This is a convenience function that creates a CTCS constraint with the same
    parameters as the CTCS constructor. Useful for functional-style constraint building.

    Args:
        constraint: The Constraint to enforce continuously
        penalty: Penalty function type ('squared_relu', 'huber', or 'smooth_relu').
            Defaults to 'squared_relu'.
        nodes: Optional (start, end) tuple of node indices for enforcement interval.
            None enforces over entire trajectory.
        idx: Optional grouping index for multiple augmented states
        check_nodally: Whether to also enforce constraint at discrete nodes.
            Defaults to False.

    Returns:
        CTCS: A CTCS constraint wrapping the input constraint

    Example:
        Using the helper function:

            from openscvx.symbolic.expr.constraint import ctcs
            altitude_constraint = ctcs(
                altitude >= 10,
                penalty="huber",
                nodes=(0, 100),
                check_nodally=True
            )

        Equivalent to using CTCS constructor:

            altitude_constraint = CTCS(altitude >= 10, penalty="huber", nodes=(0, 100))

        Also equivalent to using .over() method on constraint:

            altitude_constraint = (altitude >= 10).over((0, 100), penalty="huber")
    """
    return CTCS(constraint, penalty, nodes, idx, check_nodally)

to_expr(x: Union[Expr, float, int, np.ndarray]) -> Expr

Convert a value to an Expr if it is not already one.

This is a convenience function that wraps numeric values and arrays as Constant expressions, while leaving Expr instances unchanged. Used internally by operators to ensure operands are proper Expr objects.

Parameters:

Name Type Description Default
x Union[Expr, float, int, ndarray]

Value to convert - can be an Expr, numeric scalar, or numpy array

required

Returns:

Type Description
Expr

The input if it's already an Expr, otherwise a Constant wrapping the value

Source code in openscvx/symbolic/expr/expr.py
def to_expr(x: Union[Expr, float, int, np.ndarray]) -> Expr:
    """Convert a value to an Expr if it is not already one.

    This is a convenience function that wraps numeric values and arrays as Constant
    expressions, while leaving Expr instances unchanged. Used internally by operators
    to ensure operands are proper Expr objects.

    Args:
        x: Value to convert - can be an Expr, numeric scalar, or numpy array

    Returns:
        The input if it's already an Expr, otherwise a Constant wrapping the value
    """
    return x if isinstance(x, Expr) else Constant(np.array(x))

traverse(expr: Expr, visit: Callable[[Expr], None])

Depth-first traversal of an expression tree.

Visits each node in the expression tree by applying the visit function to the current node, then recursively visiting all children.

Parameters:

Name Type Description Default
expr Expr

Root expression node to start traversal from

required
visit Callable[[Expr], None]

Callback function applied to each node during traversal

required
Source code in openscvx/symbolic/expr/expr.py
def traverse(expr: Expr, visit: Callable[[Expr], None]):
    """Depth-first traversal of an expression tree.

    Visits each node in the expression tree by applying the visit function to the
    current node, then recursively visiting all children.

    Args:
        expr: Root expression node to start traversal from
        visit: Callback function applied to each node during traversal
    """
    visit(expr)
    for child in expr.children():
        traverse(child, visit)