Setting Bounds

After a problem has been instantiated, bounds on decision variables and constraints can be set using the bounds attribute of the problem object. The hierarchical structure of the Bounds class reflects the structure of the problem decision variables and constraints. In YAPSS, all bounds are initialized with default values +inf for upper bounds and -inf for lower bounds, except for phase durations, for which the lower bound defaults to zero.

Consider the HS071 problem example below, where bounds are defined for parameters and discrete constraints.

>>> from yapss import Problem
>>>
>>> problem = Problem(name="HS071", nx=[], ns=4, nd=2)
>>> bounds = problem.bounds
>>> bounds.parameter.lower = [1.0, 1.0, 1.0, 1.0]
>>> bounds.parameter.upper = [5.0, 5.0, 5.0, 5.0]
>>> bounds.discrete.lower = 25.0, 40.0
>>> bounds.discrete.upper[1] = 40.0

In this example, there are four decision variables (the four parameters), and two discrete constraints. Each parameter is bounded between 1 and 5, and the first discrete constraint is bounded between 25 and 40, while the second discrete constraint is bounded below by 40, without an upper limit.

Additional bounds can be set for each phase in the problem, covering variables like initial and final time, duration, states, controls, integral values, and path constraints.

Except for the bounds on the initial time, final time, and duration of each phase, all the bounds are arrays, and their values are implemented as NumPy arrays. Bound values to be set can be addressed using either slicing or direct assignment. For instance, the following three lines are functionally equivalent:

>>> bounds.parameter.lower[:] = [1.0, 1.0, 1.0, 1.0]
>>> bounds.parameter.lower = [1.0, 1.0, 1.0, 1.0]
>>> bounds.parameter.lower = 1.0, 1.0, 1.0, 1.0

List of Bounds

The available bounds include:

Time Bounds:

  • bounds.phase[k].initial_time.upper, bounds.phase[k].initial_time.lower

  • bounds.phase[k].final_time.upper, bounds.phase[k].final_time.lower

  • bounds.phase[k].duration.upper, bounds.phase[k].duration.lower

To ensure feasibility, set these bounds with care. For example, each phase must satisfy:

initial_time.lowerfinal_time.upper

and

final_time.lower - initial_time.upperduration.upper

State Bounds:

  • bounds.phase[k].initial_state.upper, bounds.phase[k].initial_state.lower

  • bounds.phase[k].final_state.upper, bounds.phase[k].final_state.lower

  • bounds.phase[k].state.upper, bounds.phase[k].state.lower

These bounds should be consistent across phases. For example:

final_state.lowerstate.upper

Control, Path, and Integral Bounds:

  • bounds.phase[k].control.upper, bounds.phase[k].control.lower

  • bounds.phase[k].path.upper, bounds.phase[k].path.lower

  • bounds.phase[k].integral.upper, bounds.phase[k].integral.lower

Parameter and Discrete Constraint Bounds:

  • bounds.parameter.upper, bounds.parameter.lower

  • bounds.discrete.upper, bounds.discrete.lower

Example

Consider the dynamic soaring problem. It has six states, two controls, one path constraint, three discrete constraints, and a single parameter for wind shear rate. We initialize the bounds for this problem as follows:

>>> from yapss import Problem
>>> import numpy as np
>>> problem = Problem(name="dynamic soaring", nx=[6], nu=[2], nh=[1], ns=1, nd=3)

The initial time is set to zero, with an expected duration between 10 and 30 seconds:

>>> # The initial time is fixed to be 0. The final time will be between 10 and 30.
>>> bounds = problem.bounds.phase[0]
>>> bounds.initial_time.lower = bounds.initial_time.upper = 0
>>> bounds.final_time.lower = 10
>>> bounds.final_time.upper = 30
>>>
>>> # The initial time is fixed to be 0. The final time will be between 10 and 30.
>>> bounds.initial_time.lower = 0
>>> bounds.initial_time.upper = 0
>>> bounds.final_time.lower = 10
>>> bounds.final_time.upper = 30

The initial and final states are set to zero, and there are bounds on the control variables (lift coefficient and bank angle) from the problem statement:

>>> # The initial and final positions are at the origin
>>> bounds.initial_state.lower[:3] = bounds.initial_state.upper[:3] = 0, 0, 0
>>> bounds.final_state.lower[:3] = bounds.final_state.upper[:3] = 0, 0, 0
>>>
>>> # CL_max <= 1.5. Set loose box bound on bank angle.
>>> bounds.control.lower = 0, np.radians(-75)
>>> bounds.control.upper = 1.5, np.radians(75)
>>>
>>> # Limits on the normal load
>>> bounds.path.lower = (-2,)
>>> bounds.path.upper = (5,)

There’s also a discrete constraint that imposes a periodicity condition on the velocity, flight path angle, and heading angle:

>>> # Discrete constraints
>>> problem.bounds.discrete.lower = problem.bounds.discrete.upper = 0, 0, np.radians(360)

In addition, it’s good practice to set loose box bounds on the decision variables, which can sometimes improve the performance of the Ipopt solver:

>>> # Set loose box bounds on the state.
>>> # None of these should be active in the solution.
>>> bounds.state.lower = -1500, -1000, 0, 10, np.radians(-75), np.radians(-225)
>>> bounds.state.upper = +1500, +1000, 1000, 350, np.radians(75), np.radians(225)

Bounds on initial and final states, position, and control variables are then set based on the problem requirements, as shown in this detailed example.

Special Considerations for State Bounds

Note

This section applies only to problems where state bounds act as path constraints, and where accurate Lagrange multipliers are required.

Broadly speaking, state constraints of the form

>>> problem.bounds.phase[0].state.lower[1] = -1000.0
>>> problem.bounds.phase[0].state.upper[1] = +1000.0

might be used in one of two ways:

  1. As inactive bounds to aid solver convergence without constraining the final solution.

  2. As path constraints, where bounds are expected to be active in the final solution.

In the case where state bounds are intended to be path constraints, the user should instead use path constraints in the user-defined continuous function, as below:

>>> def continuous(arg):
>>>     # Apply state[1] as a path constraint
>>>     arg.phase[0].path[0] = arg.phase[0].state[1]
>>>
>>> problem.continuous = continuous
>>> problem.bounds.phase[0].path.lower[0] = -1000.0
>>> problem.bounds.phase[0].path.upper[0] = +1000.0

While both approaches yield the correct primal solution (decision variables), state bounds applied directly as path constraints may lead to incorrect Lagrange multipliers — particularly for initial and final states. In order to obtain accurate numerical results, path constraints should be applied and Lagrange multipliers calculated only at collocation points, not all interpolation points, to be consistent with pseudospectral integration scheme. In addition, without additional logic, it’s difficult to determine whether the Lagrange multipliers returned by the NLP solver should be associated with the endpoint state constraints or the state path constraint.

For these reasons, state bounds expected to be active should be implemented as true path constraints, as shown in the example above. Note that these considerations do not apply to constraints on control variables.

The reset() Method

To reset bounds, call the reset() method at any level in the Bounds hierarchy. For instance:

>>> problem.bounds.reset()  # Resets all bounds

or for a single phase:

>>> problem.bounds.phase[0].reset()  # Resets bounds for phase 0

Input Validation

Setting bounds correctly can be error-prone, so YAPSS helps reduce errors by validating bounds configurations. Errors are caught when assigning values, with helpful feedback. For example, trying to assign a scalar instead of a sequence for path constraints raises an error:

>>> bounds.path.lower = -2
Traceback (most recent call last):
    ...
ValueError: ArrayBound must be a sequence of floats of length 1.

Similarly, trying to set conflicting control bounds results in an error:

>>> bounds.control.upper = 1.5, np.radians(0)
>>> bounds.control.lower = 0, np.radians(75)
>>> bounds.validate()
Traceback (most recent call last):
    ...
ValueError: Failed to set bound.phase[0].control.lower.
Must have bound.phase[0].control.upper[i] >= bound.phase[0].control.lower[i] for all i.
Condition failed for i in [1].

Bounds Class Reference

class Bounds(problem: yapss.Problem)[source]

Represents bounds on variables and constraints in an optimal control problem.

The Bounds class organizes and manages the bounds for decision variables and constraints in a hierarchical structure tailored to the optimal control problem. Each variable or constraint in the hierarchy has associated upper and lower bounds, along with methods to validate and reset the bounds.

Each bounded variable in the hierarchy includes the following attributes:

upperfloat or np.ndarray

The upper bound.

lowerfloat or np.ndarray

The lower bound.

validate()method

Validates user-defined bounds, raising a ValueError if they are infeasible.

reset()method

Resets bounds to default values (+inf for upper bounds, -inf for lower bounds), except for phase durations where the lower bound is set to zero.

Attributes:
parameterArrayBounds

Bounds for the parameters in the optimal control problem.

discreteArrayBounds

Bounds for discrete constraint functions within the problem.

phasetuple of PhaseBounds

A tuple of PhaseBounds instances, where each PhaseBounds instance represents the bounds associated with one phase in the problem. Each PhaseBounds instance has the attributes:

controlArrayBounds

Bounds for control variables within the phase.

stateArrayBounds

Bounds for state variables within the phase.

initial_stateArrayBounds

Bounds on the initial state for the phase.

final_stateArrayBounds

Bounds on the final state for the phase.

initial_timeScalarBounds

Bounds on the initial time of the phase.

final_timeScalarBounds

Bounds on the final time of the phase.

durationScalarBounds

Bounds on the duration of the phase, with a default lower bound of zero.

integralArrayBounds

Bounds for any integral values defined over the phase.

pathArrayBounds

Bounds for path constraints applied to the phase.

zero_modeArrayBounds

Bounds for the state “zero modes”

reset() None[source]

Reset all bounds to default values across phases, parameters, and constraints.

validate() None[source]

Validate the bounds for each variable and constraint in the problem.

Raises:
ValueError

If infeasible bounds are detected.