User guide · 2.0

Notebook 1 - Getting Started

In this notebook, you will learn the fundamentals of SHOTpy. By the end, you will be comfortable defining and solving an optimization problem.

Table of Contents

  1. Installation

  2. Creating Variables

  3. Your First Optimization Problem

  4. Objectives

  5. Constraints

  6. Building Large Problems Efficiently

  7. Retrieving Solution Details

  8. Putting It All Together: A Full MINLP Example

  9. Notebook 1 - Key Takeaways

1. Installation

First, install SHOTpy, then import the module and create a solver.

%pip install SHOTpy
import SHOTpy

# Create a SHOT solver
solver = SHOTpy.Solver()
print("SHOT solver created successfully!")
SHOT solver created successfully!

2. Creating Variables

Variables are the fundamental building blocks of optimization problems. SHOTpy supports:

  • Real (continuous) variables

  • Integer variables

  • Binary variables

The most convenient way to create a variable is to create it and add it to a problem in one step:

var_name = problem.addVariable("name", SHOTpy.VariableType.<Type>, lower_bound, upper_bound)

  • name (str): Human-readable identifier shown by the solver and in prints.

  • <Type>: Variable kind — Real, Integer, or Binary.

  • lower_bound (float, optional): Minimum allowed value.

  • upper_bound (float, optional): Maximum allowed value.

Without bounds, a binary variable has the bounds 0 and 1, and other variables are unbounded. A bound of float("inf") or -float("inf") also means that there is no bound.

A variable can also be created on its own with SHOTpy.Variable("name", SHOTpy.VariableType.<Type>, lower_bound, upper_bound) and added later with problem.addVariable(var). Important: A variable must be added to the problem before it is used in the objective or the constraints.

The problem numbers its variables in the order they are added, starting from 0. The number is the variable’s index, readable as var.index, and is the position of the variable’s value in a solution. It is assigned by the problem, so you never pass one in.

Example:

# Create a continuous variable: x ∈ [0, 10]
x = SHOTpy.Variable("x", SHOTpy.VariableType.Real, 0.0, 10.0)

# Create an integer variable: y ∈ {0, 1, 2, ..., 5}
y = SHOTpy.Variable("y", SHOTpy.VariableType.Integer, 0.0, 5.0)

# Create a binary variable: z ∈ {0, 1}
z = SHOTpy.Variable("z", SHOTpy.VariableType.Binary, 0.0, 1.0)

print(f"Created variables: {x.name}, {y.name}, {z.name}")

# Create a problem (more on this in §3) and add the variables to it
problem = SHOTpy.Problem(solver)
problem.addVariable(x)
problem.addVariable(y)
problem.addVariable(z)

# Or create a variable and add it to the problem in one step; addVariable returns the variable
u = problem.addVariable("u", SHOTpy.VariableType.Real, 0.0, 10.0)
v = problem.addVariable("v", SHOTpy.VariableType.Binary)  # bounds 0 and 1
w = problem.addVariable("w")                               # real, without bounds

print(f"Variables in the problem (name, index): {[(var.name, var.index) for var in problem.allVariables]}")
Created variables: x, y, z
Variables in the problem (name, index): [('x', 0), ('y', 1), ('z', 2), ('u', 3), ('v', 4), ('w', 5)]

3. Your First Optimization Problem

Before exploring all the ways you can build objectives and constraints, let’s solve something small and simple. Every SHOTpy problem follows the same structure:

Item

Syntax

Purpose

Create Solver

solver = SHOTpy.Solver()

Initialize SHOT solver instance

Create Problem

problem = SHOTpy.Problem(solver)

Create optimization problem object, which uses the settings of the solver

Add Variables

x = problem.addVariable("x", ...)

Create variables and add them to the problem

Set Objective

problem.setObjective(2*x + 3*y, ...)

Set the objective function and its direction

Add Constraint(s)

problem.addConstraint(x + y >= 5, "c1")

Add a constraint with an optional name

Finalize Problem

problem.finalize()

Lock problem structure before solving

Set Problem

solver.setProblem(problem)

Assign problem to solver; returns False if it fails

Solve

solver.solveProblem()

Execute solver; returns False if it fails

Check Status

solver.getModelReturnStatus()

Check whether the solution is proven optimal

Get Results

solver.getPrimalSolution()

Access primal solution

Let’s see this in action with a two-variable linear program: $$

\[\begin{align*} \text{minimize} \quad & 2x + 3y \\ \text{subject to} \quad & x + y \geq 5 \\ & 0 \leq x, y \leq 100 \end{align*}\]

$$

# Create solver
solver = SHOTpy.Solver()

# Create problem
problem = SHOTpy.Problem(solver)

# Add variables
x = problem.addVariable("x", SHOTpy.VariableType.Real, 0.0, 100.0)
y = problem.addVariable("y", SHOTpy.VariableType.Real, 0.0, 100.0)

# Set objective
problem.setObjective(2*x + 3*y, SHOTpy.ObjectiveDirection.Minimize)

# Add constraint
problem.addConstraint(x + y >= 5, "c1")

# Finalize problem
problem.finalize()

# Set problem
assert solver.setProblem(problem)

# Solve
assert solver.solveProblem()

# Check status
print(f"Status: {solver.getModelReturnStatus().name}")

# Get solution
sol = solver.getPrimalSolution()
print(f"Optimal objective value: {sol.objValue:.4f}")
print(f"Optimal solution: x = {sol.point[0]:.4f}, y = {sol.point[1]:.4f}")
- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴

    Iteration     │  Time  │  Dual cuts  │     Objective value     │   Objective gap   │     Current solution
     #: type      │  tot.  │   + | tot.  │       dual | primal     │    abs. | rel.    │    obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴

     1: LP-O         0.01                          10 | 10           0.0e+00 | 0.0e+00           10 | 0                
Status: OptimalGlobal
Optimal objective value: 10.0000
Optimal solution: x = 5.0000, y = 0.0000

This short example already uses every ingredient you’ll need. The next sections slow down and look at each piece in more detail (specifically more about objectives and constraints, what kind of terms you can use, and finally, retrieving solution details), before we put it all together in a larger and harder example.

4. Objectives

Objectives specify what to minimize or maximize. The simplest way to set the objective is to write it as an expression:

problem.setObjective(expression, SHOTpy.ObjectiveDirection.<Direction>)

  • expression: The function to optimize, written with variables, numbers, Python operators (+, -, *, /, **) and SHOTpy functions (e.g., SHOTpy.sqrt(expr), SHOTpy.exp(expr), SHOTpy.log(expr), SHOTpy.sin(expr), …).

  • <Direction>: Either Minimize (the default) or Maximize.

You don’t have to say whether the objective is linear, quadratic or nonlinear: problem.finalize() works it out, and creates a LinearObjectiveFunction, QuadraticObjectiveFunction or NonlinearObjectiveFunction.

Alternatively, the objective can be created from its class and built up term by term:

objective = SHOTpy.<Type>ObjectiveFunction(SHOTpy.ObjectiveDirection.<Direction>)

  • <Type>: Linear, Quadratic, or Nonlinear.

  • Add terms using: objective.add(). The linear and quadratic classes only take terms, i.e., linear (SHOTpy.LinearTerm(coeff, var)) and quadratic (SHOTpy.QuadraticTerm(coeff, var1, var2)) terms. NonlinearObjectiveFunction also takes expressions and signomial terms (SHOTpy.SignomialTerm(coeff, [(var, exp), ...])).

  • Then call problem.setObjective(objective).

The examples in this section and the next use a small problem with the variables \(x, y \in [0.1, 10]\). It is only used to try out objectives and constraints, and is not solved:

# A problem to try out objectives and constraints on; it is not solved
problem = SHOTpy.Problem(solver)
x = problem.addVariable("x", SHOTpy.VariableType.Real, 0.1, 10.0)
y = problem.addVariable("y", SHOTpy.VariableType.Real, 0.1, 10.0)

Linear Example:

# Minimize: 2*x + 3*y
problem.setObjective(2*x + 3*y, SHOTpy.ObjectiveDirection.Minimize)

# The same objective created from its class:
# objective = SHOTpy.LinearObjectiveFunction(SHOTpy.ObjectiveDirection.Minimize)
# objective.add(SHOTpy.LinearTerm(2.0, x))
# objective.add(SHOTpy.LinearTerm(3.0, y))
# problem.setObjective(objective)

Quadratic Example:

# Minimize: x^2 + y^2 + 2*x*y + x
problem.setObjective(x**2 + y**2 + 2*x*y + x, SHOTpy.ObjectiveDirection.Minimize)

# The same objective created from its class:
# objective = SHOTpy.QuadraticObjectiveFunction(SHOTpy.ObjectiveDirection.Minimize)
# objective.add(SHOTpy.QuadraticTerm(1.0, x, x))      # x^2
# objective.add(SHOTpy.QuadraticTerm(1.0, y, y))      # y^2
# objective.add(SHOTpy.QuadraticTerm(2.0, x, y))      # 2*x*y
# objective.add(SHOTpy.LinearTerm(1.0, x))            # x
# problem.setObjective(objective)

Nonlinear Example:

# Minimize: sqrt(x) + 2*exp(x) + log(y) + sin(x) + 1.0*x^2.5*y^-1.0 + 1.0*x^2 - 1.0
problem.setObjective(SHOTpy.sqrt(x) + 2*SHOTpy.exp(x) + SHOTpy.log(y) + SHOTpy.sin(x) + 1.0*x**2.5*y**-1.0 + 1.0*x**2 - 1.0, SHOTpy.ObjectiveDirection.Minimize)

# The same objective created from its class, adding one part at a time:
# objective = SHOTpy.NonlinearObjectiveFunction(SHOTpy.ObjectiveDirection.Minimize)
# objective.add(SHOTpy.sqrt(x))
# objective.add(2*SHOTpy.exp(x))
# objective.add(SHOTpy.log(y))
# objective.add(SHOTpy.sin(x))
# objective.add(1.0*x**2 - 1.0)
# objective.add(SHOTpy.SignomialTerm(1.0, [(x, 2.5), (y, -1.0)]))
# problem.setObjective(objective)

Note: Each call to problem.setObjective() replaces the objective, so the problem now has the nonlinear objective. At the end of the next section, we’ll see what finalize() makes of it.

5. Constraints

Constraints are the rules, limits, and mathematical conditions that define the boundaries of your model. The simplest way to add a constraint is to write it as a comparison:

problem.addConstraint(expression <= upper_bound, "name")

  • The comparison can use <=, >= or ==, with an expression on either side, e.g., x*y >= x + 1.

  • "name" (str, optional): Human-readable name (e.g., “c1”, “demand”, “capacity”). Without a name, the constraint is named constraint_<index>. Give each constraint its own name, so that you can look it up with problem.getConstraint("name").

  • A constraint with both a lower and an upper bound is written as SHOTpy.inequality(lower_bound, expression, upper_bound). A chained comparison such as 1 <= x + y <= 5 raises a TypeError, since Python would evaluate it as (1 <= x + y) and (x + y <= 5) and lose one of the bounds.

As for the objective, problem.finalize() decides whether a constraint is a LinearConstraint, QuadraticConstraint or NonlinearConstraint. It may replace the constraint with a new object, so read the constraints back from the problem after finalize(), e.g., with problem.getConstraint("name").

Alternatively, a constraint can be created from its class and built up term by term:

constraint = SHOTpy.<Type>Constraint("name", lower_bound, upper_bound)

  • <Type>: Linear, Quadratic, or Nonlinear.

  • lower_bound (float): Minimum value for the left-hand side; use SHOTpy.SHOT_DBL_MIN if unbounded below.

  • upper_bound (float): Maximum value for the left-hand side; use SHOTpy.SHOT_DBL_MAX if unbounded above.

  • Add terms using: constraint.add(), which takes the same terms as the objective classes.

  • Then call problem.addConstraint(constraint).

Linear Example:

# 2x + y <= 10
problem.addConstraint(2*x + y <= 10, "linear_con")

# The same constraint created from its class:
# constraint = SHOTpy.LinearConstraint("linear_con", SHOTpy.SHOT_DBL_MIN, 10.0)
# constraint.add(SHOTpy.LinearTerm(2.0, x))
# constraint.add(SHOTpy.LinearTerm(1.0, y))
# problem.addConstraint(constraint)

Quadratic Example:

# x^2 + y^2 <= 4
problem.addConstraint(x**2 + y**2 <= 4, "quadratic_con")

# The same constraint created from its class:
# constraint = SHOTpy.QuadraticConstraint("quadratic_con", SHOTpy.SHOT_DBL_MIN, 4.0)
# constraint.add(SHOTpy.QuadraticTerm(1.0, x, x))
# constraint.add(SHOTpy.QuadraticTerm(1.0, y, y))
# problem.addConstraint(constraint)

Nonlinear Example:

# sqrt(x) + 2*exp(x) + log(y) + sin(x) + 1.0*x^2.5*y^-1.0 + 1.0*x^2 <= 10.0
expr = SHOTpy.sqrt(x) + 2*SHOTpy.exp(x) + SHOTpy.log(y) + SHOTpy.sin(x) + 1.0*x**2.5*y**-1.0 + 1.0*x**2
problem.addConstraint(expr <= 10.0, "nonlinear_con")

# The same constraint created from its class:
# constraint = SHOTpy.NonlinearConstraint("nonlinear_con", expr, SHOTpy.SHOT_DBL_MIN, 10.0)
# problem.addConstraint(constraint)

Two-Sided and Equality Example:

# 1 <= x + y <= 8
problem.addConstraint(SHOTpy.inequality(1, x + y, 8), "ranged_con")
# Alternatively, you can add two constraints instead of one ranged constraint:
# problem.addConstraint(x + y >= 1, "lower_bound_con")
# problem.addConstraint(x + y <= 8, "upper_bound_con")

# x = 2y
problem.addConstraint(x == 2*y, "equality_con")

What Does finalize() Make of It?

Let’s finalize the problem and read the objective and the constraints back from it, to see which class finalize() gave each of them:

problem.finalize()

print(f"{'objective':14s} -> {type(problem.objectiveFunction).__name__}")
for constraint in problem.numericConstraints:
    print(f"{constraint.name:14s} -> {type(constraint).__name__}")

# A constraint can also be looked up by its name
print(problem.getConstraint("ranged_con"))
objective      -> NonlinearObjectiveFunction
linear_con     -> LinearConstraint
quadratic_con  -> QuadraticConstraint
nonlinear_con  -> NonlinearConstraint
ranged_con     -> LinearConstraint
equality_con   -> LinearConstraint
<LinearConstraint 'ranged_con': [    3,L-convex    ] [L    ]   ranged_con:1 <=   +x +y <= 8>

What finalize() Extracts

You don’t have to write the terms yourself: problem.finalize() extracts them from the expressions.

Written as

After finalize()

2*x1*x2, x1**2

quadratic term

3*x1 + 2*x2 - 1

linear terms and a constant

x1*x2*x3

monomial term

x1**0.5 * x2**-1, x1**3, x1/x2

signomial term

exp(x3)

stays a nonlinear expression

If nothing nonlinear is left, the constraint or objective becomes a QuadraticConstraint or LinearConstraint (or the corresponding objective class). The extraction is controlled by the settings Model.Reformulation.Quadratics.ExtractStrategy (0 = do not extract), Model.Reformulation.Monomials.Extract and Model.Reformulation.Signomials.Extract, which have to be set before finalize().

finalize() also rewrites the model into the form SHOT works with: a constraint with only a lower bound, f(x) >= L, becomes -f(x) <= -L, while a constraint with both bounds, L <= f(x) <= U (e.g., an equality), is kept as it is.

A signomial term can also be written explicitly with SHOTpy.SignomialTerm(coefficient, [(variable, power), ...]) and added to a NonlinearConstraint or NonlinearObjectiveFunction; e.g., SHOTpy.SignomialTerm(3.0, [(x1, 2.0), (x2, -1.0)]) is \(3x_1^2x_2^{-1}\).

# Create problem
problem = SHOTpy.Problem(solver)

# Add variables
x1 = problem.addVariable("x1", SHOTpy.VariableType.Real, 0.1, 10.0)
x2 = problem.addVariable("x2", SHOTpy.VariableType.Real, 0.1, 10.0)
x3 = problem.addVariable("x3", SHOTpy.VariableType.Real, 0.1, 10.0)

# Set objective: x1*x2*x3 + x1^0.5, a monomial and a signomial term
problem.setObjective(x1*x2*x3 + x1**0.5, SHOTpy.ObjectiveDirection.Minimize)

# Add constraints
# 1 <= 2*x1*x2 <= 5, a quadratic term
problem.addConstraint(SHOTpy.inequality(1, 2*x1*x2, 5), "q")

# 2*x1 >= 1, a linear term
problem.addConstraint(2*x1 >= 1, "lin")

# 3*x1^2*x2^-1 <= 100, with the signomial term written explicitly
constraint = SHOTpy.NonlinearConstraint("sig", SHOTpy.SHOT_DBL_MIN, 100.0)
constraint.add(SHOTpy.SignomialTerm(3.0, [(x1, 2.0), (x2, -1.0)]))
problem.addConstraint(constraint)

# Finalize problem
problem.finalize()

# Read the constraints and the objective back from the problem
for constraint in problem.numericConstraints:
    print(f"{constraint.name:9s} -> {type(constraint).__name__}")

properties = problem.objectiveFunction.properties
print(f"objective -> {type(problem.objectiveFunction).__name__}, "
      f"monomial terms: {properties.hasMonomialTerms}, signomial terms: {properties.hasSignomialTerms}")

# 2*x1 >= 1 has been rewritten as -2*x1 <= -1
print(problem.getConstraint("lin"))
q         -> QuadraticConstraint
lin       -> LinearConstraint
sig       -> NonlinearConstraint
objective -> NonlinearObjectiveFunction, monomial terms: True, signomial terms: True
<LinearConstraint 'lin': [    1,L-convex    ] [L    ]          lin:  -2*x1 <= -1>

6. Building Large Problems Efficiently

Above we added variables, constraints and terms one at a time, which reads well and is fine for small models. However, for large models it does not scale. To build a large problem efficiently, define a Python list and use the following commands:

Instead of

Use

problem.addVariable(v) in a loop

problem.addVariables(list_of_variables)

problem.addConstraint(c) in a loop

problem.addConstraints(list_of_constraints)

problem.addConstraint(x <= 1, name) in a loop

problem.addConstraints(list_of_comparisons, list_of_names)

constraint.add(LinearTerm(c, v)) in a loop

constraint.add(SHOTpy.LinearTerms(coefficients, variables))

constraint.add(QuadraticTerm(c, u, v)) in a loop

constraint.add(SHOTpy.QuadraticTerms(coefficients, first, second))

The bulk add merges the terms through a hash map in a single pass, so the result is exactly the same as adding them one at a time — only faster.

The example below builds a linear and a quadratic constraint with \(N = 20\,000\) terms each, first one term at a time and then all terms at once:

import time
N = 20000

def build(bulk):
    """Build one linear row and one quadratic row of N terms, returning the time it took."""
    solver = SHOTpy.Solver()
    problem = SHOTpy.Problem(solver)

    variables = [SHOTpy.Variable(f"x{i}", SHOTpy.VariableType.Real, 0.0, 10.0) for i in range(N)]
    coefficients = [1.0 + i for i in range(N)]

    # A list is accepted wherever a Variables container is
    problem.addVariables(variables)

    start = time.perf_counter()

    row = SHOTpy.LinearConstraint("row", 0.0, 1.0)
    quadratic_row = SHOTpy.QuadraticConstraint("qrow", SHOTpy.SHOT_DBL_MIN, 1.0)

    if bulk:
        row.add(SHOTpy.LinearTerms(coefficients, variables))
        quadratic_row.add(SHOTpy.QuadraticTerms(coefficients, variables, variables))
    else:
        for c, v in zip(coefficients, variables):
            row.add(SHOTpy.LinearTerm(c, v))
        for c, v in zip(coefficients, variables):
            quadratic_row.add(SHOTpy.QuadraticTerm(c, v, v))

    elapsed = time.perf_counter() - start

    problem.addConstraints([row, quadratic_row])
    return elapsed, problem

single_time, single_problem = build(bulk=False)
bulk_time, bulk_problem = build(bulk=True)

print(f"one term at a time : {single_time:7.3f} s")
print(f"all terms at once  : {bulk_time:7.3f} s  ({single_time / bulk_time:.0f} times faster)")
one term at a time :   0.221 s
all terms at once  :   0.006 s  (36 times faster)

Other Ways of Building a Container

A container can also be built from a list of terms, built up with append (which does not merge terms of the same variable, and reserve takes the number of terms that will be added), or handed a list directly: constraint.add([...]) converts the list and adds all of it at once. Constraints written as comparisons are added all at once with problem.addConstraints(comparisons, names).

# Create solver
solver = SHOTpy.Solver()

# Create problem
problem = SHOTpy.Problem(solver)

# Add variables, all at once
x = SHOTpy.Variable("x", SHOTpy.VariableType.Real, 0.0, 10.0)
y = SHOTpy.Variable("y", SHOTpy.VariableType.Real, 0.0, 10.0)
problem.addVariables([x, y])

# Set objective: 2x + 3y, from a list of terms
objective = SHOTpy.LinearObjectiveFunction(SHOTpy.ObjectiveDirection.Minimize)
objective.add(SHOTpy.LinearTerms([SHOTpy.LinearTerm(2.0, x), SHOTpy.LinearTerm(3.0, y)]))
problem.setObjective(objective)

# Add constraints
# x + y >= 1, from terms built up with append
terms = SHOTpy.LinearTerms()
terms.reserve(2)
terms.append(SHOTpy.LinearTerm(1.0, x))
terms.append(SHOTpy.LinearTerm(1.0, y))
c1 = SHOTpy.LinearConstraint("c1", 1.0, SHOTpy.SHOT_DBL_MAX)
c1.add(terms)

# x - y <= 3, from a list passed straight to add()
c2 = SHOTpy.LinearConstraint("c2", SHOTpy.SHOT_DBL_MIN, 3.0)
c2.add([SHOTpy.LinearTerm(1.0, x), SHOTpy.LinearTerm(-1.0, y)])

problem.addConstraints([c1, c2])

# Comparisons and their names, all at once
problem.addConstraints([x + 2*y <= 15, 2*x + y <= 15], ["c3", "c4"])

# Finalize problem
problem.finalize()

7. Retrieving Solution Details

After solving, first check the outcome with solver.getModelReturnStatus(): a solution is proven optimal only if the status is SHOTpy.ModelReturnStatus.OptimalGlobal. Other values are, e.g., FeasibleSolution (a solution was found, but it is not proven optimal), InfeasibleGlobal and Unbounded. The solver also provides the bounds and gaps of the solve:

Method

Returns

solver.getModelReturnStatus()

The status of the solution, e.g., OptimalGlobal or FeasibleSolution

solver.getTerminationReason()

Why SHOT stopped, e.g., AbsoluteGap, RelativeGap or TimeLimit

solver.getPrimalBound()

The objective value of the best solution found

solver.getGlobalDualBound()

The best proven bound on the optimal objective value

solver.getAbsoluteObjectiveGap() / solver.getRelativeObjectiveGap()

The gap between the primal and the dual bound

solver.getPrimalSolutions()

All the solutions found, the best first

To access the optimal solution itself, use solver.getPrimalSolution(), which returns a solution object with detailed information about the solve. Below you can find a table containing all available attributes:

Attribute

Type

Description

point

List of floats

Vector containing the optimal values for all variables, in the order of their indexes

objValue

Float

The objective function value at the optimal solution

iterFound

Integer

The iteration number at which this solution was found

sourceType

Enum

The type/source of the solution (e.g., PrimalSolutionSource.NLPFixedIntegers)

sourceDescription

String

Human-readable description of how the solution was obtained

maxIntegerToleranceError

Float

Maximum deviation of integer variables from their rounded values (before rounding was applied)

maxDeviatingConstraintLinear

Pair (int, float)

Index and value of the linear constraint with largest violation (index -1 if none)

maxDeviatingConstraintQuadratic

Pair (int, float)

Index and value of the quadratic constraint with largest violation (index -1 if none)

maxDeviatingConstraintNonlinear

Pair (int, float)

Index and value of the nonlinear constraint with largest violation (index -1 if none)

boundProjectionPerformed

Boolean

Whether variable bounds were corrected (projected) to upper or lower bounds

integerRoundingPerformed

Boolean

Whether integer variables were rounded from their relaxed values

displayed

Boolean

Whether this solution has already been printed to console

So far we’ve only used sol.objValue and sol.point, but as can be noted, the solution object carries a lot more than that. For example, the attributes let you inspect exactly how a solution was reached, which iteration found it, whether any rounding or bound projection was needed, and where the largest constraint violations (if any) occurred.

8. Putting It All Together: A Full MINLP Example

Let’s combine everything to model and solve a complete mixed-integer nonlinear optimization problem.

Consider this mixed-integer nonlinear problem:

\[\begin{split}\begin{align*} \text{minimize} \quad & -x_1 - x_2\\ \text{subject to} \quad & 0.15(x_1 - 8)^2 + 0.1(x_2 - 6)^2 + 0.025e^{x_1}x_2^{-2} - 5 \leq 0 \\ & \frac{1}{x_1} + \frac{1}{x_2} - x_1^{0.5}x_2^{0.5} + 4 \leq 0 \\ & 2x_1 - 3x_2 \leq 2 \\ & 1 \leq x_1 \leq 20, \quad 1 \leq x_2 \leq 20 \\ & x_1 \in \mathbb{R}, x_2 \in \mathbb{Z} \end{align*}\end{split}\]
# Create solver
solver = SHOTpy.Solver()

# Create problem
problem = SHOTpy.Problem(solver)

# Add variables
x1 = problem.addVariable("x1", SHOTpy.VariableType.Real, 1.0, 20.0)
x2 = problem.addVariable("x2", SHOTpy.VariableType.Integer, 1.0, 20.0)

# Set objective
problem.setObjective(-x1 - x2, SHOTpy.ObjectiveDirection.Minimize)

# Add constraints
# 0.15(x1-8)^2 + 0.1(x2-6)^2 + 0.025*exp(x1)*x2^-2 - 5 <= 0
problem.addConstraint(0.15*(x1 - 8)**2 + 0.1*(x2 - 6)**2 + 0.025*SHOTpy.exp(x1)*x2**(-2) - 5.0 <= 0, "nonlin_con1")

# 1/x1 + 1/x2 - x1^0.5*x2^0.5 + 4 <= 0
problem.addConstraint(1.0/x1 + 1.0/x2 - x1**0.5 * x2**0.5 + 4.0 <= 0, "nonlin_con2")

# 2*x1 - 3*x2 <= 2
problem.addConstraint(2*x1 - 3*x2 <= 2, "lin_con")

# Finalize problem
problem.finalize()

# Set problem
assert solver.setProblem(problem)

# Solve
assert solver.solveProblem()
 Performing bound tightening on original problem.
  - Bounds for 2 variables tightened in 0.00 s and 5 passes.
  - Objective bounds are: [-23.4282, -4.22729]

 Performing bound tightening on reformulated problem.
  - Bounds for 2 variables tightened in 0.00 s and 2 passes.
  - Objective bounds are: [-23.4282, -4.22729]
- Interior point search ──────────────────────────────────────────────────────────────────────────────────────────────╴

 Strategy selected:          cutting plane minimax
    Iteration     │  Time  │    Cuts     │     Objective value     │  Objective diff.   
     #: type      │  tot.  │   + | tot.  │    problem | line srch  │    abs. | rel.    
╶─────────────────┴────────┴─────────────┴─────────────────────────┴──────────────────╴
     1: LP           0.00                      -1e+12 | -1e+12          inf. | inf.    
     2: LP           0.00      2 | 2          -7.6275 | 0.328035     8.0e+00 | 1.0e+00 
     3: LP           0.00      2 | 4         -3.29179 | 0.299795     3.6e+00 | 1.1e+00 
     4: LP           0.00      2 | 6         -1.72401 | 0.0590611    1.8e+00 | 1.0e+00 
     5: LP           0.00      2 | 8        -0.382227 | 0.0339402    4.2e-01 | 1.1e+00 
     6: LP           0.00      2 | 10       -0.374105 | 0.00370268   3.8e-01 | 1.0e+00 
     7: LP           0.01      2 | 12       -0.374105 | -0.0165915   3.6e-01 | 9.6e-01 

 Valid interior point with constraint deviation -0.017 found.

- Main iteration step ────────────────────────────────────────────────────────────────────────────────────────────────╴

    Iteration     │  Time  │  Dual cuts  │     Objective value     │   Objective gap   │     Current solution
     #: type      │  tot.  │   + | tot.  │       dual | primal     │    abs. | rel.    │    obj.fn. | max.err.
╶─────────────────┴────────┴─────────────┴─────────────────────────┴───────────────────┴──────────────────────────────╴

     1: LP-O         0.01                    -23.4282 | -16.8377     6.6e+00 | 3.9e-01     -23.4282 | 2: 5.00e+00      
     2: LP-O         0.01      3 | 3         -22.5374 | -20.0386     2.5e+00 | 1.2e-01     -22.5374 | 1: 1.65e+00      
     3: LP-O         0.01      2 | 5         -22.1259 | -20.0386     2.1e+00 | 1.0e-01     -22.1259 | 1: 1.28e+00      
     4: LP-O         0.01      2 | 7         -21.7557 | -20.0386     1.7e+00 | 8.6e-02     -21.7557 | 1: 8.78e-01      
     5: LP-O         0.01      1 | 8         -21.3837 | -20.0386     1.3e+00 | 6.7e-02     -21.3837 | 1: 4.32e-01      
     6: MILP-SL1     0.01      2 | 10        -21.3837 | -20.2459     1.1e+00 | 5.6e-02     -20.2459 | 0: 0.00e+00      
     1: NLPSOLPT-R   0.02      2 | 10        -21.3837 | -20.9036     4.8e-01 | 2.3e-02     -20.9036 | 0: 9.98e-09      
     7: MILP-O       0.02                    -20.9373 | -20.9036     3.4e-02 | 1.6e-03     -20.9373 | 2: 5.14e-02      
     8: MILP-O       0.03      6 | 16        -20.9036 | -20.9036     0.0e+00 | 0.0e+00     -20.9036 | 6: 1.31e+00      
# Check the outcome of the solve
print(f"Status: {solver.getModelReturnStatus().name}")
print(f"Termination Reason: {solver.getTerminationReason().name}")
print(f"Primal Bound: {solver.getPrimalBound()}")
print(f"Dual Bound: {solver.getGlobalDualBound()}\n")

# Get detailed solution information
sol = solver.getPrimalSolution()

# Solution details
print(f"Objective Value: {sol.objValue}")
print(f"Iteration Found: {sol.iterFound}")
print(f"Source Description: {sol.sourceDescription}\n")

# Variable values
print("Variable Values:")
print(f"  x1 = {sol.point[0]}")
print(f"  x2 = {sol.point[1]}")

# The optimum is x1 ≈ 8.903615, x2 = 12
assert abs(sol.objValue + 20.903615) < 1e-3
Status: OptimalGlobal
Termination Reason: AbsoluteGap
Primal Bound: -20.903615008052974
Dual Bound: -20.903615033838058

Objective Value: -20.903615008052974
Iteration Found: 8
Source Description: MIP sol. pool

Variable Values:
  x1 = 8.903615008052975
  x2 = 12.0

9. Key Takeaways

1. SHOTpy Uses a Consistent, Repeatable Workflow

Every optimization problem follows the same eight-step pattern:

  1. Create solver.

  2. Create problem.

  3. Add variables.

  4. Set objective.

  5. Add constraints.

  6. Finalize problem.

  7. Set the problem

  8. Solve and retrieve the results.

Once you master this pattern, you can model any LP/QP/NLP/MILP/MI(QC)QP/MINLP problem with SHOTpy.

2. Modeling in SHOTpy Is Straightforward and Intuitive

You write mathematics almost exactly as you think it. Build expressions using natural Python operators and SHOTpy functions.

3. Solutions Contain Rich Information

The model return status tells you whether a solution is proven optimal. Beyond the optimal variable values, solutions reveal detailed information about the solve. This diagnostic information helps you understand solution quality and solver behavior.

4. Tips for Best Results

  • Follow the order of the steps: nothing can be added to a problem after finalize(), and most settings have to be changed before solver.setProblem().

  • Build anything but a toy model with the bulk forms of Section 6, not a term at a time.

  • Give the variables bounds that are as tight as you know them; this helps the solver.

  • Check the return values of setProblem() and solveProblem(), and getModelReturnStatus() and the bounds after solving.

Next Steps

In Notebook 2, you’ll learn more about the solver features. To name a few, you’ll learn how to:

  • Read problems from a file.

  • Configure the solver for your specific needs.

  • Use callbacks to customize SHOT’s behavior.