Download as pdf or txt
Download as pdf or txt
You are on page 1of 281

Hands-On Mathematical Optimization

with AMPL in Python

The MO Book Group

Dec 07, 2023


CONTENTS

i
Hands-On Mathematical Optimization with AMPL in Python

Welcome to this repository of notebooks Hands-On Mathematical Optimization with AMPL in Python, also know as Data-
Driven Mathematical Optimization with AMPL in Python, or MO-Book With AMPL, a project currently under development
with completion expected by the end of 2023. This is the AMPL version of Hands-On Mathematical Optimization in
Python. These notebooks introduce the concepts and tools of mathematical optimization with examples from a range of
disciplines. The goals of these notebooks are to:
• provide a foundation for hands-on learning of mathematical optimization,
• demonstrate the tools and concepts of optimization with practical examples,
• help readers to develop the practical skills needed to build models and solving problems using state-of-the-art
modeling languages and solvers.

Getting started

The notebooks in this repository make extensive use of amplpy which is an interface that allows developers to access
the features of AMPL from within Python. AMPL (A Mathematical Programming Language) is an algebraic modeling
language to describe and solve high-complexity problems in large-scale optimization. Natural mathematical modeling
syntax lets you formulate optimization models the way you think about them. AMPL’s new Python ecosystem allows you
to collaborate, ideate, and prototype to build full optimization applications and deploy them to larger systems.
All notebooks in this repository can be opened and run in Google Colab. A launch icon appearing at the top of a page
(look for the rocket) indicates the notebook can be opened as an executable document. Selecting Colab will reopen the
notebook in Google Colab. Cells inside of the notebooks will perform any necessary installations of amplpy and solvers
needed to execute the code within the notebook.
Start your journey with the first chapter!

Help us!

We seek your feedback! If you encounter an issue or have suggestions on how to make these examples better, please open
an issue using the link at the top of every page (look for the Github cat icon).

About Us

We are a group of researchers and educators who came together with a common purpose of developing materials for use
in our classroom teaching. Hopefully, these materials will find use in other classrooms and, most importantly, by those
seeking entry into the world of building optimization models for data-rich applications.
• Krzysztof Postek, Boston Consulting Group (formerly TU Delft)
• Alessandro Zocca, VU Amsterdam
• Joaquim Gromicho, ORTEC and the University of Amsterdam
• Jeffrey Kantor, University of Notre Dame

Citation

If you wish to cite this work, please use

CONTENTS 1
Hands-On Mathematical Optimization with AMPL in Python

@book{PostekZoccaAMPL2024,
title = "Hands-On Mathematical Optimization with AMPL in Python",
author = "Postek, Krzysztof and Zocca, Alessandro and Gromicho, Joaquim and Kantor,
↪ Jeffrey",

year = 2024,
publisher = "Online",
url = "https://ampl.com/mo-book"
}

CONTENTS 2
CHAPTER

ONE

1. MATHEMATICAL OPTIMIZATION

1.1 What is mathematical optimization?

Mathematical optimization is a broad term describing a way of mathematically describing decision problems and then
solving them using dedicated algorithms. Optimization models consist of three parts:
• the decision variables, which correspond to actions or choices that we can make in our decision problem: whether
to open a new manufacturing facility, which supply routes to use, or for which prices we should sell our products.
• the objective function, which is used to evaluate a specific solution (i.e. a specific choice of values for the decision
variables introduced earlier). For the objective function, a “goal” should be specified, either maximize or minimize
it. In other applications, the objective can be to minimize operational costs or to maximize the number of satisfied
customers.
• the constraints that restrict the possible values of our decision variables, i.e., conditions that must be satisfied.
Other common examples of constraints are to require that a maximum allowed budget is satisfied, that all demand
of important customers is met, or that the capacities of warehouses are not exceeded.
Both the objective function and the constraints are expressed as functions of the decision variables. The constraints define
the feasible region of a model, i.e., the set of all candidate solutions that meet the constraints.
The goal of an optimization model is to find the best solution – the global optimum – among the values for the decision
variables that meet all the constraints.
Applied mathematical optimization requires three types of skills, which can be related to three fundamental questions:
• What to model? First, the modeler must translate a problem from the real world to an abstract mathematical
representation. Not every aspect of the real world can or should be taken into account by the model, so there are
many choices to be made in this first step, which typically have a significant impact on the model and solution
approach.
• How to model? There can be multiple equivalent model formulations. Conceptually, equivalent models solve the
same optimization problem, but the applicable solution approaches or computational complexity may differ.
• How to interpret the model’s solution? After solving the model, the solution has to be evaluated and translated
back to the original real-world problem
These three aspects should be treated as a continuous process, not as sequential steps. For example, if the final solution
turns out to be impractical, we need to adjust the model. If certain desired properties cannot be modeled efficiently,
perhaps we should re-evaluate what to include in the model. A mathematical model is a tool, not a goal (well, except for
mathematicians to study). A model is “always flawed” and our challenge is to make a model useful.
Mathematically, we can describe optimization problems as follows. Given an objective function 𝑓 ∶ 𝑋 → ℝ to be
minimized, with 𝑋 ⊆ ℝ𝑛 being the feasible region of candidate solutions, we seek to find 𝑥 ∈ 𝑋 satisfying the following
condition 𝑓(𝑦) ≥ 𝑓(𝑥) ∀ 𝑦 ∈ 𝑋, i.e., that the solution we find is at least as good as all other possible solutions. Similarly,

3
Hands-On Mathematical Optimization with AMPL in Python

we can define a maximization problem by changing the last condition into 𝑓(𝑦) ≤ 𝑓(𝑥) ∀ 𝑦 ∈ 𝑋. In both cases, we
refer to such solutions as optimal solutions. The general way to formulate a minimization problem is:

min 𝑓(𝑥)
s.t. 𝑥 ∈ 𝑋,

and similarly for a maximization problem. Different types of function 𝑓 and set 𝑋 lead to different types of optimization
problems and to different solution techniques.
In this introductory chapter, we present a simple example of optimization in the context of production planning, which
serves also as a tutorial introduction to optimization in the Python programming language and the AMPL optimization
modeling language.
Go to the next chapter about linear optimization.

1.2 A Production Planning Problem

1.2.1 Problem Statement

A company produces two versions of a product. Each version is made from the same raw material that costs $10 per
gram, and each version requires two different types of specialized labor to finish.
• 𝑈 is the higher priced version of the product. 𝑈 sells for $270 per unit and requires 10 grams of raw material, one
hour of labor type 𝐴, two hours of labor type 𝐵. The market demand for 𝑈 is limited to forty units per week.
• 𝑉 is the lower priced version of the product with unlimited demand that sells for $210 per unit and requires 9 grams
of raw material, 1 hour of labor type 𝐴 and 1 hour of labor type 𝐵.
This data is summarized in the following table:

Version Raw Material required Labor A required Labor B required Market Demand Price
U 10 g 1 hr 2 hr ≤ 40 units $270
V 9g 1 hr 1 hr unlimited $210

Weekly production at the company is limited by the availability of labor and by the inventory of raw material. The raw
material has a shelf life of one week and must be ordered in advance. Any raw material left over at the end of the week
is discarded. The following table details the cost and availability of raw material and labor.

Resource Amount Available Cost


Raw Material ? $10 / g
Labor A 80 hours $50 / hour
Labor B 100 hours $40 / hour

The company wishes to maximize gross profits.


1. How much raw material should be ordered in advance for each week?
2. How many units of 𝑈 and 𝑉 should the company produce each week?

1.2. A Production Planning Problem 4


Hands-On Mathematical Optimization with AMPL in Python

1.2.2 Mathematical Model

The problem statement above describes an optimization problem. Reformulating the problem statement as a mathematical
model involves three kinds of elements:
• decision variables
• objective function
• constraints
The starting point in developing a mathematical model is to list decision variables relevant to the problem at hand. Decision
variables are quantities that can be modified to achieve a desired outcome. While some decision variables introduced at
this stage may prove redundant later, the goal at this point is to create a comprehensive list of variables that will be useful
in expressing the problem’s objective and constraints.
For this problem statement, listed below are decision variables with symbols, descriptions, and any lower and upper bounds
that are known from the problem data.

Decision Variable Description lower bound upper bound


𝑥𝑀 amount of raw material used 0 -
𝑥𝐴 amount of Labor A used 0 80
𝑥𝐵 amount of Labor B used 0 100
𝑦𝑈 number of 𝑈 units to produce 0 40
𝑦𝑉 number of 𝑉 units to produce 0 -

The next step is to formulate an objective function describing that describes how we will measure the value of candidate
solutions to the problem. In this case, the value of the solution is measured by profit, which is to be maximized.Profit is
equal to the difference between total revenue from sales and total cost of operations:

profit = revenue − cost

Total revenue, in turn, is the sum of the revenues from the two products, and total cost is the sum of the costs of the three
resources:
revenue = 270𝑦𝑈 + 210𝑦𝑉
cost = 10𝑥𝑀 + 50𝑥𝐴 + 40𝑥𝐵

Putting these together, we have the following objective function expressing maximization of profit:

maximize 270𝑦𝑈 + 210𝑦𝑉 − 10𝑥𝑀 − 50𝑥𝐴 − 40𝑥𝐵

Finally, the decision variables 𝑦𝑈 , 𝑦𝑉 , 𝑥𝑀 , 𝑥𝐴 , 𝑥𝐵 need to satisfy the specific conditions given in the problem statement.
Constraints are mathematical relationships among decision variables or expressions, formulated either as equalities or
inequalities. In this problem, for each resource there is a requirement that the amount used in production of 𝑈 plus
the amount used in the production of 𝑉 must not exceed the amount available. These requirements can be expressed
mathematicaly as linear inequalities:

10𝑦𝑈 + 9𝑦𝑉 ≤ 𝑥𝑀 raw material


2𝑦𝑈 + 1𝑦𝑉 ≤ 𝑥𝐴 labor A
1𝑦𝑈 + 1𝑦𝑉 ≤ 𝑥𝐵 labor B

We are now ready to formulate the full mathematical optimization problem in the following canonical way: First we state
the objective function to be maximized (or minimized), then we list all the constraints, and lastly we list all the decision

1.2. A Production Planning Problem 5


Hands-On Mathematical Optimization with AMPL in Python

variables and their bounds:


maximize 270𝑦𝑈 + 210𝑦𝑉 − 10𝑥𝑀 − 50𝑥𝐴 − 40𝑥𝐵 (1.1)
subject to 10𝑦𝑈 + 9𝑦𝑉 ≤ 𝑥𝑀
2𝑦𝑈 + 1𝑦𝑉 ≤ 𝑥𝐴
1𝑦𝑈 + 1𝑦𝑉 ≤ 𝑥𝐵
0 ≤ 𝑥𝑀
0 ≤ 𝑥𝐴 ≤ 80
0 ≤ 𝑥𝐵 ≤ 100
0 ≤ 𝑦𝑈 ≤ 40
0 ≤ 𝑦𝑉 .

This completes the mathematical description of this example of a production planning problem.
An optimal solution of this problem is any assignment of values to the decision variables that meets the constraints and
that achieves the maximum/minimum objective.
However, even for a simple problem like this one, it is not immediately clear what the optimal solution is. This is exactly
where mathematical optimization algorithms come into play. They are generic procedures that can find the optimal
solutions of problems as long as these problems can be formulated in a standardized fashion as above.
For a practitioner, mathematical optimization often boils down to formulating the problem as a model above, then passing
it over to one of the many software packages that can solve such a model regardless of what was the original story behind
the model. To do so, we need an interface of communication between the models and the algorithms. In this book, we
adopt an interface based on the AMPL modeling language and the Python programming language. AMPL is based on
the same mathematical formulation that practitioners use, but is standardized so that it can be read and translated by a
computer system. Python provides access to powerful tools for processing data and presenting results.
Thus the next step is to create the corresponding AMPL model.

1.3 A Basic AMPL Model

AMPL is an algebraic modeling language for mathematical optimization that integrates with the Python programming
environment. It enables users to define optimization models consisting of decision variables, objective functions, and
constraints, and to compute solutions using a variety of open-source and commercial solvers.
This notebook introduces basic concepts of AMPL needed to formulate and solve the production planning problem
introduced in a companion notebook:
• Variables
• Objectives
• Constraints
• Solving
• Reporting the solution
The AMPL model shown in this notebook is a direct translation of the mathematical model into basic AMPL components.
In this approach, parameter values from the mathematical model are included directly in the AMPL model for simplicity.
This method works well for problems with a small number of decision variables and constraints, but it limits the reuse
of the model. Another notebook will demonstrate AMPL features for writing models for more generic, “data-driven”
applications.

1.3. A Basic AMPL Model 6


Hands-On Mathematical Optimization with AMPL in Python

1.3.1 Preliminary Step: Install AMPL and Python tools

We start by installing amplpy, the application programming interface (or API) that integrates the AMPL modeling lan-
guage with the Python programming language. Also we install two Python utilities, matplotlib and pandas, that will be
used in the parts of this notebook that display results.
These installations need to be done only once for each Python environment on a personal computer. A new installation
must be done for each new Colab session, however.

# install dependencies
%pip install -q amplpy matplotlib pandas

1.3.2 Step 1. Import AMPL

The first step for a new AMPL model is to import the needed components into the AMPL environment. The Python code
shown below is used at the beginning of every notebook. There are just two elements that may vary according to your
needs:
• The modules line lists the solvers that you intend to use. In the present notebook we list "cbc" and "highs"
to request the free CBC and HiGHS solvers.
• The license_uuid is a code that determines your permissions to use AMPL and commercial solvers. Here
the UUID is set to "default" which gets you full-featured versions of AMPL and most popular solvers, limited
only by the size of the problems that can be solved; this is sufficient for all of our small examples.
If you have obtained a different license for AMPL and one or more solvers, you can get its UUID from your account on
the AMPL Portal. If you are using these notebooks in a course, your instructor may give you a UUID to use.
This step also creates a new object, named ampl, that will be referenced when you want to refer to various components
and methods of the AMPL system within Python statements.

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["cbc", "highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

1.3.3 Step 2. Decision variables

To define the decision variables of your model, you use AMPL var statements. Our basic production planning model
has 5 decision variables, so they can be defined by 5 var statements.
Each statement starts with the keyword var and a unique name for the variable. Then lower and upper bounds for the
variable are specified by >= and <= phrases. Bounds are optional; in this model, >= 0 is specified for all 5 variables, but
some of them have no specified upper bound.
The %%ampl_eval line at the beginning of this cell tells Python to send all of the statements in the cell to AMPL. To
add an AMPL comment line, put a # character at the beginning of the line.

1.3. A Basic AMPL Model 7


Hands-On Mathematical Optimization with AMPL in Python

%%ampl_eval
# define decision variables

reset;

var xM >= 0;
var xA >= 0, <= 80;
var xB >= 0, <= 100;

var yU >= 0, <= 40;


var yV >= 0;

1.3.4 Step 3. Objective

Since this is a maximization problem, an AMPL maximize statement specifies the objective function. (If it were a
minimization, a minimize statement would be used instead.)
The maximize keyword is followed by a name for the objective — we can call it Profit — and a colon (:) char-
acter. Then the expression for the objective function is written in AMPL just the same as we previously wrote it in the
mathematical formulation.
Notice that all AMPL statements are ended with a semicolon (;) character. A long statement can be spread over more
than one line, as in the case of our maximize statement for this model.

%%ampl_eval
# define objective function

maximize Profit:
270*yU + 210*yV - 10*xM - 50*xA - 40*xB;

1.3.5 Step 4. Constraints

AMPL specifies constraints in much the same way as the objective, with just a few differences:
• An AMPL constraint definition begins with the keywords subject to.
• The expression for a constraint contains a less-than (<=), greater-than (>=), or equals (=) operator.
Just as for the objective, each constraint has a name, and the expressions for the three constraints are the same as in the
mathematical formulation.
(You can abbreviate subject to as subj to or s.t. Or you can leave it out entirely; a statement that does not
begin with a keyword is assumed by AMPL to be a constraint definition.)

%%ampl_eval
# define constraints

subj to raw_materials: 10*yU + 9*yV <= xM;


subj to labor_A: 2*yU + 1*yV <= xA;
subj to labor_B: 1*yU + 1*yV <= xB;

1.3. A Basic AMPL Model 8


Hands-On Mathematical Optimization with AMPL in Python

1.3.6 Step 5. Solving

With the model now fully specified, the next step is to compute optimal values for the decision variables. From this point
onward, our cells will contain Python statements and function calls, including calls to amplpy functions that manage the
integration of AMPL and Python.
There are many ways to assign values to the variables, so that all of the bounds and all of the constraints are satisfied.
Each of these ways gives a feasible solution for our model. A solution is optimal if it gives the highest possible value of
the objective function among all the feasible solutions.
To compute an optimal solution, we call a solver: a separate program that applies numerical algorithms to determine
optimal values for the variables. There are just a few steps:
• The AMPL commands show and expand display the model components and expressions, so that you can check
that they have been specified correctly. We use the amplpy.AMPL.eval function to run these commands in
AMPL.
• The amplpy.AMPL.option attribute sets the "solver" option to one of the solvers that we loaded in Step
2.
• The amplpy.AMPL.solve function invokes the chosen solver. AMPL takes care of converting the model to
the form that the solver requires for its computation, and converting the results back to a form that can be used in
Python.
To show how different solvers can be tested, we conclude by setting a different solver and solving again. As expected, the
two solvers report the same optimal objective value, 2400.

# exhibit the model that has been built


ampl.eval("show;")
ampl.eval("expand;")

# solve using two different solvers


ampl.option["solver"] = "cbc"
ampl.solve()

ampl.option["solver"] = "highs"
ampl.solve()

variables: xA xB xM yU yV

constraints: labor_A labor_B raw_materials

objective: Profit
maximize Profit:
-10*xM - 50*xA - 40*xB + 270*yU + 210*yV;

subject to raw_materials:
-xM + 10*yU + 9*yV <= 0;

subject to labor_A:
-xA + 2*yU + yV <= 0;

subject to labor_B:
-xB + yU + yV <= 0;

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 2400


0 simplex iterations
HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 2400
(continues on next page)

1.3. A Basic AMPL Model 9


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


0 simplex iterations
0 barrier iterations

1.3.7 Step 6. Reporting the solution

The final step in most applications is to report the solution in a suitable format. For this example, we demonstrate simple
tabular and graphic reports using the Pandas library. For an overview of other ways to report and visualize the solutions,
see also the appendix of the gasoline-distribution notebook.

Accessing solution values with display

The amplpy ampl.display function shows the values of one or more AMPL expressions, computed from the current
values of the variables. You can also apply this function to special expresions such as _var for the values of all the
variables, and _varname for the names of all the variables.

# display a component of the model


ampl.display("Profit")
ampl.display("270*yU + 210*yV", "10*xM + 50*xA + 40*xB")

ampl.display("_varname", "_var")

Profit = 2400

270*yU + 210*yV = 16800


10*xM + 50*xA + 40*xB = 14400

: _varname _var :=
1 xM 720
2 xA 80
3 xB 80
4 yU 0
5 yV 80
;

Accessing solution values with get_value

After an optimal solution has been successfully computed, the value of an AMPL expression can be retrieved in Python
by use of the ampl.get_value function. When combined with Python f strings, ampl.get_value provides a
convenient means of creating formatted reports.

print(f" Profit = {ampl.get_value('Profit'): 9.2f}")


print(f"Revenue = {ampl.get_value('270*yU + 210*yV'): 9.2f}")
print(f" Cost = {ampl.get_value('10*xM + 50*xA + 40*xB'): 9.2f}")

Profit = 2400.00
Revenue = 16800.00
Cost = 14400.00

1.3. A Basic AMPL Model 10


Hands-On Mathematical Optimization with AMPL in Python

Creating reports with Pandas and Matplotlib

Pandas, a freely available library for working with data in Python, is widely used in the data science community. Here
we use a Pandas Series() object to hold and display solution data. We can then visualize the data using the matplotlib
library, for instance with a bar chart.

import pandas as pd

# create pandas series for production and raw materials


production = pd.Series(
{
"U": ampl.get_value("yU"),
"V": ampl.get_value("yV"),
}
)

raw_materials = pd.Series(
{
"A": ampl.get_value("xA"),
"B": ampl.get_value("xB"),
"M": ampl.get_value("xM"),
}
)

# display pandas series


display(production)
display(raw_materials)

U 0.0
V 80.0
dtype: float64

A 80.0
B 80.0
M 720.0
dtype: float64

import matplotlib.pyplot as plt

# create grid of subplots


fig, ax = plt.subplots(1, 2, figsize=(8, 2))

# show pandas series as horizontal bar plots


production.plot(ax=ax[0], kind="barh", title="Production")
raw_materials.plot(ax=ax[1], kind="barh", title="Raw Materials")

# show vertical axis in descending order


ax[0].invert_yaxis()
ax[1].invert_yaxis()

1.3. A Basic AMPL Model 11


Hands-On Mathematical Optimization with AMPL in Python

Appendix???

Mention .mod file (model isolation) and pure Python execution?

%%writefile production_planning_basic.mod

# decision variables
var x_M >= 0;
var x_A >= 0, <= 80;
var x_B >= 0, <= 100;

var y_U >= 0, <= 40;


var y_V >= 0;

# auxiliary variables
var revenue = 270 * y_U + 210 * y_V;
var cost = 10 * x_M + 50 * x_A + 40 * x_B;

# objective
maximize profit: revenue - cost;

# constraints
s.t. raw_materials: 10 * y_U + 9 * y_V <= x_M;
s.t. labor_A: 2 * y_U + 1 * y_V <= x_A;
s.t. labor_B: 1 * y_U + 1 * y_V <= x_B;

Overwriting production_planning_basic.mod

# Create AMPL instance and load the model


ampl = AMPL()
ampl.read("production_planning_basic.mod")

# Select a solver and solve the problem


ampl.option["solver"] = SOLVER
ampl.solve()

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 2400


0 simplex iterations

1.3. A Basic AMPL Model 12


Hands-On Mathematical Optimization with AMPL in Python

1.4 A Data-Driven AMPL Model

In this notebook, we’ll revisit the production planning example. However, this time we’ll demonstrate how Python’s data
structures combine with AMPL’s ability to separate model and data, to create an optimization model that scales with the
size of the data tables. This enables the model to adjust to new products, varying prices, or changing demand. We refer
to this as “data-driven” modeling.
This notebook introduces two new AMPL model components that describe the data in a general way:
• Sets
• Parameters
These components enable the model to specify variables, constraints, and summations that are indexed over sets. The
combination of sets and indices is essential to building scalable and maintainable models for more complex applications.
We will begin this analysis by examining the production planning data sets to identify the underlying problem structure.
Then we will reformulate the mathematical model in a more general way that is valid for any data scenario. Finally
we show how the same formulation carries over naturally into AMPL, providing a clear, data-driven formulation of the
production planning application.

# install dependencies and select solver


%pip install -q amplpy pandas

1.4.1 Data representations

We begin by revisiting the data tables and mathematical model developed for the basic production planning problem
presented in the previous notebook. The original data values were given as follows:

Product Material required Labor A required Labor B required Market Demand Price
U 10 g 1 hr 2 hr ≤ 40 units $270
V 9g 1 hr 1 hr unlimited $210

Resource Amount Available Cost


M unlimited $10 / g
A 80 hours $50 / hour
B 100 hours $40 / hour

Two distinct sets of objects are evident from these tables. The first is the set of products, comprising 𝑈 and 𝑉 . The
second is the set of resources used to produce those products, comprising raw materials and two labor types, which we
have abbreviated as 𝑀 , 𝐴, and 𝐵.
Having identified these sets, the data for this application be factored into three simple tables. The first two tables list
attributes of the products and attributes of the resources. The third table summarizes the processes used to create the
products from the resources, which requires providing a value for each combination of product and resource:
Table: Products

Product Demand Price


U ≤ 40 units $270
V unlimited $210

Table: Resources

1.4. A Data-Driven AMPL Model 13


Hands-On Mathematical Optimization with AMPL in Python

Resource Available Cost


M ? $10 / g
A 80 hours $50 / hour
B 100 hours $40 / hour

Table: Processes

Product M A B
U 10 g 1 hr 2 hr
V 9g 1 hr 1 hr

How does a Python-based AMPL application work with this data? We can think of the data as being handled in three
steps:
1. Import the data into Python, in whatever form is convenient for the application.
2. Convert the data to the forms required by the optimization model.
3. Send the data to AMPL.
For our example, we implement step 1 by use of Python nested dictionaries that closely resemble the above three tables:
• In the products data structure, the product abbreviations serve as keys for outermost dictionary, and the product-
related attribute names (demand and price) as keys for the inner dictionaries.
• In the resources data structure, the resource abbreviations serve as keys for outermost dictionary, and the
resource-related attribute names (available and cost) as keys for the inner dictionaries.
• In the processes data structure, there is a value corresponding to each combination of a product and a resource;
the product abbreviations serve as keys for outermost dictionary, and resource abbreviations as keys for the inner
dictionaries.
Where demand or availability is “unlimited”, we use an expression that Python interprets as an infinite value.
You will see a variety of data representation in this book, chosen in each case to be most efficient and convenient for the
application at hand. Some will use Python packages, particularly numpy and pandas, that are designed for large-scale data
handling.

Inf = float("inf")

products = {
"U": {"demand": 40, "price": 270},
"V": {"demand": Inf, "price": 210},
}

resources = {
"M": {"available": Inf, "cost": 10},
"A": {"available": 80, "cost": 50},
"B": {"available": 100, "cost": 40},
}

processes = {
"U": {"M": 10, "A": 2, "B": 1},
"V": {"M": 9, "A": 1, "B": 1},
}

1.4. A Data-Driven AMPL Model 14


Hands-On Mathematical Optimization with AMPL in Python

1.4.2 Mathematical model

Once the problem data is rearranged into tables like this, the structure of the production planning problem becomes
evident. Along with the two sets, we have a variety of symbolic parameters that specify the model’s costs, limits, and
processes in a general way.Compared to the previous notebook, these abstractions allow us to create mathematical models
that can adapt and scale with the supplied data.
Let 𝑃 and 𝑅 be the set of products and resources, respectively, and let 𝑝 and 𝑟 be representative elements of those sets.
We use indexed decision variables 𝑥𝑟 to denote the amount of resource 𝑟 that is consumed in production, and 𝑦𝑝 to denote
the amount of product 𝑝 produced.
The model specifies lower and upper bounds on the values of the variables. We represent these as

0 ≤ 𝑥𝑟 ≤ 𝑏𝑟𝑥 ∀𝑟 ∈ 𝑅
0 ≤ 𝑦𝑝 ≤ 𝑏𝑝𝑦 ∀𝑝 ∈ 𝑃

where the upper bounds, 𝑏𝑟𝑥 and 𝑏𝑝𝑦 , are data taken from the tables of attributes.
The objective is given as before,

profit = revenue − cost

but now the expressions for revenue and cost are expressed more generally as sums over the product and resource sets,

revenue = ∑ 𝑐𝑝𝑦 𝑦𝑝
𝑝∈𝑃

cost = ∑ 𝑐𝑟𝑥 𝑥𝑟
𝑟∈𝑅

where parameters 𝑐𝑝𝑦 and 𝑐𝑟𝑥 represent the selling prices for products and the costs for resources, respectively. The limits
on available resources can be written as

∑ 𝑎𝑟𝑝 𝑦𝑝 ≤ 𝑥𝑟 ∀𝑟 ∈ 𝑅
𝑝∈𝑃

where 𝑎𝑟𝑝 is the amount of resource 𝑟 needed to make 1 unit of product 𝑝. Putting these pieces together, we have the
following symbolic model for the production planning problem.

maximize ∑ 𝑐𝑝𝑦 𝑦𝑝 − ∑ 𝑐𝑟𝑥 𝑥𝑟 (1.2)


𝑝∈𝑃 𝑟∈𝑅

subject to ∑ 𝑎𝑟𝑝 𝑦𝑝 ≤ 𝑥𝑟 ∀𝑟 ∈ 𝑅
𝑝∈𝑃

0 ≤ 𝑥𝑟 ≤ 𝑏𝑟𝑥 ∀𝑟 ∈ 𝑅
0 ≤ 𝑦𝑝 ≤ 𝑏𝑝𝑦 ∀𝑝 ∈ 𝑃
(1.3)

When formulated this way, the model can be applied to any problem with the same structure, regardless of the number
of products or resources. This flexibility is possible due to the use of sets to describe the products and resources for a
particular problem instance, indices like 𝑝 and 𝑟 to refer to elements of those sets, and data tables that hold the relevant
parameter values.
Generalizing mathematical models in this fashion is a feature of all large-scale optimization applications. Next we will
see how this type of generalization carries over naturally into formulating and solving the model in AMPL.

1.4. A Data-Driven AMPL Model 15


Hands-On Mathematical Optimization with AMPL in Python

1.4.3 The production model in AMPL

As before, we begin the construction of an AMPL model by importing the needed components into the AMPL environ-
ment.

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Next we use AMPL set statements to define the product and resource sets. Notice that at this point, we are only telling
AMPL about the two sets will be used in the model. The members of these sets will be sent from Python to AMPL later,
as part of the problem data.
In mathematical formulations, it is customary to keep the names of all components short. But when writing the model
in AMPL, we are free to use longer, more meaningful names that make the model statements easier to read. Thus, for
example, here we use PRODUCTS and RESOURCES as the AMPL names of the sets that are are called 𝑃 and 𝑅 in the
mathematical model.

%%ampl_eval
# define sets

set PRODUCTS;
set RESOURCES;

The next step is to introduce parameters that will be used as data in the objective function and the constraints.
A statement that defines and AMPL parameter begin with the param keyword and a unique name. Then between braces
{ and } it specifies the index sets for the parameter. For example:
• param demand {PRODUCTS} >= 0; states that there is a “product demanded” value for each member of
the set PRODUCTS.
• param need {RESOURCES,PRODUCTS} >= 0; states that there is a “resource needed” value for each
combination of a resource and a product.
At the end of each param statement, we specify that the values for the parameter must be nonnegative or positive, as
appropriate. These specifications will be used to later to check that the actual data values are appropriate for the problem.
There are 5 different param statements in all, corresponding to the 5 different kinds of data in tables, and the 5 different
symbolic parameters 𝑏𝑝𝑦 , 𝑐𝑝𝑦 , 𝑏𝑟𝑥 , 𝑐𝑟𝑥 , and 𝑎𝑟𝑝 in the mathematical model.

%%ampl_eval
# define parameters

param demand {PRODUCTS} >= 0;


param price {PRODUCTS} > 0;

param available {RESOURCES} >= 0;


param cost {RESOURCES} > 0;

param need {RESOURCES,PRODUCTS} >= 0;

AMPL defines the decision variables in much the same way as the parameters, but with var as the keyword starting the
statement. We name the variables Use for resource use, and Sell for product sales.
To express the bounds on the variables in the same way as the mathematical formulation, a more general form of the
AMPL statement is needed. In the case of the Use variables, for example:

1.4. A Data-Driven AMPL Model 16


Hands-On Mathematical Optimization with AMPL in Python

• The indexing expression is written {r in RESOURCES} to say that there is a variable for each member of the
resource set, and also to associate the index r with members of the set for purpose of this statement. This is the
AMPL equivalent of ∀𝑟 ∈ 𝑅 in the mathematical statement.
• The upper bound is written <= available[r] to say that for each member r of the resource set, the variable’s
upper bound is given by the corresponding value from the availability table. This is the AMPL equivalent of ≤ 𝑏𝑟𝑥
in the mathematical statement.
An expression in brackets [...] is called an AMPL subscript because it plays the same role as a mathematical subscript
like 𝑟 in ≤ 𝑏𝑟𝑥 . Anywhere that the model refers to particular values of an indexed parameter or variables, you will see
subscript expressions. For example,
• need[r,p] will be the amount of resource r needed to make one unit of product p.
• Use[r] will be the total amount of resource r used.

%%ampl_eval
# define variables

var Use {r in RESOURCES} >= 0, <= available[r];


var Sell {p in PRODUCTS} >= 0, <= demand[p];

Just as in the previous notebook, the AMPL statement for the objective function begins with maximize Profit. But
now, as in the mathematical formulation, AMPL uses general summation expressions:
• sum {p in PRODUCTS} price[p] * Sell[p] is the sum, over all products, of the price per unit time
the number sold. It corresponds to ∑𝑝∈𝑃 𝑐𝑝𝑦 𝑦𝑝 in the mathematical formulation.
• sum {r in RESOURCES} cost[r] * Use[r] is the sum, over all resources, of the cost per unit time
the amount used. It corresponds to ∑𝑟∈𝑅 𝑐𝑟𝑥 𝑥𝑟 in the mathematical formulation.
The full expression for the objective function is simply the first of these expressions minus the second one.

%%ampl_eval
# define objective function

maximize Profit:
sum {p in PRODUCTS} price[p] * Sell[p] -
sum {r in RESOURCES} cost[r] * Use[r];

The previous AMPL model had 3 constraints, each defined by a subject to statement. But the data-driven mathe-
matical formulation recognizes that there is only one different kind of constraint — resources needed must be less than
or equal to resources used — repeated 3 times, once for each resource. The AMPL version combines expressions that
have already appeared in earlier parts of the model:
• subject to ResourceLimit {r in RESOURCES} says that the model will have one constraint corre-
sponding to each member r of the resource set.
• sum {p in PRODUCTS} need[r,p] * Sell[p] <= Use[r] says that the total of resource r needed,
summed over all produces sold, must be <= the total of resource r used. This corresponds to ∑𝑝∈𝑃 𝑎𝑟𝑝 𝑦𝑝 ≤ 𝑥𝑟
in the mathematical formulation.

%%ampl_eval
# create indexed constraint

subject to ResourceLimit {r in RESOURCES}:


sum {p in PRODUCTS} need[r,p] * Sell[p] <= Use[r];

1.4. A Data-Driven AMPL Model 17


Hands-On Mathematical Optimization with AMPL in Python

1.4.4 The production data in AMPL

Now that the AMPL model is defined, we can carry out step 2 of data handling, which is to convert the data to the forms
that the model requires:
• For the two sets, Python lists of the set members.
• For the two parameters indexed over products, Python dictionaries whose keys are the product names.
• For the two parameters indexed over resources, Python dictionaries whose keys are the resource names.
• For the parameter indexed over resource-product pairs, a Python dictionary whose keys are tuples consisting of a
a resource and a product.
Using Python’s powerful expression forms, all of these lists and dictionaries are readily extracted from the nested dictio-
naries that our application set up in step 1. To avoid having too many different names, we assign each list and dictionary
to a Python program variable that has the same name as the corresponding AMPL set or parameter:

# set data
PRODUCTS = products.keys()
RESOURCES = resources.keys()

# product data
demand = {k: v["demand"] for k, v in products.items()}
price = {k: v["price"] for k, v in products.items()}

# resource data
available = {k: v["available"] for k, v in resources.items()}
cost = {k: v["cost"] for k, v in resources.items()}

need = {(r, p): value for p in processes.keys() for r, value in processes[p].items()}

print(PRODUCTS, RESOURCES)
print(demand, price)
print(available, cost)
print(need)

dict_keys(['U', 'V']) dict_keys(['M', 'A', 'B'])


{'U': 40, 'V': inf} {'U': 270, 'V': 210}
{'M': inf, 'A': 80, 'B': 100} {'M': 10, 'A': 50, 'B': 40}
{('M', 'U'): 10, ('A', 'U'): 2, ('B', 'U'): 1, ('M', 'V'): 9, ('A', 'V'): 1, ('B', 'V
↪'): 1}

1.4.5 Solving the production problem

Now the Python data can be sent to AMPL, and AMPL can invoke a solver. For this simple model, we can make the
Python data correspond exactly to the AMPL data, and thus the statements for sending the data to AMPL are particularly
easy to write.
The statements for selecting a solver and for initiating the solver process are the same as we used with the basic production
planning example. When the solver is finished, it displays a few lines of output to confirm that a solution has been found.

# load set data


ampl.set["PRODUCTS"] = PRODUCTS
ampl.set["RESOURCES"] = RESOURCES

# load parameter data


(continues on next page)

1.4. A Data-Driven AMPL Model 18


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


ampl.param["price"] = price
ampl.param["demand"] = demand
ampl.param["cost"] = cost
ampl.param["available"] = available
ampl.param["need"] = need

# set solver and solve


ampl.option["solver"] = "highs"
ampl.solve()

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 2400


2 simplex iterations
0 barrier iterations

1.4.6 Reporting the results

It remains to retrieve the solution from AMPL, after which Python’s extensive features and ecosystem can be used to
present the results in any way desired. For this first example we use one of the simplest Python features, the print
statement.
An AMPL entity is referenced in Python code via its name in the AMPL model. For example, the objective function
Profit is ampl.obj['Profit'], and the collection of Sell variables is ampl.var['Sell'].
For an entity that is not indexed, the value() method returns the associated value. Thus the first print statement refers
to ampl.obj['Profit'].value().
For an indexed entity, we use the to_dict() method to return the values in a Python dictionary, with the set members
as keys. Then a for loop can use the items() method to iterate over the dictionary and print a line for each member.

# create a solution report


print(f"Profit = {ampl.obj['Profit'].value()}")

print("\nProduction Report")
for product, Sell in ampl.var["Sell"].to_dict().items():
print(f" {product} produced = {Sell}")

print("\nResource Report")
for resource, Use in ampl.var["Use"].to_dict().items():
print(f" {resource} consumed = {Use}")

Profit = 2400.0

Production Report
U produced = 0
V produced = 80

Resource Report
A consumed = 80
B consumed = 80
M consumed = 720

1.4. A Data-Driven AMPL Model 19


Hands-On Mathematical Optimization with AMPL in Python

1.4.7 For Python experts: Creating subclasses of AMPL

Some readers of these notebooks may be more experienced Python developers who wish to apply AMPL in more special-
ized, data driven applications. The following cell shows how the AMPL class can be extended to create specialized model
classes. Here we create a subclass called ProductionModel that accepts a particular representation of the problem
data to produce a production model object. The production model object inherits all of the methods associated with any
AMPL model, such as .display() and .solve(), but can be extended with additional methods.
%%writefile production_planning.mod

# define sets
set PRODUCTS;
set RESOURCES;

# define parameters
param demand {PRODUCTS} >= 0;
param price {PRODUCTS} > 0;
param available {RESOURCES} >= 0;
param cost {RESOURCES} > 0;
param need {RESOURCES,PRODUCTS} >= 0;

# define variables
var Use {r in RESOURCES} >= 0, <= available[r];
var Sell {p in PRODUCTS} >= 0, <= demand[p];

# define objective function


maximize Profit:
sum {p in PRODUCTS} price[p] * Sell[p] -
sum {r in RESOURCES} cost[r] * Use[r];

# create indexed constraint


subject to ResourceLimit {r in RESOURCES}:
sum {p in PRODUCTS} need[r,p] * Sell[p] <= Use[r];

Overwriting production_planning.mod

import pandas as pd

class ProductionModel(AMPL):
"""
A class representing a production model using AMPL.
"""

def __init__(self, products, resources, processes):


"""
Initialize ProductionModel as an AMPL instance.

:param products: A dictionary containing product information.


:param resources: A dictionary containing resource information.
:param processes: A dictionary containing process information.
"""
super(ProductionModel, self).__init__()

# save data in the model instance


self.products = products
self.resources = resources
(continues on next page)

1.4. A Data-Driven AMPL Model 20


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


self.processes = processes

# flag to monitor solution status


self.solved = False

def load_data(self):
"""
Prepare the data and pass the information to AMPL.
"""
# convert the data dictionaries into pandas data frames
products = pd.DataFrame(self.products).T
resources = pd.DataFrame(self.resources).T
processes = pd.DataFrame(self.processes).T

# display the generated data frames


display(products)
display(resources)
display(processes)

# pass data to AMPL


self.set_data(products, "PRODUCTS")
self.set_data(resources, "RESOURCES")
self.param["need"] = processes.T

def solve(self, solver="highs"):


"""
Read the model, load the data, set the solver and solve the optimization␣
↪problem.

"""
self.read("production_planning.mod")
self.load_data()
self.option["solver"] = solver
super(ProductionModel, self).solve()
self.solved = True

def report(self):
"""
Solve, if necessary, then report the model solution.
"""
if not self.solved:
self.solve()

print(f"Profit = {self.obj['Profit'].value()}")

print("\nProduction Report")
Sell = self.var["Sell"].to_pandas()
Sell.rename(columns={Sell.columns[0]: "produced"}, inplace=True)
Sell.index.rename("PRODUCTS", inplace=True)
display(Sell)

print("\nResource Report")
Use = self.var["Use"].to_pandas()
Use.rename(columns={Use.columns[0]: "consumed"}, inplace=True)
Use.index.rename("RESOURCES", inplace=True)
display(Use)

(continues on next page)

1.4. A Data-Driven AMPL Model 21


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


m = ProductionModel(products, resources, processes)
m.report()

demand price
U 40.0 270.0
V inf 210.0

available cost
M inf 10.0
A 80.0 50.0
B 100.0 40.0

M A B
U 10 2 1
V 9 1 1

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 2400


2 simplex iterations
0 barrier iterations
Profit = 2400.0

Production Report

produced
PRODUCTS
U 0
V 80

Resource Report

consumed
RESOURCES
A 80
B 80
M 720

1.4. A Data-Driven AMPL Model 22


CHAPTER

TWO

2. LINEAR OPTIMIZATION

The simplest and most scalable class of optimization problems is the one where the objective function and the constraints
are formulated using the simplest possible type of functions – linear functions. A linear optimization (LO) is an opti-
mization problem of the form

min 𝑐⊤ 𝑥
s.t. 𝐴𝑥 ≤ 𝑏
𝑥 ≥ 0,

where the 𝑛 (decision) variables are grouped in a vector 𝑥 ∈ ℝ𝑛 , 𝑐 ∈ ℝ𝑛 are the objective coefficients, and the 𝑚 linear
constraints are described by the matrix 𝐴 ∈ ℝ𝑚×𝑛 and the vector 𝑏 ∈ ℝ𝑚 .
Linear problems can (i) be maximization problems, (ii) involve equality constraints and constraints of the form ≥, and
(iii) have unbounded or non-positive decision variables 𝑥𝑖 ’s. In fact, any LO problem with such features can be easily
converted to the “canonical” LO form above by adding/removing variables and/or multiplying specific inequalities by −1.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of LOs:
• A first LO example, modelling the microchip production problem of company BIM
• Least Absolute Deviation (LAD) Regression
• Mean Absolute Deviation (MAD) portfolio optimization
• Wine quality prediction problem using L1 regression
• A variant of BIM problem: maximizing the lowest possible profit
• Two variants of the BIM problem using fractional objective or additional fixed costs
• The BIM production problem using demand forecasts
• Extra material: Multi-product facility production
Go to the next chapter about mixed-integer linear optimization.

2.1 BIM production

# install dependencies and select solver


%pip install -q amplpy matplotlib pandas

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook


(continues on next page)

23
Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

2.1.1 General LO formulation

The simplest and most scalable class of optimization problems is the one where the objective function and the constraints
are formulated using the simplest possible type of functions - linear functions. A linear optimization (LO) is a problem
of the form
min 𝑐⊤ 𝑥
s.t. 𝐴𝑥 ≤ 𝑏
𝑥 ≥ 0,

where the 𝑛 (decision) variables are grouped in a vector 𝑥 ∈ ℝ𝑛 , 𝑐 ∈ ℝ𝑛 are the objective coefficients, and the 𝑚 linear
constraints are described by the matrix 𝐴 ∈ ℝ𝑚×𝑛 and the vector 𝑏 ∈ ℝ𝑚 .
Of course, linear problems could also (i) be maximization problems, (ii) involve equality constraints and constraints of
the form ≥, and (iii) have unbounded or non-positive decision variables 𝑥𝑖 ’s. In fact, any LP problem with such features
can be easily converted to the ‘canonical’ LP form by adding/removing variables and/or multiplying specific inequalities
by −1.

2.1.2 The microchip production problem

The company BIM (Best International Machines) produces two types of microchips, logic chips (1 g silicon, 1 g plastic, 4
g copper) and memory chips (1 g germanium, 1 g plastic, 2 g copper). Each of the logic chips can be sold for a 12 € profit,
and each of the memory chips for a 9 € profit. The current stock of raw materials is as follows: 1000 g silicon, 1500 g
germanium, 1750 g plastic, 4800 g of copper. How many microchips of each type should be produced to maximize the
profit while respecting the raw material stock availability?
Let 𝑥1 denote the number of logic chips and 𝑥2 that of memory chips. This decision can be reformulated as an optimization
problem of the following form:

max 12𝑥1 + 9𝑥2 (2.1)


s.t. 𝑥1 ≤ 1000 (2.2)
silicon
𝑥2 ≤ 1500 germanium
(2.3)
𝑥1 + 𝑥2 ≤ 1750 plastic
(2.4)
4𝑥1 + 2𝑥2 ≤ 4800 copper
(2.5)
𝑥1 , 𝑥2(2.6)
≥0

The problem has 𝑛 = 2 decision variables and 𝑚 = 4 constraints. Using the standard notation introduced above, denote
𝑥
the vector of decision variables by 𝑥 = ( 1 ) and define the problem coefficients as
𝑥2

1 0 1000
12 ⎡0 1⎤ ⎛ 1500⎞
𝑐 = ( ), 𝐴=⎢ ⎥, and 𝑏=⎜

⎜1750⎟
⎟.

9 ⎢1 1⎥
⎣4 2⎦ ⎝ 4800 ⎠
This model can be implemented and solved in AMPL as follows.

2.1. BIM production 24


Hands-On Mathematical Optimization with AMPL in Python

%%ampl_eval

var x1 >= 0;
var x2 >= 0;

maximize profit: 12 * x1 + 9 * x2;

s.t. silicon: x1 <= 1000;


s.t. germanium: x2 <= 1500;
s.t. plastic: x1 + x2 <= 1750;
s.t. copper: 4 * x1 + 2 * x2 <= 4800;

ampl.option["solver"] = SOLVER
ampl.solve()

print(f'x = ({ampl.var["x1"].value():.1f}, {ampl.var["x2"].value():.1f})')


print(f'optimal value = {ampl.obj["profit"].value():.2f}')

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 17700


2 simplex iterations
0 barrier iterations
x = (650.0, 1100.0)
optimal value = 17700.00

2.1.3 Dual problem

One can construct bounds for the value of objective function by multiplying the constraints by non-negative numbers and
adding them to each other so that the left-hand side looks like the objective function, while the right-hand side is the
corresponding bound.
Let 𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 be non-negative numbers. If we multiply each of these variables by one of the four constraints of the
original problem and sum all of them side by side to obtain the inequality

(𝜆1 + 𝜆3 + 4𝜆4 )𝑥1 + (𝜆2 + 𝜆3 + 2𝜆4 )𝑥2 ≤ 1000𝜆1 + 1500𝜆2 + 1750𝜆3 + 4800𝜆4 .

It is clear that if 𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 ≥ 0 satisfy

𝜆1 + 𝜆3 + 4𝜆4 ≥ 12,
𝜆2 + 𝜆3 + 2𝜆4 ≥ 9,

then we have the following:

12𝑥1 + 9𝑥2 ≤ (𝜆1 + 𝜆3 + 4𝜆4 )𝑥1 + (𝜆2 + 𝜆3 + 2𝜆4 )𝑥2 ≤ 1000𝜆1 + 1500𝜆2 + 1750𝜆3 + 4800𝜆4 ,

where the first inequality follows from the fact that 𝑥1 , 𝑥2 ≥ 0, and the most right-hand expression becomes an upper
bound on the optimal value of the objective.
If we seek 𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 ≥ 0 such that the upper bound on the RHS is as tight as possible, that means that we need to
minimize the expression 1000𝜆1 + 1500𝜆2 + 1750𝜆3 + 4800𝜆4 . This can be formulated as the following LP, which we
name the dual problem:

min 1000𝜆1 + 1500𝜆2 + 1750𝜆3 + 4800𝜆4


s.t. 𝜆1 + 𝜆3 + 4𝜆4 ≥ 12,
𝜆2 + 𝜆3 + 2𝜆4 ≥ 9,
𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 ≥ 0.

2.1. BIM production 25


Hands-On Mathematical Optimization with AMPL in Python

It is easy to solve and find the optimal solution (𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 ) = (0, 0, 6, 1.5), for which the objective functions takes
the value 17700. Such a value is (the tightest) upper bound for the original problem. Here, we present the AMPL code
for this example.

%%writefile bim_dual.mod

var y1 >= 0;
var y2 >= 0;
var y3 >= 0;
var y4 >= 0;

minimize obj: 1000 * y1 + 1500 * y2 + 1750 * y3 + 4800 * y4;

s.t. x1: y1 + y3 + 4 * y4 >= 12;


s.t. x2: y2 + y3 + 2 * y4 >= 9;

Writing bim_dual.mod

ampl = AMPL()
ampl.read("bim_dual.mod")
ampl.option["solver"] = SOLVER
ampl.solve()

print(
f'y = ({ampl.var["y1"].value():.1f}, {ampl.var["y2"].value():.1f}, {ampl.var["y3
↪"].value():.1f}, {ampl.var["y4"].value():.1f})'

)
print(f'optimal value = {ampl.obj["obj"].value():.2f}')

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 17700


3 simplex iterations
0 barrier iterations
y = (0.0, 0.0, 6.0, 1.5)
optimal value = 17700.00

Note that since the original LP is feasible and bounded, strong duality holds and the optimal value of the primal problem
coincides with the optimal value of the dual problem.

2.2 LAD Regression

Linear regression is a supervised machine learning technique that produces a linear model predicting values of a dependent
variable from known values of one or more independent variables. Linear regression has a long history dating back to at
least the 19th century and is a mainstay of modern data analysis.
This notebook demonstrates a technique for linear regression based on LP that use the Least Absolute Deviation (LAD)
as the metric to quantify the goodness of the model prediction. The sum of absolute values of errors is the 𝐿1 norm which
is known to have favorable robustness characteristics in practical use. We follow closely this paper.

# install dependencies and select solver


%pip install -q amplpy numpy matplotlib scikit-learn ipywidgets

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook


(continues on next page)

2.2. LAD Regression 26


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Note: you may need to restart the kernel to use updated packages.

2.2.1 Generate data

The Python scikit learn library for machine learning provides a full-featured collection of tools for regression. The
following cell uses make_regression from scikit learn to generate a synthetic data set for use in subsequent cells.
The data consists of a numpy array y containing n_samples of one dependent variable 𝑦, and an array X containing
n_samples observations of n_features independent explanatory variables.

from sklearn.datasets import make_regression


import numpy as np

n_features = 1
n_samples = 1000
noise = 30

# generate regression dataset


np.random.seed(2020)
X, y = make_regression(n_samples=n_samples, n_features=n_features, noise=noise)

2.2.2 Data Visualization

Before going further, it is generally useful to prepare an initial visualization of the data. The following cell presents a
scatter plot of 𝑦 versus 𝑥 for the special case of one explanatory variable, and a histogram of the difference between 𝑦
and the mean value 𝑦.̄ This histogram will provide a reference against which to compare the residual error in 𝑦 after
regression.

import matplotlib.pyplot as plt

if n_features == 1:
plt.scatter(X, y, alpha=0.3)
plt.xlabel("X")
plt.ylabel("y")
plt.grid(True)

plt.figure()
plt.hist(y - np.mean(y), bins=int(np.sqrt(len(y))))
plt.title("histogram y - mean(y)")
plt.ylabel("counts")

2.2. LAD Regression 27


Hands-On Mathematical Optimization with AMPL in Python

2.2.3 Model

Suppose we have a finite dataset consisting of 𝑛 points {(𝑋 (𝑖) , 𝑦(𝑖) )}𝑖=1,…,𝑛 with 𝑋 (𝑖) ∈ ℝ𝑘 and 𝑦(𝑖) ∈ ℝ. A linear
regression model assumes the relationship between the vector of 𝑘 regressors 𝑋 and the dependent variable 𝑦 is linear.
This relationship is modeled through an error or deviation term 𝑒𝑖 , which quantifies how much each of the data points
diverge from the model prediction and is defined as follows:

𝑘
(𝑖)
𝑒𝑖 ∶= 𝑦(𝑖) − 𝑚⊤ 𝑋 (𝑖) − 𝑏 = 𝑦(𝑖) − ∑ 𝑋𝑗 𝑚𝑗 − 𝑏, (2.7)
𝑗=1

for some real numbers 𝑚1 , … , 𝑚𝑘 and 𝑏.


The Least Absolute Deviation (LAD) is a possible statistical optimality criterion for such a linear regression. Similar to
the well-known least-squares technique, it attempts to find a vector of linear coefficients 𝑚 = (𝑚1 , … , 𝑚𝑘 ) and intercept
𝑏 so that the model closely approximates the given set of data. The method minimizes the sum of absolute errors, that is
𝑛
∑𝑖=1 |𝑒𝑖 |.
The LAD regression is formulated as an optimization problem with the intercept 𝑏, the coefficients 𝑚𝑖 ’s, and the errors
𝑒𝑖 ’s as decision variables, namely
𝑛
min ∑ |𝑒𝑖 | (2.8)
𝑖=1

s.t. 𝑒𝑖 = 𝑦(𝑖) − 𝑚⊤ 𝑋 (𝑖) − 𝑏 ∀ 𝑖 = 1, …(2.9)


, 𝑛.

In general, the appearance of an absolute value term indicates the problem is nonlinear and, worse, that the objective
function is not differentiable when any 𝑒𝑖 = 0. However, for this case where the objective is to minimize a sum of
absolute errors, one can reformulate the decision variables to transform this into a linear problem. More specifically,
+
introducing for every term 𝑒𝑖 two new variables 𝑒−
𝑖 , 𝑒𝑖 ≥ 0, we can rewrite the model as

𝑛
min ∑(𝑒+ −
𝑖 + 𝑒𝑖 ) (2.10)
𝑖=1

s.t. 𝑒+ −
𝑖 − 𝑒𝑖 = 𝑦(𝑖) − 𝑚⊤ 𝑋 (𝑖) − 𝑏 ∀ 𝑖 = 1,(2.11)
…,𝑛
𝑒+ −
𝑖 , 𝑒𝑖 ≥ 0 ∀ 𝑖 = 1,(2.12)
…,𝑛

The following cell provides a direct implementation of LAD regression.

%%writefile lad_regression.mod

# indexing sets
set I;
set J;

# parameters
param y{I};
param X{I, J};

# variables
var ep{I} >= 0;
var em{I} >= 0;
var m{J} >= 0;
(continues on next page)

2.2. LAD Regression 28


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


var b;

# constraints
s.t. residuals {i in I}:
ep[i] - em[i] == y[i] - sum{j in J}(X[i, j] * m[j]) - b;

# objective
minimize sum_of_abs_errors: sum{i in I}(ep[i] + em[i]);

def lad_regression(X, y):


ampl = AMPL()
ampl.read("lad_regression.mod")

n, k = X.shape

# note use of Python style zero based indexing


ampl.set["I"] = range(n)
ampl.set["J"] = range(k)

ampl.param["y"] = y
ampl.param["X"] = X

ampl.option["solver"] = SOLVER
ampl.solve()

return ampl

m = lad_regression(X, y)
m.display("m")
m.display("b")

2.2.4 Visualizing the Results

m_m = list(m.var["m"].to_dict().values())
m_b = m.var["b"].value()

y_fit = np.array([sum(x[j] * v for j, v in enumerate(m_m)) + m_b for x in X])

if n_features == 1:
plt.scatter(X, y, alpha=0.3, label="data")
plt.plot(X, y_fit, "r", label="y_fit")
plt.xlabel("X")
plt.ylabel("y")
plt.grid(True)
plt.legend()

plt.figure()
plt.hist(y - np.mean(y), bins=int(np.sqrt(len(y))), alpha=0.5, label="y - mean(y)")
plt.hist(y - y_fit, bins=int(np.sqrt(len(y))), color="r", alpha=0.8, label="y - y_fit
↪")

plt.title("histogram of residuals")
plt.ylabel("counts")
plt.legend()

2.2. LAD Regression 29


Hands-On Mathematical Optimization with AMPL in Python

2.3 MAD portfolio optimization

Portfolio optimization and modern portfolio theory has a long and important history in finance and investment. The
principal idea is to find a blend of investments in financial securities that achieves an optimal trade-off between financial
risk and return. The introduction of modern portfolio theory is generally attributed to the 1952 doctoral thesis of Harry
Markowitz who subsequently was award a share of the 1990 Nobel Memorial Prize in Economics for his fundamental
contributions to this field. The well-known “Markowitz Model” models measure risk using covariance of the portfolio
with respect to constituent assets, then solves a minimum variance problem by quadratic optimization problem subject to
constraints to allocate of wealth among assets.
In a remarkable 1991 paper, Konno and Yamazaki proposed a different approach using the mean absolute deviation
(MAD) in portfolio return as a measure of financial risk. The proposed implementation directly incorporates historical
price data into a large scale linear optimization problem.

# install dependencies and select solver


%pip install -q amplpy numpy matplotlib scikit-learn yfinance

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

?25l ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/5.6 MB ? eta -:--:--


╸━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.1/5.6 MB 2.8 MB/s eta 0:00:02
━━━━━━╸━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.9/5.6 MB 13.8 MB/s eta 0:00:01
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸━━ 5.3/5.6 MB 51.9 MB/s eta 0:00:01
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 5.6/5.6 MB 53.3 MB/s eta 0:00:01
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.6/5.6 MB 39.7 MB/s eta 0:00:00
?25hUsing default Community Edition License for Colab. Get yours at: https://ampl.com/
↪ce

Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

2.3.1 Download historical stock data

The following cells download daily trading data for a group of the stocks from Yahoo Finance. The trading data is stored in
a designated sub-directory (default ./data/stocks/) as individual .csv files for each stock. Subsequent notebooks
can read and consolidate the stock price data.
Run the cells in the notebook once to create data sets for use by other notebook, or to refresh a previously stored set of
data. The function will overwrite any existing data sets.

2.3. MAD portfolio optimization 30


Hands-On Mathematical Optimization with AMPL in Python

Installing yfinance

The notebook uses the yfinance module to read data from Yahoo Finance. Web interfaces for financial services are
notoriously fickle and subject to change, and a particular issue with Google Colaboratory.

%pip install yfinance --upgrade -q

import matplotlib.dates as mdates


import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scipy.stats as stats
import datetime as datetime
import yfinance as yf

Stocks to download

Edit the following cell to download a list of stock symbols from Yahoo Finance, n_years to change the historical period,
or change the data directory. The first step in this analysis is to load and consolidate the asset price information into a
single DataFrame named assets. The consolidated price information consists of the adjusted closing price reported by
Yahoo Finance which includes adjustments for stock splits and dividend distributions.

# list of stock symbols


tickers = [
"AXP",
"AAPL",
"AMGN",
"BA",
"CAT",
"CRM",
"CSCO",
"CVX",
"DIS",
"DOW",
"GS",
"HD",
"IBM",
"INTC",
"JNJ",
"JPM",
"KO",
"MCD",
"MMM",
"MRK",
"MSFT",
"NKE",
"PG",
"TRV",
"UNH",
"V",
"VZ",
"WBA",
"WMT",
"XOM",
]
(continues on next page)

2.3. MAD portfolio optimization 31


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

# number of years
n_years = 3.0

# historical period
end_date = datetime.datetime.today().date()
start_date = end_date - datetime.timedelta(round(n_years * 365))

assets = yf.download(tickers, start=start_date, end=end_date)["Adj Close"]

assets.fillna(method="bfill", inplace=True)
assets.fillna(method="ffill", inplace=True)

assets.plot(logy=True, figsize=(12, 8), grid=True, lw=1, title="Adjusted Close")


plt.legend(bbox_to_anchor=(1.0, 1.0))

[*********************100%%**********************] 30 of 30 completed

<matplotlib.legend.Legend at 0x7e07143165c0>

2.3. MAD portfolio optimization 32


Hands-On Mathematical Optimization with AMPL in Python

2.3.2 Daily return and Mean Absolute Deviation of historical asset prices

Scaled asset prices

The historical prices are scaled to a value to have unit value at the start of the historical period. Scaling facilitates plotting
and subsequent calculations while preserving arithmetic and logarithmic returns needed for analysis.

# scaled asset prices


assets_scaled = assets.div(assets.iloc[0])
assets_scaled.plot(figsize=(12, 8), grid=True, lw=1, title="Adjusted Close: Scaled")
plt.legend(bbox_to_anchor=(1.0, 1.0))

<matplotlib.legend.Legend at 0x7e0714da3f10>

Statistics of daily returns

The scaled price of asset 𝑗 on trading day 𝑡 is designated 𝑆𝑗,𝑖 . The daily return is computed as

𝑆𝑗,𝑡 − 𝑆𝑗,𝑡−1
𝑟𝑗,𝑡 =
𝑆𝑗,𝑡−1

where 𝑡 = 1, … , 𝑇 . The mean return for asset 𝑗 is

1 𝑇
𝑟𝑗̄ = ∑𝑟
𝑇 𝑡=1 𝑗,𝑡

The following cells compute and display the daily returns for all assets and displays as time series and histograms.

2.3. MAD portfolio optimization 33


Hands-On Mathematical Optimization with AMPL in Python

# daily returns
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]

fig, ax = plt.subplots(6, 5, figsize=(12, 10), sharex=True, sharey=True)


for a, s in zip(ax.flatten(), sorted(daily_returns.columns)):
daily_returns[s].plot(ax=a, lw=1, title=s, grid=True)

plt.tight_layout()

# distributions of returns

daily_returns = assets.diff()[1:] / assets.shift(1)[1:]

fig, ax = plt.subplots(6, 5, figsize=(12, 10), sharex=True, sharey=True)


ax = ax.flatten()

for a, s in zip(ax.flatten(), daily_returns.columns):


daily_returns[s].hist(ax=a, lw=1, grid=True, bins=50)
mean_return = daily_returns[s].mean()
mean_absolute_deviation = abs((daily_returns[s] - mean_return)).mean()
a.set_title(f"{s} = {mean_return:0.5f}")
(continues on next page)

2.3. MAD portfolio optimization 34


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


a.set_xlim(-0.08, 0.08)
a.axvline(mean_return, color="r", linestyle="--")
a.axvline(mean_return + mean_absolute_deviation, color="g", linestyle="--")
a.axvline(mean_return - mean_absolute_deviation, color="g", linestyle="--")

plt.tight_layout()

Mean Absolute Deviation

The mean absolute deviation (MAD) for asset 𝑗 is

1 𝑇
MAD𝑗 = ∑ |𝑟 − 𝑟𝑗̄ |
𝑇 𝑡=1 𝑗,𝑡

where 𝑇 is the period under consideration. The mean daily return and the mean absolute deviation in daily return are
computed and plotted in the following cell. The side by side comparison provides a comparison of return vs volatility for
individual assets.

2.3. MAD portfolio optimization 35


Hands-On Mathematical Optimization with AMPL in Python

# bar charts of mean return and mean absolute deviation in returns

daily_returns = assets.diff()[1:] / assets.shift(1)[1:]


mean_return = daily_returns.mean()
mean_absolute_deviation = abs(daily_returns - mean_return).mean()

fig, ax = plt.subplots(1, 2, figsize=(12, 0.35 * len(daily_returns.columns)))


mean_return.plot(kind="barh", ax=ax[0], title="Mean Return")
ax[0].invert_yaxis()
mean_absolute_deviation.plot(kind="barh", ax=ax[1], title="Mean Absolute Deviation")
ax[1].invert_yaxis()

# plot return vs risk


daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
mean_return = daily_returns.mean()
mean_absolute_deviation = abs(daily_returns - mean_return).mean()

fig, ax = plt.subplots(1, 1, figsize=(10, 6))


for s in assets.keys():
(continues on next page)

2.3. MAD portfolio optimization 36


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


ax.plot(mean_absolute_deviation[s], mean_return[s], "s", ms=8)
ax.text(mean_absolute_deviation[s] * 1.03, mean_return[s], s)

ax.set_xlim(0, 1.1 * mean_absolute_deviation.max())


ax.axhline(0, color="r", linestyle="--")
ax.set_title("Daily return versus mean absolute deviaion")
ax.set_xlabel("Mean Absolute Deviation in Daily Returns")
ax.set_ylabel("Mean Daily Return")
ax.grid(True)

2.3.3 Analysis of a portfolio of assets

Return on a portfolio

Given a portfolio with value 𝑊𝑡 at time 𝑡, return on the portfolio at 𝑡𝑡+𝛿𝑡 is defined as

𝑊𝑡+𝛿𝑡 − 𝑊𝑡
𝑟𝑡+𝛿𝑡 =
𝑊𝑡

For the period from [𝑡, 𝑡 + 𝛿𝑡) we assume there are 𝑛𝑗,𝑡 shares of asset 𝑗 with a starting value of 𝑆𝑗,𝑡 per share. The initial
and final values of the portfolio are then
𝐽
𝑊𝑡 = ∑ 𝑛𝑗,𝑡 𝑆𝑗,𝑡
𝑗=1
𝐽
𝑊𝑡+𝛿𝑡 = ∑ 𝑛𝑗,𝑡 𝑆𝑗,𝑡+𝛿𝑡
𝑗=1

2.3. MAD portfolio optimization 37


Hands-On Mathematical Optimization with AMPL in Python

The return of the portfolio is given by

𝑊𝑡+𝛿𝑡 − 𝑊𝑡
𝑟𝑡+𝛿𝑡 =
𝑊𝑡
𝐽 𝐽
∑𝑗=1 𝑛𝑗,𝑡 𝑆𝑗,𝑡+𝛿𝑡 − ∑𝑗=1 𝑛𝑗,𝑡 𝑆𝑗,𝑡
=
𝑊𝑡
𝐽
∑𝑗=1 𝑛𝑗,𝑡 𝑆𝑗,𝑡 𝑟𝑗,𝑡+𝛿𝑡
=
𝑊𝑡
𝐽
𝑛𝑗,𝑡 𝑆𝑗,𝑡
=∑ 𝑟𝑗,𝑡+𝛿𝑡
𝑗=1
𝑊𝑡

where 𝑟𝑗,𝑡+𝛿𝑡 is the return on asset 𝑗 at time 𝑡 + 𝛿𝑡.


Defining 𝑊𝑗,𝑡 = 𝑛𝑗,𝑡 𝑆𝑗,𝑡 as the wealth invested in asset 𝑗 at time 𝑡, then 𝑤𝑗,𝑡 = 𝑛𝑗,𝑡 𝑆𝑗,𝑡 /𝑊𝑡 is the fraction of total wealth
invested in asset 𝑗 at time 𝑡. The return on a portfolio of 𝐽 assets is then given by
𝐽
𝑟𝑡+𝛿𝑡 = ∑ 𝑤𝑗,𝑡 𝑟𝑗,𝑡+𝛿𝑡
𝑗=1

on a single interval extending from 𝑡 to 𝑡 + 𝛿𝑡.

Mean Absolute Deviation in portfolio returns

The return on a portfolio of 𝐽 assets over a period of 𝑇 intervals with weights 𝑤𝑗 for asset 𝑗 is given by

𝐽
1 𝑇
MAD = ∑ ∣ ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ )∣,
𝑇 𝑡=1 𝑗=1

where 𝑟𝑡,𝑗 is the return on asset 𝑗 at time 𝑡, 𝑟𝑗̄ is the mean return for asset 𝑗, and 𝑤𝑗 is the fraction of the total portfolio
that is invested in asset 𝑗. Note that due to the use of absolute values, MAD for the portfolio is not the weighted sum of
MAD𝑗 for individual assets

2.3.4 MAD portfolio optimization

The portfolio optimization problem is to find an allocation of investments weights 𝑤𝑗 to minimize the portfolio measure
of risk subject to constraints on required return and any other constraints an investor wishes to impose. Assume that we
can make investment decisions at every trading day 𝑡 over a fixed time horizon ranging from 𝑡 = 1, … , 𝑇 and that there
is a set of 𝐽 assets in which we can choose to invest.”
𝐽
1 𝑇
MAD = min ∑ ∣ ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ )∣
𝑇 𝑡=1 𝑗=1
𝐽
s.t. ∑ 𝑤𝑗 𝑟𝑗̄ ≥ 𝑅
𝑗=1
𝐽
∑ 𝑤𝑗 = 1
𝑗=1

𝑤𝑗 ≥ 0 ∀𝑗 ∈ 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀ 𝑗 ∈ 𝐽.

2.3. MAD portfolio optimization 38


Hands-On Mathematical Optimization with AMPL in Python

where 𝑅 is the minimum required portfolio return. The lower bound 𝑤𝑗 ≥ 0 is a “no short sales” constraint. The upper
bound 𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 enforces a required level of diversification in the portfolio.
Defining two sets of auxiliary variables 𝑢𝑡 ≥ 0 and 𝑣𝑡 ≥ 0 for every 𝑡 = 1, … , 𝑇 , leads to a reformulation of the problem
as a linear optimization:

1 𝑇
MAD = min ∑(𝑢 + 𝑣𝑡 )
𝑇 𝑡=1 𝑡
𝐽
s.t. 𝑢𝑡 − 𝑣𝑡 = ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ ) ∀𝑡 ∈ 1, … , 𝑇
𝑗=1
𝐽
∑ 𝑤𝑗 𝑟𝑗̄ ≥ 𝑅
𝑗=1
𝐽
∑ 𝑤𝑗 = 1
𝑗=1

𝑤𝑗 ≥ 0 ∀𝑗 ∈ 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀𝑗 ∈ 𝐽
𝑢𝑡 , 𝑣𝑡 ≥ 0 𝑡 = 1, … , 𝑇 .

2.3.5 AMPL model

%%writefile mad_portfolio.mod

param R default 0;
param w_lb default 0;
param w_ub default 1;

set ASSETS;
set TIME;

param daily_returns{TIME, ASSETS};


param mean_return{ASSETS};

var w{ASSETS};
var u{TIME} >= 0;
var v{TIME} >= 0;

minimize MAD: sum{t in TIME}(u[t] + v[t]) / card(TIME);

s.t. portfolio_returns {t in TIME}:


u[t] - v[t] == sum{j in ASSETS}(w[j] * (daily_returns[t, j] - mean_return[j]));

s.t. sum_of_weights: sum{j in ASSETS} w[j] == 1;

s.t. mean_portfolio_return: sum{j in ASSETS}(w[j] * mean_return[j]) >= R;

s.t. no_short {j in ASSETS}: w[j] >= w_lb;

s.t. diversify {j in ASSETS}: w[j] <= w_ub;

Writing mad_portfolio.mod

2.3. MAD portfolio optimization 39


Hands-On Mathematical Optimization with AMPL in Python

def mad_portfolio(assets):
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
mean_return = daily_returns.mean()

daily_returns["Date"] = daily_returns.index.format()
daily_returns.set_index("Date", inplace=True)

ampl = AMPL()
ampl.read("mad_portfolio.mod")

ampl.set["ASSETS"] = list(assets.columns)
ampl.set["TIME"] = daily_returns.index.values

ampl.param["daily_returns"] = daily_returns
ampl.param["mean_return"] = mean_return

return ampl

def mad_visualization(assets, m):


mean_portfolio_return = m.get_value("sum{j in ASSETS}(w[j] * mean_return[j])")

print(f"Weight lower bound {m.param['w_lb'].value():0.3f}")


print(f"Weight upper bound {m.param['w_ub'].value():0.3f}")
print(
f"Fraction of portfolio invested {m.get_value('sum{j in ASSETS} w[j]'):0.
↪3f}"

)
print(f"Required portfolio daily return {m.param['R'].value():0.5f}")
print(f"Portfolio mean daily return {mean_portfolio_return:0.5f}")
print(f"Portfolio mean absolute deviation {m.obj['MAD'].value():0.5f}")

daily_returns = assets.diff()[1:] / assets.shift(1)[1:]


mean_return = daily_returns.mean()
mean_absolute_deviation = abs(daily_returns - mean_return).mean()
mad_portfolio_weights = m.var["w"].to_pandas()

fig, ax = plt.subplots(1, 3, figsize=(15, 0.35 * len(daily_returns.columns)))


mad_portfolio_weights.plot(kind="barh", ax=ax[0], title="MAD Portfolio Weights")
ax[0].invert_yaxis()
ax[0].axvline(m.param["w_lb"].value(), ls="--", color="g")
ax[0].axvline(m.param["w_ub"].value(), ls="--", color="r")
ax[0].legend(["lower bound", "upper bound"])

mean_return.plot(kind="barh", ax=ax[1], title="asset mean daily return")


ax[1].axvline(m.param["R"].value(), ls="--", color="g")

ax[1].axvline(mean_portfolio_return, ls="--", color="r")


ax[1].invert_yaxis()
ax[1].legend(["required return", "portfolio return"])

mean_absolute_deviation.plot(
kind="barh", ax=ax[2], title="asset mean absolute deviation"
)
ax[2].axvline(m.obj["MAD"].value(), ls="--", color="r")
ax[2].legend(["portfolio MAD"])
ax[2].invert_yaxis()
(continues on next page)

2.3. MAD portfolio optimization 40


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

m = mad_portfolio(assets)
m.param["w_lb"] = 0
m.param["w_ub"] = 0.2
m.param["R"] = 0.001
m.option["solver"] = SOLVER
m.solve()
mad_visualization(assets, m)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.007363414097


830 simplex iterations
0 barrier iterations

Weight lower bound 0.000


Weight upper bound 0.200
Fraction of portfolio invested 1.000
Required portfolio daily return 0.00100
Portfolio mean daily return 0.00100
Portfolio mean absolute deviation 0.00736

2.3. MAD portfolio optimization 41


Hands-On Mathematical Optimization with AMPL in Python

2.3.6 MAD risk versus return

The portfolio optimization problem has been formulated as the minimization of a risk measure, MAD, subject to a lower
bound 𝑅 on mean portfolio return. Increasing the required return for the portfolio therefore comes at the cost of tolerating
a higher level of risk. Finding the optimal trade off between risk and return is a central aspect of any investment strategy.
The following cell creates a plot of the risk/return trade off by solving the MAD portfolio optimization problem for in-
creasing values of required return 𝑅. This should be compared to the similar construction commonly used in presentations
of the portfolio optimization problem due to Markowitz.

# plot return vs risk


daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
mean_return = daily_returns.mean()
mean_absolute_deviation = abs(daily_returns - mean_return).mean()

fig, ax = plt.subplots(1, 1, figsize=(10, 6))


for s in assets.keys():
ax.plot(mean_absolute_deviation[s], mean_return[s], "s", ms=8)
ax.text(mean_absolute_deviation[s] * 1.03, mean_return[s], s)

ax.set_xlim(0, 1.1 * max(mean_absolute_deviation))


ax.axhline(0, color="r", linestyle="--")
ax.set_title("Return vs Risk")
ax.set_xlabel("Mean Absolute Deviation in Daily Returns")
ax.set_ylabel("Mean Daily Return")
ax.grid(True)

m = mad_portfolio(assets)
for R in np.linspace(0, mean_return.max(), 20):
m.param["R"] = R
m.option["solver"] = SOLVER
m.solve()
mad_portfolio_weights = m.var["w"].to_pandas()
portfolio_returns = daily_returns.dot(mad_portfolio_weights)
portfolio_mean_return = portfolio_returns.mean()
portfolio_mean_absolute_deviation = abs(
portfolio_returns - portfolio_mean_return
).mean()
ax.plot(portfolio_mean_absolute_deviation, portfolio_mean_return, "ro", ms=10)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


881 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005691877947


(continues on next page)

2.3. MAD portfolio optimization 42


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


40 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.00576705787


81 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005911268434


62 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.006139268668


72 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.006464446095


73 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.006899270748


73 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.007408897585


41 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.008004143952


48 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.008674936697


53 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.009431054553


38 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01029920634


31 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01123704961


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01222932988


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01329439346


19 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01443680153


17 simplex iterations
(continues on next page)

2.3. MAD portfolio optimization 43


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01566350556


21 simplex iterations
0 barrier iterations

2.3.7 Addition of a Risk-free Asset

The option of a holding a risk-free asset as a component of investment can substantially reduce financial risk. The risk-
𝐽
free asset is designated as 𝑗 = 0 with a fixed return 𝑟0̄ . The fraction invested in asset 𝑗 = 0 will be 𝑤0 = 1 − ∑𝑗=1 𝑤𝑗 .
The optimization model becomes
𝐽
1 𝑇
MAD = min ∑ ∣ ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ )∣
𝑇 𝑡=1 𝑗=1
𝐽
s.t. ∑ 𝑤𝑗 (𝑟𝑗̄ − 𝑟0̄ ) ≥ 𝑅 − 𝑟0̄
𝑗=1
𝐽
∑ 𝑤𝑗 ≤ 1
𝑗=1

𝑤𝑗 ≥ 0 ∀𝑗 ∈ 1, … , 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀𝑗 ∈ 1, … , 𝐽

2.3. MAD portfolio optimization 44


Hands-On Mathematical Optimization with AMPL in Python

where 𝑅 is the minimum required portfolio return. The lower bound 𝑤𝑗 ≥ 0 is a “no short sales” constraint. The upper
bound 𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 enforces a required level of diversification in the portfolio.
Defining two sets of auxiliary variables 𝑢𝑡 ≥ 0 and 𝑣𝑡 ≥ 0 for every 𝑡 = 1, … , 𝑇 , leads to a reformulation of the problem
as an LO:
1 𝑇
MAD = min ∑(𝑢 + 𝑣𝑡 )
𝑇 𝑡=1 𝑡
𝐽
s.t. 𝑢𝑡 − 𝑣𝑡 = ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ ) ∀𝑡 ∈ 1, … , 𝑇
𝑗=1
𝐽
∑ 𝑤𝑗 (𝑟𝑗̄ − 𝑟0̄ ) ≥ 𝑅 − 𝑟0̄
𝑗=1
𝐽
∑ 𝑤𝑗 ≤ 1
𝑗=1

𝑤𝑗 ≥ 0 ∀𝑗 ∈ 1, … , 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀𝑗 ∈ 1, … , 𝐽
𝑢𝑡 , 𝑣𝑡 ≥ 0 ∀𝑡 ∈ 1, … , 𝑇 .

%%writefile mad_portfolio_cash.mod

param R default 0;
param rf default 0;
param w_lb default 0;
param w_ub default 1;

set ASSETS;
set TIME;

param daily_returns{TIME, ASSETS};


param mean_return{ASSETS};

var w{ASSETS};
var u{TIME} >= 0;
var v{TIME} >= 0;

minimize MAD: sum{t in TIME}(u[t] + v[t]) / card(TIME);

s.t. portfolio_returns {t in TIME}:


u[t] - v[t] == sum{j in ASSETS}(w[j] * (daily_returns[t, j] - mean_return[j]));

s.t. sum_of_weights: sum{j in ASSETS} w[j] <= 1;

s.t. mean_portfolio_return: sum{j in ASSETS}(w[j] * (mean_return[j] - rf)) >= R - rf;

s.t. no_short {j in ASSETS}: w[j] >= w_lb;

s.t. diversify {j in ASSETS}: w[j] <= w_ub;

Writing mad_portfolio_cash.mod

def mad_portfolio_cash(assets):
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
(continues on next page)

2.3. MAD portfolio optimization 45


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


mean_return = daily_returns.mean()

daily_returns["Date"] = daily_returns.index.format()
daily_returns.set_index("Date", inplace=True)

ampl = AMPL()
ampl.read("mad_portfolio_cash.mod")

ampl.set["ASSETS"] = list(assets.columns)
ampl.set["TIME"] = daily_returns.index.values

ampl.param["daily_returns"] = daily_returns
ampl.param["mean_return"] = mean_return

return ampl

m = mad_portfolio_cash(assets)
m.param["w_lb"] = 0
m.param["w_ub"] = 0.2
m.param["R"] = 0.001
m.option["solver"] = SOLVER
m.solve()
mad_visualization(assets, m)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.007287885


783 simplex iterations
0 barrier iterations

Weight lower bound 0.000


Weight upper bound 0.200
Fraction of portfolio invested 0.917
Required portfolio daily return 0.00100
Portfolio mean daily return 0.00100
Portfolio mean absolute deviation 0.00729

2.3. MAD portfolio optimization 46


Hands-On Mathematical Optimization with AMPL in Python

2.3.8 MAD risk versus return with a risk-free asset

As above, it is instructive to plot the MAD risk versus required return 𝑅. The result is similar, but not exactly the same,
as the standard presentation from modern portfolio theory (MPT) for efficient frontier of investing, and the capital market
line. A careful look at the the plot below shows minor difference at very high levels of return and risk that departs from
the MPT analysis.
# plot return vs risk
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
mean_return = daily_returns.mean()
mean_absolute_deviation = abs(daily_returns - mean_return).mean()

fig, ax = plt.subplots(1, 1, figsize=(10, 6))


for s in assets.keys():
ax.plot(mean_absolute_deviation[s], mean_return[s], "s", ms=8)
ax.text(mean_absolute_deviation[s] * 1.03, mean_return[s], s)

ax.set_xlim(0, 1.1 * max(mean_absolute_deviation))


ax.axhline(0, color="r", linestyle="--")
ax.set_title("Return vs Risk")
ax.set_xlabel("Mean Absolute Deviation in Daily Returns")
ax.set_ylabel("Mean Daily Return")
ax.grid(True)

for color, m in zip(["ro", "go"], [mad_portfolio(assets), mad_portfolio_


↪cash(assets)]):
(continues on next page)

2.3. MAD portfolio optimization 47


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


for R in np.linspace(0, mean_return.max(), 20):
m.param["R"] = R
m.option["solver"] = SOLVER
m.solve()
mad_portfolio_weights = m.var["w"].to_pandas()
portfolio_returns = daily_returns.dot(mad_portfolio_weights)
portfolio_mean_return = portfolio_returns.mean()
portfolio_mean_absolute_deviation = abs(
portfolio_returns - portfolio_mean_return
).mean()
ax.plot(portfolio_mean_absolute_deviation, portfolio_mean_return, color,␣
↪ms=10)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


881 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005678309992


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005691877947


40 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.00576705787


81 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005911268434


62 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.006139268668


72 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.006464446095


73 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.006899270748


73 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.007408897585


41 simplex iterations
0 barrier iterations
(continues on next page)

2.3. MAD portfolio optimization 48


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.008004143952


48 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.008674936697


53 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.009431054553


38 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01029920634


31 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01123704961


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01222932988


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01329439346


19 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01443680153


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01566350556


21 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.0007228063862


759 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.001445612772


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.002168419159


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.002891225545


0 simplex iterations
0 barrier iterations

(continues on next page)

2.3. MAD portfolio optimization 49


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.003614031931
0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.004336838317


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.005059644704


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.00578245109


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.006505257476


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.007228063862


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.007950870248


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.008673676635


0 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.009431054553


36 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01029920634


31 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01123704961


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01222932988


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01329439346


19 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01443680153


17 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.01566350556


(continues on next page)

2.3. MAD portfolio optimization 50


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


21 simplex iterations
0 barrier iterations

2.4 Wine quality prediction with L1 regression

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.6/5.6 MB 14.7 MB/s eta 0:00:00


?25hUsing default Community Edition License for Colab. Get yours at: https://ampl.com/
↪ce

Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

2.4. Wine quality prediction with L1 regression 51


Hands-On Mathematical Optimization with AMPL in Python

2.4.1 Problem description

Regression is the task of fitting a model to data. If things go well, the model might provide useful predictions in response
to new data. This notebook shows how linear programming and least absolute deviation (LAD) regression can be used
to create a linear model for predicting wine quality based on physical and chemical properties. The example uses a well
known data set from the machine learning community.
Physical, chemical, and sensory quality properties were collected for a large number of red and white wines produced in
the Portugal then donated to the UCI machine learning repository (Cortez, Paulo, Cerdeira, A., Almeida, F., Matos, T.
& Reis, J.. (2009). Wine Quality. UCI Machine Learning Repository.) The following cell reads the data for red wines
directly from the UCI machine learning repository.
Cortez, P., Cerdeira, A., Almeida, F., Matos, T., & Reis, J. (2009). Modeling wine preferences by data mining from
physicochemical properties. Decision support systems, 47(4), 547-553. https://doi.org/10.1016/j.dss.2009.05.016
Let us first download the data
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

wines = pd.read_csv(
"https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/
↪winequality-red.csv",

sep=";",
)
display(wines)

fixed acidity volatile acidity citric acid residual sugar chlorides \


0 7.4 0.700 0.00 1.9 0.076
1 7.8 0.880 0.00 2.6 0.098
2 7.8 0.760 0.04 2.3 0.092
3 11.2 0.280 0.56 1.9 0.075
4 7.4 0.700 0.00 1.9 0.076
... ... ... ... ... ...
1594 6.2 0.600 0.08 2.0 0.090
1595 5.9 0.550 0.10 2.2 0.062
1596 6.3 0.510 0.13 2.3 0.076
1597 5.9 0.645 0.12 2.0 0.075
1598 6.0 0.310 0.47 3.6 0.067

free sulfur dioxide total sulfur dioxide density pH sulphates \


0 11.0 34.0 0.99780 3.51 0.56
1 25.0 67.0 0.99680 3.20 0.68
2 15.0 54.0 0.99700 3.26 0.65
3 17.0 60.0 0.99800 3.16 0.58
4 11.0 34.0 0.99780 3.51 0.56
... ... ... ... ... ...
1594 32.0 44.0 0.99490 3.45 0.58
1595 39.0 51.0 0.99512 3.52 0.76
1596 29.0 40.0 0.99574 3.42 0.75
1597 32.0 44.0 0.99547 3.57 0.71
1598 18.0 42.0 0.99549 3.39 0.66

alcohol quality
0 9.4 5
1 9.8 5
2 9.8 5
(continues on next page)

2.4. Wine quality prediction with L1 regression 52


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


3 9.8 6
4 9.4 5
... ... ...
1594 10.5 5
1595 11.2 6
1596 11.0 6
1597 10.2 5
1598 11.0 6

[1599 rows x 12 columns]

2.4.2 Model objective: Mean Absolute Deviation (MAD)

Given 𝑛 repeated observations of a response variable 𝑦𝑖 (in this wine quality), the mean absolute deviation (MAD) of
𝑦𝑖 from the mean value 𝑦 ̄ is

1 𝑛
MAD (𝑦) = ∑ |𝑦 − 𝑦|̄
𝑛 𝑖=1 𝑖

def MAD(df):
return (df - df.mean()).abs().mean()

print("MAD = ", MAD(wines["quality"]))

fig, ax = plt.subplots(figsize=(12, 4))


ax = wines["quality"].plot(alpha=0.6, title="wine quality")
ax.axhline(wines["quality"].mean(), color="r", ls="--", lw=3)

mad = MAD(wines["quality"])
ax.axhline(wines["quality"].mean() + mad, color="g", lw=3)
ax.axhline(wines["quality"].mean() - mad, color="g", lw=3)
ax.legend(["y", "mean", "mad"])
ax.set_xlabel("observation")

plt.show()

MAD = 0.6831779242889846

2.4. Wine quality prediction with L1 regression 53


Hands-On Mathematical Optimization with AMPL in Python

2.4.3 A preliminary look at the data

The data consists of 1,599 measurements of eleven physical and chemical characteristics plus an integer measure of
sensory quality recorded on a scale from 3 to 8. Histograms provides insight into the values and variability of the data set.

fig, axes = plt.subplots(3, 4, figsize=(12, 8), sharey=True)

for ax, column in zip(axes.flatten(), wines.columns):


wines[column].hist(ax=ax, bins=30)
ax.axvline(wines[column].mean(), color="r", label="mean")
ax.set_title(column)
ax.legend()

plt.tight_layout()

2.4. Wine quality prediction with L1 regression 54


Hands-On Mathematical Optimization with AMPL in Python

2.4.4 Which features influence reported wine quality?

The art of regression is to identify the features that have explanatory value for a response of interest. This is where a person
with deep knowledge of an application area, in this case an experienced onenologist will have a head start compared to
the naive data scientist. In the absence of the experience, we proceed by examining the correlation among the variables
in the data set.

_ = wines.corr()["quality"].plot(kind="bar", grid=True)

2.4. Wine quality prediction with L1 regression 55


Hands-On Mathematical Optimization with AMPL in Python

wines[["volatile acidity", "density", "alcohol", "quality"]].corr()

volatile acidity density alcohol quality


volatile acidity 1.000000 0.022026 -0.202288 -0.390558
density 0.022026 1.000000 -0.496180 -0.174919
alcohol -0.202288 -0.496180 1.000000 0.476166
quality -0.390558 -0.174919 0.476166 1.000000

Collectively, these figures suggest alcohol is a strong correlate of quality, and several additional factors as candidates
for explanatory variables..

2.4. Wine quality prediction with L1 regression 56


Hands-On Mathematical Optimization with AMPL in Python

2.4.5 LAD line fitting to identify features

An alternative approach is perform a series of single feature LAD regressions to determine which features have the largest
impact on reducing the mean absolute deviations in the residuals.
1
min ∑ |𝑦 − 𝑎𝑥𝑖 − 𝑏|
𝐼 𝑖∈𝐼 𝑖

This computation has been presented in a prior notebook.

%%writefile lad_fit_1.mod

set I;

param y{I};
param X{I};

var a;
var b;

var e_pos{I} >= 0;


var e_neg{I} >= 0;

var prediction{i in I} = a * X[i] + b;


s.t. prediction_error{i in I}: e_pos[i] - e_neg[i] == prediction[i] - y[i];

minimize mean_absolute_deviation: sum{i in I}(e_pos[i] + e_neg[i]) / card(I);

Writing lad_fit_1.mod

def lad_fit_1(df, y_col, x_col):


m = AMPL()
m.read("lad_fit_1.mod")

m.set["I"] = df.index.values

m.param["y"] = df[y_col]
m.param["X"] = df[x_col]

m.option["solver"] = SOLVER
m.solve()

return m

m = lad_fit_1(wines, "quality", "alcohol")

print(
f'The mean absolute deviation for a single-feature regression is {m.obj["mean_
↪absolute_deviation"].value():0.5f}'

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.5411666021


1578 simplex iterations
0 barrier iterations

The mean absolute deviation for a single-feature regression is 0.54117

2.4. Wine quality prediction with L1 regression 57


Hands-On Mathematical Optimization with AMPL in Python

This calculation is performed for all variables to determine which variables are the best candidates to explain deviations
in wine quality.
mad = (wines["alcohol"] - wines["alcohol"].mean()).abs().mean()
vars = {}
for i in wines.columns:
m = lad_fit_1(wines, "quality", i)
vars[i] = m.obj["mean_absolute_deviation"].value()

fig, ax = plt.subplots()
pd.Series(vars).plot(kind="bar", ax=ax, grid=True)
ax.axhline(mad, color="r", lw=3)
ax.set_title("MADs for single-feature regressions")
plt.show()

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6579111945


1494 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.5980365332


1642 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6457762063


1584 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6579111945


1508 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6517416082


1628 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6579111945


1543 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6172771025


1706 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6544712022


1648 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6579111945


1489 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.6320777409


1636 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.5411666021


1578 simplex iterations
0 barrier iterations
(continues on next page)

2.4. Wine quality prediction with L1 regression 58


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0


2 simplex iterations
0 barrier iterations

wines["prediction"] = [i[1] for i in m.var["prediction"].get_values()]


wines["quality"].hist(label="data")

wines.plot(x="quality", y="prediction", kind="scatter")


plt.show()

2.4. Wine quality prediction with L1 regression 59


Hands-On Mathematical Optimization with AMPL in Python

2.4. Wine quality prediction with L1 regression 60


Hands-On Mathematical Optimization with AMPL in Python

2.4.6 Multivariate 𝐿1 -regression

Let us now perform a full multivariate 𝐿1 -regression on the wine dataset to predict the wine quality 𝑦 using the provided
wine features. We aim to find the coefficients 𝑚𝑗 ’s and 𝑏 that minimize the mean absolute deviation (MAD) by solving
the following problem:

1 𝑛
MAD (𝑦)̂ = min ∑ |𝑦 − 𝑦𝑖̂ |
𝑚, 𝑏 𝑛 𝑖=1 𝑖

𝐽
s. t. 𝑦𝑖̂ = ∑ 𝑥𝑖,𝑗 𝑚𝑗 + 𝑏 ∀𝑖 = 1, … , 𝑛,
𝑗=1

where 𝑥𝑖,𝑗 are values of ‘explanatory’ variables, i.e., the 11 physical and chemical characteristics of the wines. By taking
care of the absolute value appearing in the objective function, this can be implemented in AMPL as an LP as follows:
%%writefile l1_fit.mod

set I;
set J;

param y{I};
param X{I, J};

var a{J};
var b;

var e_pos{I} >= 0;


var e_neg{I} >= 0;

var prediction{i in I} = sum{j in J}(a[j] * X[i, j]) + b;

s.t. prediction_error{i in I}: e_pos[i] - e_neg[i] == prediction[i] - y[i];

minimize mean_absolute_deviation: sum{i in I}(e_pos[i] + e_neg[i]) / card(I);

Writing l1_fit.mod

def l1_fit(df, y_col, x_cols):


m = AMPL()
m.read("l1_fit.mod")

m.set["I"] = df.index.values
m.set["J"] = x_cols

m.param["y"] = df[y_col]
m.param["X"] = df[x_cols]

m.option["solver"] = SOLVER
m.solve()

return m

m = l1_fit(
wines,
(continues on next page)

2.4. Wine quality prediction with L1 regression 61


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


"quality",
[
"alcohol",
"volatile acidity",
"citric acid",
"sulphates",
"total sulfur dioxide",
"density",
"fixed acidity",
],
)
print(f"MAD = {m.obj['mean_absolute_deviation'].value():0.5f}\n")

for k, v in m.var["a"].get_values():
print(f"{k} {v}")
print("\n")

wines["prediction"] = [i[1] for i in m.var["prediction"].get_values()]


wines["quality"].hist(label="data")

wines.plot(x="quality", y="prediction", kind="scatter")


plt.show()

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.4997972064


1631 simplex iterations
0 barrier iterations

MAD = 0.49980

alcohol 0.3424249748641086
citric acid -0.2892764146613565
density -18.50082905729104
fixed acidity 0.06381837845852499
sulphates 0.9060911918549712
total sulfur dioxide -0.002187357846776872
volatile acidity -0.9806174627416928

2.4. Wine quality prediction with L1 regression 62


Hands-On Mathematical Optimization with AMPL in Python

2.4. Wine quality prediction with L1 regression 63


Hands-On Mathematical Optimization with AMPL in Python

2.4.7 How do these models perform?

A successful regression model would demonstrate a substantial reduction from MAD (𝑦) to MAD (𝑦). ̂ The value of
MAD (𝑦) sets a benchmark for the regression. The linear regression model clearly has some capability to explain the
observed deviations in wine quality. Tabulating the results of the regression using the MAD statistic we find

Regressors MAD
none 0.683
alcohol only 0.541
all 0.500

Are these models good enough to replace human judgment of wine quality? The reader can be the judge.

2.5 BIM production for worst case

# install dependencies and select solver


%pip install -q amplpy

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

2.5.1 Minmax objective function

Another class of seemingly complicated objective functions that can be easily rewritten as an LP are those stated as
maxima over several linear functions. Given a finite set of indices 𝐾 and a collection of vectors {𝑐𝑘 }𝑘∈𝐾 , the minimax
problem given by

min max 𝑐𝑘⊤ 𝑥 (2.13)


𝑘∈𝐾

General expressions like the latter can be linearized by introducing an auxiliary variable 𝑧 and setting

min 𝑧
s.t. 𝑐𝑘⊤ 𝑥 ≤ 𝑧 ∀ 𝑘 ∈ 𝐾.

This trick works because if all the quantities corresponding to different indices 𝑘 ∈ 𝐾 are below the auxiliary variable 𝑧,
then we are guaranteed that also their maximum is also below 𝑧 and vice versa. Note that the absolute value function can
be rewritten |𝑥𝑖 | = max{𝑥𝑖 , −𝑥𝑖 }, hence the linearization of the optimization problem involving absolute values in the
objective functions is a special case of this.

2.5. BIM production for worst case 64


Hands-On Mathematical Optimization with AMPL in Python

2.5.2 BIM problem variant: Maximizing the lowest possible profit

In the same way we can minimize a maximum like above, we can also maximize the minimum. Let us consider the
BIM microchip production problem, but suppose that there is uncertainty regarding the selling prices of the microchips.
Instead of just the nominal prices 12 € and 9 €, BIM estimates that the prices may more generally take the values
𝑃 = {(12, 9), (11, 10), (8, 11)}. The optimization problem for a production plan that achieves the maximum among the
lowest possible profits can be formulated using the trick mentioned above and can be implemented in AMPL as follows.

%%writefile bim_maxmin.mod

var x1 >= 0;
var x2 >= 0;
var z;

set costs dimen 2;

maximize profit: z;

s.t. maxmin {(c1,c2) in costs}:


z <= c1 * x1 + c2 * x2;

s.t. silicon: x1 <= 1000;


s.t. germanium: x2 <= 1500;
s.t. plastic: x1 + x2 <= 1750;
s.t. copper: 4 * x1 + 2 * x2 <= 4800;

Writing bim_maxmin.mod

def BIM_maxmin(costs):
ampl = AMPL()
ampl.read("bim_maxmin.mod")

ampl.set["costs"] = costs

return ampl

m = BIM_maxmin([[12, 9], [11, 10], [8, 11]])


m.option["solver"] = SOLVER
m.solve()
print(
"x=({:.1f},{:.1f}) revenue={:.3f}".format(
m.var["x1"].value(), m.var["x2"].value(), m.obj["profit"].value()
)
)

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 17500


4 simplex iterations
0 barrier iterations
x=(583.3,1166.7) revenue=17500.000

2.5. BIM production for worst case 65


Hands-On Mathematical Optimization with AMPL in Python

2.6 BIM production variants

# install dependencies and select solver


%pip install -q amplpy numpy matplotlib scikit-learn yfinance

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

2.6.1 Two variants of the BIM problem: fractional objective and additional fixed
costs

Recall the BIM production model introduced earlier here, that is

max 12𝑥1 + 9𝑥2


s.t. 𝑥1 ≤ 1000 (silicon)
𝑥2 ≤ 1500 (germanium)
𝑥1 + 𝑥2 ≤ 1750 (plastic)
4𝑥1 + 2𝑥2 ≤ 4800 (copper)
𝑥1 , 𝑥2 ≥ 0.

Assume the pair (12, 9) reflects the sales price (revenues) in € and not the profits made per unit produced. We then
need to account for the production costs. Suppose that the production costs for (𝑥1 , 𝑥2 ) chips are equal to a fixed cost of
100 (independent of the number of units produced) plus 7/6𝑥1 plus 5/6𝑥2 . It is reasonable to maximize the difference
between the revenues and the costs. This approach yields the following linear model:

%%writefile BIM_with_revenues_minus_costs.mod

var x1 >= 0;
var x2 >= 0;

var revenue = 12 * x1 + 9 * x2;


var variable_cost = 7/6 * x1 + 5/6 * x2;

param fixed_cost default 100;

maximize profit: revenue - variable_cost - fixed_cost;

s.t. silicon: x1 <= 1000;


s.t. germanium: x2 <= 1500;
s.t. plastic: x1 + x2 <= 1750;
s.t. copper: 4 * x1 + 2 * x2 <= 4800;

Writing BIM_with_revenues_minus_costs.mod

def BIM_with_revenues_minus_costs():
m = AMPL()
m.read("BIM_with_revenues_minus_costs.mod")
(continues on next page)

2.6. BIM production variants 66


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

return m

BIM_linear = BIM_with_revenues_minus_costs()
BIM_linear.option["solver"] = SOLVER
BIM_linear.solve()

print(
"x=({:.1f},{:.1f}) value={:.3f} revenue={:.2f} cost={:.2f}".format(
BIM_linear.var["x1"].value(),
BIM_linear.var["x2"].value(),
BIM_linear.obj["profit"].value(),
BIM_linear.var["revenue"].value(),
BIM_linear.var["variable_cost"].value()
+ BIM_linear.param["fixed_cost"].value(),
)
)

HiGHS 1.5.1:HiGHS 1.5.1: optimal solution; objective 15925


2 simplex iterations
0 barrier iterations
x=(650.0,1100.0) value=15925.000 revenue=17700.00 cost=1775.00

This first model has the same optimal solution as the original BIM model, namely (650, 1100) with a revenue of 17700
and a cost of 1775.
Alternatively, we may aim to optimize the efficiency of the plan, expressed as the ratio between the revenues and the
costs:
12𝑥1 + 9𝑥2
max
7/6𝑥1 + 5/6𝑥2 + 100
s.t. 𝑥1 ≤ 1000 (silicon)
𝑥2 ≤ 1500 (germanium)
𝑥1 + 𝑥2 ≤ 1750 (plastic)
4𝑥1 + 2𝑥2 ≤ 4800 (copper)
𝑥1 , 𝑥2 ≥ 0.

In order to solve this second version we need to deal with the fraction appearing in the objective function by introducing
an auxiliary variable 𝑡 ≥ 0. More specifically, we reformulate the model as follows

max 12𝑦1 + 9𝑦2


s.t. 𝑦1 ≤ 1000 ⋅ 𝑡 (silicon)
𝑦2 ≤ 1500 ⋅ 𝑡 (germanium)
𝑦1 + 𝑦2 ≤ 1750 ⋅ 𝑡 (plastic)
4𝑦1 + 2𝑦2 ≤ 4800 ⋅ 𝑡 (copper)
7/6𝑦1 + 5/6𝑦2 + 100𝑦 = 1 (fraction)
𝑦1 , 𝑦2 , 𝑡 ≥ 0

Despite the change of variables, we can always recover the solution as (𝑥1 , 𝑥2 ) = (𝑦1 /𝑡, 𝑦2 /𝑡).
%%writefile BIM_with_revenues_over_costs.mod

var y1 >= 0;
var y2 >= 0;
var t >= 0;
(continues on next page)

2.6. BIM production variants 67


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

var revenue = 12 * y1 + 9 * y2;


var variable_cost = 7/6 * y1 + 5/6 * y2;

param fixed_cost default 100;

maximize profit: revenue;

s.t. silicon: y1 <= 1000 * t;


s.t. germanium: y2 <= 1500 * t;
s.t. plastic: y1 + y2 <= 1750 * t;
s.t. copper: 4 * y1 + 2 * y2 <= 4800 * t;

s.t. frac: variable_cost + fixed_cost * t == 1;

Writing BIM_with_revenues_over_costs.mod

def BIM_with_revenues_over_costs():
m = AMPL()
m.read("BIM_with_revenues_over_costs.mod")

return m

BIM_fractional = BIM_with_revenues_over_costs()
BIM_fractional.option["solver"] = SOLVER
BIM_fractional.solve()

t = BIM_fractional.var["t"].value()
y1 = BIM_fractional.var["y1"].value()
y2 = BIM_fractional.var["y2"].value()
profit = BIM_fractional.obj["profit"].value()
variable_cost = BIM_fractional.var["variable_cost"].value()
fixed_cost = BIM_fractional.param["fixed_cost"].value()
revenue = BIM_fractional.var["revenue"].value()

print(
"x=({:.1f},{:.1f}) value={:.3f} revenue={:.2f} cost={:.2f}".format(
y1 / t,
y2 / t,
profit / (variable_cost + fixed_cost * t),
revenue / t,
variable_cost / t + fixed_cost,
)
)

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 10.05076142


4 simplex iterations
0 barrier iterations
x=(250.0,1500.0) value=10.051 revenue=16500.00 cost=1641.67

The second model has optimal solution (250, 1500) with a revenue of 16500 and a cost of 1641.667.
The efficiency, measured as the ratio of revenue over costs for the optimal solution, is different for the two models. For
the first model the efficiency is equal to 17700
1775 = 9.972, which is strictly smaller than that of the second model, that is
16500
1641.667 = 10.051.

2.6. BIM production variants 68


Hands-On Mathematical Optimization with AMPL in Python

2.7 BIM production using demand forecasts

# install dependencies and select solver


%pip install -q amplpy numpy matplotlib

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

2.7.1 The problem: Optimal material acquisition and production planning using
demand forecasts

This example is a continuation of the BIM chip production problem illustrated here. Recall hat BIM produces logic and
memory chips using copper, silicon, germanium, and plastic and that each chip requires the following quantities of raw
materials:

chip copper silicon germanium plastic


logic 0.4 1 - 1
memory 0.2 - 1 1

BIM needs to carefully manage the acquisition and inventory of these raw materials based on the forecasted demand for
the chips. Data analysis led to the following prediction of monthly demands:

chip Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
logic 88 125 260 217 238 286 248 238 265 293 259 244
memory 47 62 81 65 95 118 86 89 82 82 84 66

At the beginning of the year, BIM has the following stock:

copper silicon germanium plastic


480 1000 1500 1750

The company would like to have at least the following stock at the end of the year:

copper silicon germanium plastic


200 500 500 1000

Each raw material can be acquired at each month, but the unit prices vary as follows:

2.7. BIM production using demand forecasts 69


Hands-On Mathematical Optimization with AMPL in Python

product Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
copper 1 1 1 2 2 3 3 2 2 1 1 2
silicon 4 3 3 3 5 5 6 5 4 3 3 5
germanium 5 5 5 3 3 3 3 2 3 4 5 6
plastic 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1

The inventory is limited by a capacity of a total of 9000 units per month, regardless of the type of material of products
in stock. The holding costs of the inventory are 0.05 per unit per month regardless of the material type. Due to budget
constraints, BIM cannot spend more than 5000 per month on acquisition.
BIM aims at minimizing the acquisition and holding costs of the materials while meeting the required quantities for
production. The production is made to order, meaning that no inventory of chips is kept.
Let us model the material acquisition planning and solve it optimally based on the forecasted chip demand above.
Let us first import both the price and forecast chip demand as Pandas dataframes.

import numpy as np
import matplotlib.pyplot as plt
from io import StringIO
import pandas as pd

demand_data = """chip,Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec
logic,88,125,260,217,238,286,248,238,265,293,259,244
memory,47,62,81,65,95,118,86,89,82,82,84,66"""
price_data = """product,Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec
copper,1,1,1,2,2,3,3,2,2,1,1,2
silicon,4,3,3,3,5,5,6,5,4,3,3,5
germanium,5,5,5,3,3,3,3,2,3,4,5,6
plastic,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1"""

demand_chips = pd.read_csv(StringIO(demand_data), index_col="chip")


display(demand_chips)

price = pd.read_csv(StringIO(price_data), index_col="product")


price

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
chip
logic 88 125 260 217 238 286 248 238 265 293 259 244
memory 47 62 81 65 95 118 86 89 82 82 84 66

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
product
copper 1.0 1.0 1.0 2.0 2.0 3.0 3.0 2.0 2.0 1.0 1.0 2.0
silicon 4.0 3.0 3.0 3.0 5.0 5.0 6.0 5.0 4.0 3.0 3.0 5.0
germanium 5.0 5.0 5.0 3.0 3.0 3.0 3.0 2.0 3.0 4.0 5.0 6.0
plastic 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1

We can also add a small dataframe with the consumptions and obtain the monthly demand for each raw material using a
simple matrix multiplication.

use = dict()
use["logic"] = {"silicon": 1, "plastic": 1, "copper": 4}
use["memory"] = {"germanium": 1, "plastic": 1, "copper": 2}
use = pd.DataFrame.from_dict(use).fillna(0).astype(int)
(continues on next page)

2.7. BIM production using demand forecasts 70


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


display(use)

demand = use.dot(demand_chips)
demand

logic memory
silicon 1 0
plastic 1 1
copper 4 2
germanium 0 1

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
silicon 88 125 260 217 238 286 248 238 265 293 259 244
plastic 135 187 341 282 333 404 334 327 347 375 343 310
copper 446 624 1202 998 1142 1380 1164 1130 1224 1336 1204 1108
germanium 47 62 81 65 95 118 86 89 82 82 84 66

2.7.2 The optimization model

Define the set of raw material 𝑃 = {copper, silicon, germanium, plastic} and 𝑇 the set of the 12 months of the year. Let
• 𝑥𝑝𝑡 ≥ 0 be the variable describing the amount of raw material 𝑝 ∈ 𝑃 acquired in month 𝑡 ∈ 𝑇 ;
• 𝑠𝑝𝑡 ≥ 0 be the variable describing the amount of raw material 𝑝 ∈ 𝑃 left in stock at the end of month 𝑡 ∈ 𝑇 .
Note that these values are uniquely determined by the 𝑥 variables, but we keep these additional variables to ease
the modeling.
The total cost is the objective function of our optimal acquisition and production problem. If 𝜋𝑝𝑡 is the unit price of
product 𝑝 ∈ 𝑃 in month 𝑡 ∈ 𝑇 and ℎ𝑝𝑡 the unit holding costs (which happen to be constant) we can express the total cost
as:
∑ ∑ 𝜋𝑝𝑡 𝑥𝑝𝑡 + ∑ ∑ ℎ𝑝𝑡 𝑠𝑝𝑡 .
𝑝∈𝑃 𝑡∈𝑇 𝑝∈𝑃 𝑡∈𝑇

Let us now focus on the constraints. If 𝛽 ≥ 0 denotes the monthly acquisition budget, the budget constraint can be
expressed as:
∑ 𝜋𝑝𝑡 𝑥𝑝𝑡 ≤ 𝛽 ∀𝑡 ∈ 𝑇 .
𝑝∈𝑃

Further, we constrain the inventory to be always the storage capacity ℓ ≥ 0 using:


∑ 𝑠𝑝𝑡 ≤ ℓ ∀𝑡 ∈ 𝑇 .
𝑝∈𝑃

Next, we add another constraint to fix the value of the variables 𝑠𝑝𝑡 by balancing the acquired amounts with the previous
inventory and the demand 𝛿𝑝𝑡 which for each month is implied by the total demand for the chips of both types. Note that
𝑡 − 1 is defined as the initial stock when 𝑡 is the first period, that is \texttt{January}. This can be obtained with additional
variables 𝑠 made equal to those values or with a rule that specializes, as in the code below.
𝑥𝑝𝑡 + 𝑠𝑝,𝑡−1 = 𝛿𝑝𝑡 + 𝑠𝑝𝑡 ∀𝑝 ∈ 𝑃 , 𝑡 ∈ 𝑇 .

Finally, we capture the required minimum inventory levels in December with the constraint.
𝑠𝑝Dec ≥ Ω𝑝 ∀𝑝 ∈ 𝑃 ,

where (Ω𝑝 )𝑝∈𝑃 is the vector with the desired end inventories.
Here is the AMPL implementation of this LP.

2.7. BIM production using demand forecasts 71


Hands-On Mathematical Optimization with AMPL in Python

%%writefile BIMProductAcquisitionAndInventory.mod

set T ordered;
set P;

var x{P, T} >= 0;


var s{P, T} >= 0;

param pi{P, T};


param h{P, T} default 0.05;
param delta{P, T};
param existing{P};
param desired{P};
param month_budget;
param stock_limit;

var acquisition_cost = sum{p in P, t in T} pi[p,t] * x[p,t];


var inventory_cost = sum{p in P, t in T} h[p,t] * s[p,t];

minimize total_cost: acquisition_cost + inventory_cost;

s.t. balance {p in P, t in T}:


(if t == first(T) then
existing[p] + x[p,t]
else
x[p,t] + s[p,prev(t)])
== delta[p,t] + s[p,t];
s.t. finish {p in P}: s[p, last(T)] >= desired[p];
s.t. inventory {t in T}: sum{p in P} s[p,t] <= stock_limit;
s.t. budget {t in T}: sum{p in P} pi[p,t] * x[p,t] <= month_budget;

Overwriting BIMProductAcquisitionAndInventory.mod

def ShowTableOfAmplVariables(m, X):


P = m.set["P"].to_list()
T = m.set["T"].to_list()

df = pd.DataFrame(m.var[X].to_list(), columns=["P", "T", "values"]).round(


decimals=2
)
df = df.pivot(index="P", columns="T", values="values")
df = df.reindex(P)
df = df[T]

return df

def BIMProductAcquisitionAndInventory(
demand, acquisition_price, existing, desired, stock_limit, month_budget
):
m = AMPL()
m.read("BIMProductAcquisitionAndInventory.mod")
m.set["T"] = list(demand.columns)
m.set["P"] = demand.index.values
m.param["pi"] = acquisition_price
m.param["delta"] = demand
m.param["existing"] = existing
(continues on next page)

2.7. BIM production using demand forecasts 72


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


m.param["desired"] = desired
m.param["month_budget"] = month_budget
m.param["stock_limit"] = stock_limit

return m

We now can create an instance of the model using the provided data and solve it.
budget = 5000
m = BIMProductAcquisitionAndInventory(
demand,
price,
{"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
{"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
9000,
budget,
)

m.option["solver"] = SOLVER
m.solve()

print("\nThe optimal amount of raw materials to acquire in each month is:")


display(ShowTableOfAmplVariables(m, "x"))
print("\nThe corresponding optimal stock levels in each months are:")
stock = ShowTableOfAmplVariables(m, "s")
display(stock)
print("\nThe stock levels can be visualized as follows")
stock.T.plot(drawstyle="steps-mid", grid=True, figsize=(13, 4))
plt.xticks(np.arange(len(stock.columns)), stock.columns)
plt.show()

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21152.655


29 simplex iterations
0 barrier iterations

The optimal amount of raw materials to acquire in each month is:

T Jan Feb Mar Apr May Jun Jul Aug Sep Oct \
P
silicon 0.0 0.0 0.0 965.0 0.0 0.0 0.0 0.0 0.0 1078.1
plastic 0.0 0.0 0.0 0.0 0.0 0.0 266.0 327.0 347.0 375.0
copper 0.0 0.0 3548.0 0.0 0.0 0.0 0.0 0.0 962.0 1336.0
germanium 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

T Nov Dec
P
silicon 217.9 0.0
plastic 343.0 1310.0
copper 4312.0 0.0
germanium 0.0 0.0

The corresponding optimal stock levels in each months are:

T Jan Feb Mar Apr May Jun Jul Aug \


(continues on next page)

2.7. BIM production using demand forecasts 73


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


P
silicon 912.0 787.0 527.0 1275.0 1037.0 751.0 503.0 265.0
plastic 1615.0 1428.0 1087.0 805.0 472.0 68.0 0.0 0.0
copper 4354.0 3730.0 6076.0 5078.0 3936.0 2556.0 1392.0 262.0
germanium 1453.0 1391.0 1310.0 1245.0 1150.0 1032.0 946.0 857.0

T Sep Oct Nov Dec


P
silicon 0.0 785.1 744.0 500.0
plastic 0.0 0.0 0.0 1000.0
copper 0.0 0.0 3108.0 2000.0
germanium 775.0 693.0 609.0 543.0

The stock levels can be visualized as follows

Here is a different solution corresponding to the situation where the budget is much lower, namely 2000.

budget = 2000
m = BIMProductAcquisitionAndInventory(
demand,
price,
{"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
{"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
9000,
budget,
)

m.option["solver"] = SOLVER
m.solve()

print("\nThe optimal amount of raw materials to acquire in each month is:")


display(ShowTableOfAmplVariables(m, "x"))
print("\nThe corresponding optimal stock levels in each months are:")
stock = ShowTableOfAmplVariables(m, "s")
display(stock)
print("\nThe stock levels can be visualized as follows")
stock.T.plot(drawstyle="steps-mid", grid=True, figsize=(13, 4))
plt.xticks(np.arange(len(stock.columns)), stock.columns)
plt.show()

2.7. BIM production using demand forecasts 74


Hands-On Mathematical Optimization with AMPL in Python

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25908.12917


44 simplex iterations
0 barrier iterations

The optimal amount of raw materials to acquire in each month is:

T Jan Feb Mar Apr May Jun Jul Aug \


P
silicon 444.67 559.0 0.0 666.67 0.0 400.0 65.05 0.00
plastic 0.00 0.0 0.0 0.00 0.0 0.0 266.00 327.00
copper 221.33 323.0 2000.0 0.00 1000.0 0.0 0.00 983.65
germanium 0.00 0.0 0.0 0.00 0.0 0.0 0.00 0.00

T Sep Oct Nov Dec


P
silicon 125.62 0.0 0.0 0.0
plastic 1065.00 0.0 0.0 1310.0
copper 695.52 2000.0 2000.0 934.5
germanium 0.00 0.0 0.0 0.0

The corresponding optimal stock levels in each months are:

T Jan Feb Mar Apr May Jun Jul \


P
silicon 1356.67 1790.67 1530.67 1980.33 1742.33 1856.33 1673.38
plastic 1615.00 1428.00 1087.00 805.00 472.00 68.00 0.00
copper 4575.33 4274.33 5072.33 4074.33 3932.33 2552.33 1388.33
germanium 1453.00 1391.00 1310.00 1245.00 1150.00 1032.00 946.00

T Aug Sep Oct Nov Dec


P
silicon 1435.38 1296.0 1003.0 744.0 500.0
plastic 0.00 718.0 343.0 0.0 1000.0
copper 1241.98 713.5 1377.5 2173.5 2000.0
germanium 857.00 775.0 693.0 609.0 543.0

The stock levels can be visualized as follows

Looking at the two optimal solutions corresponding to different budgets, we can note that:
• The budget is not limitative;

2.7. BIM production using demand forecasts 75


Hands-On Mathematical Optimization with AMPL in Python

• With the initial budget of 5000 the solution remains integer;


• Lowering the budget to 2000 forces acquiring fractional quantities;
• Lower values of the budget end up making the problem infeasible.

A more parsimonious model

We can create a more parsimonious model with fewer variabels by getting rid of the auxiliary variables 𝑠𝑝𝑡 . Here is the
corresponding implementation in AMPL:

%%writefile BIMProductAcquisitionAndInventory_v2.mod

set T ordered;
set P;
param pi{P, T};
param h{P, T} default 0.05;
param delta{P, T};

param existing{P};
param desired{P};

param month_budget;
param stock_limit;
var x{P,T} >= 0;
param acquisition_price{P,T};
var s{p in P, t in T} = if t == first(T) then
existing[p] + x[p,t] - delta[p,t]
else
x[p,t] + s[p,prev(t)] - delta[p,t];
s.t. non_negative_stock {p in P, t in T}: s[p,t] >= 0;
var acquisition_cost = sum{p in P, t in T} pi[p,t] * x[p,t];
var inventory_cost = sum{p in P, t in T} h[p,t] * s[p,t];
minimize total_cost: acquisition_cost + inventory_cost;
s.t. finish {p in P}: s[p,last(T)] >= desired[p];
s.t. inventory {t in T}: sum{p in P} s[p,t] <= stock_limit;
s.t. budget {t in T}: sum{p in P} pi[p,t] * x[p,t] <= month_budget;

Overwriting BIMProductAcquisitionAndInventory_v2.mod

def BIMProductAcquisitionAndInventory_v2(
demand, acquisition_price, existing, desired, stock_limit, month_budget
):
m = AMPL()
m.read("BIMProductAcquisitionAndInventory_v2.mod")

m.set["T"] = list(demand.columns)
m.set["P"] = demand.index.values

m.param["pi"] = acquisition_price
m.param["delta"] = demand
m.param["existing"] = existing
m.param["desired"] = desired
m.param["month_budget"] = month_budget
m.param["stock_limit"] = stock_limit

return m

2.7. BIM production using demand forecasts 76


Hands-On Mathematical Optimization with AMPL in Python

m = BIMProductAcquisitionAndInventory_v2(
demand,
price,
{"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
{"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
9000,
2000,
)

m.option["solver"] = SOLVER
m.solve()

print("\nThe optimal amount of raw materials to acquire in each month is:")


display(ShowTableOfAmplVariables(m, "x"))
print("\nThe corresponding optimal stock levels in each months are:")
stock = ShowTableOfAmplVariables(m, "s")
display(stock)
print("\nThe stock levels can be visualized as follows")
stock.T.plot(drawstyle="steps-mid", grid=True, figsize=(13, 4))
plt.xticks(np.arange(len(stock.columns)), stock.columns)
plt.show()

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25908.12917


21 simplex iterations
0 barrier iterations

The optimal amount of raw materials to acquire in each month is:

T Jan Feb Mar Apr May Jun Jul Aug \


P
silicon 444.67 559.0 0.0 666.67 0.0 400.0 56.88 0.0
plastic 0.00 0.0 0.0 0.00 0.0 0.0 593.00 0.0
copper 221.33 323.0 2000.0 0.00 1000.0 0.0 0.00 1000.0
germanium 0.00 0.0 0.0 0.00 0.0 0.0 0.00 0.0

T Sep Oct Nov Dec


P
silicon 133.79 0.0 0.0 0.0
plastic 1065.00 0.0 0.0 1310.0
copper 679.17 2000.0 2000.0 934.5
germanium 0.00 0.0 0.0 0.0

The corresponding optimal stock levels in each months are:

T Jan Feb Mar Apr May Jun Jul \


P
silicon 1356.67 1790.67 1530.67 1980.33 1742.33 1856.33 1665.21
plastic 1615.00 1428.00 1087.00 805.00 472.00 68.00 327.00
copper 4575.33 4274.33 5072.33 4074.33 3932.33 2552.33 1388.33
germanium 1453.00 1391.00 1310.00 1245.00 1150.00 1032.00 946.00

T Aug Sep Oct Nov Dec


P
silicon 1427.21 1296.0 1003.0 744.0 500.0
plastic 0.00 718.0 343.0 0.0 1000.0
(continues on next page)

2.7. BIM production using demand forecasts 77


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


copper 1258.33 713.5 1377.5 2173.5 2000.0
germanium 857.00 775.0 693.0 609.0 543.0

The stock levels can be visualized as follows

2.8 Extra material: Multi-product facility production

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

2.8.1 Maximizing the profit in the worst case for a multi-product facility

A common formulation for the problem of maximizing profit of a multi-product facility in a resource constrained envi-
ronment is given by the following LP

max profit = ∑ 𝑐𝑗 𝑥𝑗
𝑗∈𝐽

s.t. ∑ 𝑎𝑖𝑗 𝑥𝑗 ≤ 𝑏𝑖 ∀𝑖 ∈ 𝐼
𝑗∈𝐽

𝑥𝑗 ≥ 0 ∀ 𝑗 ∈ 𝐽,

where 𝑥𝑗 is the production of product 𝑗 ∈ 𝐽 , 𝑐𝑗 is the net profit from producing and selling one unit of product 𝑗, 𝑎𝑖,𝑗
is the amount of resource 𝑖 required to product a unit of product 𝑗, and 𝑏𝑖 is amount of resource 𝑖 ∈ 𝐼 available. If this

2.8. Extra material: Multi-product facility production 78


Hands-On Mathematical Optimization with AMPL in Python

data is available, then the linear programming solution can provide a considerable of information regarding an optimal
production plan and the marginal value of additional resources.
But what if coefficients of the model are uncertain? What should be the objective then? Does uncertainty change the
production plan? Does the uncertainty change the marginal value assigned to resources? These are complex and thorny
questions that will be largely reserved for later chapters of this book. However, it is possible to consider a specific situation
within the current context.
Consider a situation where there are multiple plausible models for the net profit. These might be a result of marketing
studies or from considering plant operation under multiple scenarios, which we collect in a set 𝑆. The set of profit models
could be written

profit𝑠 = ∑ 𝑐𝑗𝑠 𝑥𝑗 ∀𝑠 ∈ 𝑆
𝑗

where 𝑠 indexes the set of possible scenarios. The scenarios are all deemed equal, no probabilistic interpretation is given.
One conservative criterion is to find maximize profit for the worst case. Letting 𝑧 denote the profit for the worst case, this
criterion requires finding a solution for 𝑥𝑗 for 𝑗 ∈ 𝐽 that satisfies

max 𝑧
𝑥𝑗

s.t. ∑ 𝑐𝑗𝑠 𝑥𝑗 ≥ 𝑧 ∀𝑠 ∈ 𝑆
𝑗∈𝐽

∑ 𝑎𝑖𝑗 𝑥𝑗 ≤ 𝑏𝑖 ∀𝑖 ∈ 𝐼
𝑗∈𝐽

𝑥𝑗 ≥ 0 ∀𝑗 ∈ 𝐽

where 𝑧 is lowest profit that would be encountered under any condition.

2.8.2 Data

import pandas as pd

BIM_scenarios = pd.DataFrame(
[[12, 9], [11, 10], [8, 11]], columns=["product 1", "product 2"]
)

BIM_scenarios.index.name = "scenarios"
print("\nProfit scenarios")
display(BIM_scenarios)

BIM_resources = pd.DataFrame(
[
["silicon", 1000, 1, 0],
["germanium", 1500, 0, 1],
["plastic", 1750, 1, 1],
["copper", 4000, 1, 2],
],
columns=["resource", "available", "product 1", "product 2"],
)
BIM_resources = BIM_resources.set_index("resource")

print("\nAvailable resources and resource requirements")


display(BIM_resources)

2.8. Extra material: Multi-product facility production 79


Hands-On Mathematical Optimization with AMPL in Python

Profit scenarios

product 1 product 2
scenarios
0 12 9
1 11 10
2 8 11

Available resources and resource requirements

available product 1 product 2


resource
silicon 1000 1 0
germanium 1500 0 1
plastic 1750 1 1
copper 4000 1 2

2.8.3 AMPL Model

An implementation of the maximum worst-case profit model.


%%writefile maxmin.mod

set I;
set J;
set S;

param a{I,J};
param b{I};
param c{S,J};

var x{J} >= 0;


var z;

maximize profit: z;
s.t. scenario_profit {s in S}: z <= sum{j in J} c[s, j] * x[j];
s.t. resource_limits {i in I}: sum{j in J} a[i, j] * x[j] <= b[i];

Overwriting maxmin.mod

def maxmin(scenarios, resources):


products = resources.columns.tolist()
products.remove("available")

model = AMPL()
model.read("maxmin.mod")

model.set["I"] = resources.index.values
model.set["J"] = products
model.set["S"] = scenarios.index.values

model.param["a"] = resources.drop("available", axis=1)


model.param["b"] = resources["available"]
model.param["c"] = scenarios
(continues on next page)

2.8. Extra material: Multi-product facility production 80


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

return model

BIM = maxmin(BIM_scenarios, BIM_resources)


BIM.option["solver"] = SOLVER
BIM.solve()

worst_case_plan = pd.Series(BIM.var["x"].to_dict(), name="worst case")


worst_case_profit = BIM.obj["profit"].value()

print("\nWorst case profit =", worst_case_profit)


print(f"\nWorst case production plan:")
display(worst_case_plan)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 17500


3 simplex iterations
0 barrier iterations

Worst case profit = 17500.0

Worst case production plan:

product 1 583.333333
product 2 1166.666667
Name: worst case, dtype: float64

2.8.4 Is maximizing the worst case a good idea?

Maximizing the worst case among all cases is a conservative planning outlook. It may be worth investigating alternative
planning outlooks.
The first step is to create a model to optimize a single scenario. Without repeating the mathematical description, the
following AMPL model is simply the maxmin model adapted to a single scenario.

%%writefile max_profit.mod

set I;
set J;

param a{I,J};
param b{I};
param c{J};

var x{J} >= 0;

maximize profit: sum{j in J} c[j] * x[j];


s.t. resource_limits {i in I}: sum{j in J} a[i, j] * x[j] <= b[i];

Overwriting max_profit.mod

2.8. Extra material: Multi-product facility production 81


Hands-On Mathematical Optimization with AMPL in Python

def max_profit(scenario, resources):


products = resources.columns.tolist()
products.remove("available")

model = AMPL()
model.read("max_profit.mod")

model.set["I"] = resources.index.values
model.set["J"] = products

model.param["a"] = resources.drop("available", axis=1)


model.param["b"] = resources["available"]
model.param["c"] = scenario

return model

2.8.5 Optimizing for the mean scenario

The next cell computes the optimal plan for the mean scenario.

# create mean scenario


mean_case = max_profit(BIM_scenarios.mean(), BIM_resources)
mean_case.option["solver"] = SOLVER
mean_case.solve()

mean_case_profit = mean_case.obj["profit"].value()
mean_case_plan = pd.Series(mean_case.var["x"].to_dict(), name="mean case")

print(f"\nMean case profit = {mean_case_profit:0.1f}")


print("\nMean case production plan:")
print(mean_case_plan)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 17833.33333


1 simplex iterations
0 barrier iterations

Mean case profit = 17833.3

Mean case production plan:


product 1 1000
product 2 750
Name: mean case, dtype: int64

The expected profit under the mean scenario if 17,833 which is 333 greater than for the worst case. Also note the
production plan is different.
Which plan should be preferred? The one that produces a guaranteed profit of 17,500 under all scenarios, or one that
produces expected profit of 17,833?

mean_case_outcomes = BIM_scenarios.dot(mean_case_plan)
mean_case_outcomes.name = "mean outcomes"

worst_case_outcomes = BIM_scenarios.dot(worst_case_plan)
worst_case_outcomes.name = "worst case outcomes"
(continues on next page)

2.8. Extra material: Multi-product facility production 82


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

ax = pd.concat([worst_case_outcomes, mean_case_outcomes], axis=1).plot(


kind="bar", ylim=(15000, 20000)
)
ax.axhline(worst_case_profit)
ax.axhline(worst_case_outcomes.mean(), linestyle="--", label="worst case plan, mean")
ax.axhline(
mean_case_outcomes.mean(),
linestyle="--",
color="orange",
label="mean case plan, mean",
)
_ = ax.legend()

ax = pd.concat([worst_case_plan, mean_case_plan], axis=1).plot(kind="bar")

2.8. Extra material: Multi-product facility production 83


Hands-On Mathematical Optimization with AMPL in Python

2.8.6 Summary

Planning for the worst case reduces the penalty of a bad outcome. But it comes at the cost of reducing the expected
payout, the also the maximum payout should the most favorable scenario occur.
1. Which plan would you choose. Why?
2. Make a case for the other choice.

2.8. Extra material: Multi-product facility production 84


CHAPTER

THREE

3. MIXED INTEGER LINEAR OPTIMIZATION

A peculiar feature of the linear optimization problems that we have seen so far was that the decision variables can take
any real value provided that all the constraints are satisfied. However, there are many situations in which it makes sense
to restrict the feasible set in a way that cannot be expressed using linear (in-)equality constraints. For example, some
numbers might need to be integers, such as the number of people to be assigned to a task. Another situation is where
certain constraints are to hold only if another constraint holds – for example, the amount of power generated in a coal
plant taking at least its minimum value only if the generator is turned on. None of these two examples can be formulated
using linear constraints alone. For such and many other situations, it is often possible that the problem can still be modeled
as an LP, yet with an extra restriction that some variables need to take integer values only.
A mixed-integer linear optimization (MILO) is an LP problem in which some variables are constrained to be integers.
Formally, it is defined as

min 𝑐⊤ 𝑥
s.t. 𝐴𝑥 ≤ 𝑏,
𝑥𝑖 ∈ ℤ, 𝑖 ∈ ℐ,

where ℐ ⊆ {1, … , 𝑛} is the subset of indices identifying the variables that take integer values. The remaining variables
are tacitly assumed to be real variables, that is 𝑥𝑖 ∈ ℝ for 𝑖 ∉ ℐ. Of course, if the decision variables are required to be
nonnegative, we could use the set ℕ instead of ℤ. A special case of integer variables are binary variables, which can take
only values in 𝔹 = {0, 1}.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of MILO:
• A basic MILO example modeling the BIM production with perturbed data
• A workforce shift scheduling problem
• A production problem using disjunctions
• A machine scheduling problem
• Optimal recharging strategy for an electric vehicle
• BIM production revisited
• Extra material: Cryptarithms puzzle
• Extra material: Strip packing
• Extra material: Job shop scheduling
• Extra material: Maintenance planning
Go to the next chapter about network optimization.

85
Hands-On Mathematical Optimization with AMPL in Python

3.1 BIM production with perturbed data

# install dependencies and select solver


%pip install -q amplpy

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

3.1.1 Problem description

The company BIM realizes that a 1% fraction of the copper always gets wasted while producing both types of microchips,
more specifically 1% of the required amount. This means that it actually takes 4.04 gr of copper to produce a logic chip
and 2.02 gr of copper to produce a memory chip. If we rewrite the linear problem of the basic BIM problem and modify
accordingly the coefficients in the corresponding constraints, we obtain the following problem

max 12𝑥1 + 9𝑥2


s.t. 𝑥1 ≤ 1000 (silicon)
𝑥2 ≤ 1500 (germanium)
𝑥1 + 𝑥2 ≤ 1750 (plastic)
4.04𝑥1 + 2.02𝑥2 ≤ 4800 (copper with waste)
𝑥1 , 𝑥2 ≥ 0.

If we solve it, we obtain a different optimal solution than the original one, namely (𝑥1 , 𝑥2 ) ≈ (626.238, 1123.762) and
an optimal value of roughly 17628.713. Note, in particular, that this new optimal solution is not integer, but on the other
hand in the linear optimization problem above there is no constraint requiring 𝑥1 and 𝑥2 to be such.
%%writefile BIM_perturbed_LO.mod

var x1 >= 0;
var x2 >= 0;

maximize profit: 12 * x1 + 9 * x2;

s.t. silicon: x1 <= 1000;


s.t. germanium: x2 <= 1500;
s.t. plastic: x1 + x2 <= 1750;
s.t. copper: 4.04 * x1 + 2.02 * x2 <= 4800;

Overwriting BIM_perturbed_LO.mod

m = AMPL()
m.read("BIM_perturbed_LO.mod")
(continues on next page)

3.1. BIM production with perturbed data 86


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

m.option["solver"] = SOLVER
m.solve()

print(
"x = ({:.3f}, {:.3f}), optimal value = {:.3f}".format(
m.var["x1"].value(), m.var["x2"].value(), m.obj["profit"].value()
)
)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 17628.71287


2 simplex iterations
0 barrier iterations

x = (626.238, 1123.762), optimal value = 17628.713

In terms of production, of course, we would simply produce entire chips but it is not clear how to implement the fractional
solution (𝑥1 , 𝑥2 ) ≈ (626.238, 1123.762). Rounding down to (𝑥1 , 𝑥2 ) = (626, 1123) will intuitively yield a feasible
solution, but we might be giving away some profit and/or not using efficiently the available material. Rounding up to
(𝑥1 , 𝑥2 ) = (627, 1124) could possibly lead to an unfeasible solution for which the available material is not enough. We
can of course manually inspect by hand all these candidate integer solutions, but if the problem involved many more
decision variables or had a more complex structure, this would become much harder and possibly not lead to the true
optimal solution.
A much safer approach is to explicitly require the two decision variables to be non-negative integers, thus transforming
the original into the following mixed-integer linear optimization (MILO) problem:
max 12𝑥1 + 9𝑥2
s.t. 𝑥1 ≤ 1000 (silicon)
𝑥2 ≤ 1500 (germanium)
𝑥1 + 𝑥2 ≤ 1750 (plastic)
4.04𝑥1 + 2.02𝑥2 ≤ 4800 (copper with waste)
𝑥1 , 𝑥2 ∈ ℕ.
The optimal solution is (𝑥1 , 𝑥2 ) = (626, 1124) with a profit of 17628. Note that for this specific problem both the naive
rounding strategies outlined above would have not yielded the true optimal solution. The Python code for obtaining the
optimal solution using MILO solvers is given below.
%%writefile BIM_perturbed_MILO.mod

var x1 integer >= 0;


var x2 integer >= 0;

maximize profit: 12 * x1 + 9 * x2;

s.t. silicon: x1 <= 1000;


s.t. germanium: x2 <= 1500;
s.t. plastic: x1 + x2 <= 1750;
s.t. copper: 4.04 * x1 + 2.02 * x2 <= 4800;

Overwriting BIM_perturbed_MILO.mod

m = AMPL()
m.read("BIM_perturbed_MILO.mod")
(continues on next page)

3.1. BIM production with perturbed data 87


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

m.option["solver"] = SOLVER
m.solve()

print(
"x = ({:.3f}, {:.3f}), optimal value = {:.3f}".format(
m.var["x1"].value(), m.var["x2"].value(), m.obj["profit"].value()
)
)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 17628


2 simplex iterations
1 branching nodes

x = (626.000, 1124.000), optimal value = 17628.000

3.2 Workforce shift scheduling

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

3.2.1 Problem Statement

An article entitled “Modeling and optimization of a weekly workforce with Python and Pyomo” by Christian Carballo
Lozano posted on the Towards Data Science blog showed how to build a Pyomo model to schedule weekly shifts for a
small campus food store.
From the original article:
A new food store has been opened at the University Campus which will be open 24 hours a day, 7 days
a week. Each day, there are three eight-hour shifts. Morning shift is from 6:00 to 14:00, evening shift is
from 14:00 to 22:00 and night shift is from 22:00 to 6:00 of the next day. During the night there is only
one worker while during the day there are two, except on Sunday that there is only one for each shift. Each
worker will not exceed a maximum of 40 hours per week and have to rest for 12 hours between two shifts.
As for the weekly rest days, an employee who rests one Sunday will also prefer to do the same that Saturday.
In principle, there are available ten employees, which is clearly over-sized. The less the workers are needed,
the more the resources for other stores.

3.2. Workforce shift scheduling 88


Hands-On Mathematical Optimization with AMPL in Python

Here we revisit the example with a new model demonstrating how to use indexed sets in AMPL, and how to use the model
solution to create useful visualizations and reports for workers and managers.

3.2.2 Model formulation

Model sets

This problem requires assignment of an unspecified number of workers to a predetermined set of shifts. There are three
shifts per day, seven days per week. These observations suggest the need for three ordered sets:
• WORKERS with 𝑁 elements representing workers. 𝑁 is as input to a function creating an instance of the model.
• DAYS with labeling the days of the week.
• SHIFTS labeling the shifts each day.
The problem describes additional considerations that suggest the utility of several additional sets.
• SLOTS is an ordered set of (day, shift) pairs describing all of the available shifts during the week.
• BLOCKS is an ordered set of all overlapping 24 hour periods in the week. An element of the set contains the (day,
shift) period in the corresponding period. This set will be used to limit worker assignments to no more than one
for each 24 hour period.
• WEEKENDS is a the set of all (day, shift) pairs on a weekend. This set will be used to implement worker preferences
on weekend scheduling.
These additional sets improve the readability of the model.

WORKERS = {𝑤1 , 𝑤2 , … , 𝑤1 } set of all workers


DAYS = {Mon, Tues, … , Sun} days of the week
SHIFTS = {morning, evening, night} 8 hour daily shifts
SLOTS = DAYS × SHIFTS ordered set of all (day, shift) pairs
BLOCKS ⊂ SLOTS × SLOTS × SLOTS all 24 blocks of consecutive slots
WEEKENDS ⊂ SLOTS subset of slots corresponding to weekends

Model parameters

𝑁 = number of workers
WorkersRequired𝑑,𝑠 = number of workers required for each day, shift pair (𝑑, 𝑠)

3.2. Workforce shift scheduling 89


Hands-On Mathematical Optimization with AMPL in Python

Model decision variables

1 if worker 𝑤 is assigned to day, shift pair (𝑑, 𝑠) ∈ SLOTS


assign𝑤,𝑑,𝑠 = {
0 otherwise
1 if worker 𝑤 is assigned to a weekend day, shift pair (𝑑, 𝑠) ∈ WEEKENDS
weekend𝑤 = {
0 otherwise
1 if worker 𝑤 is needed during the week
needed𝑤 = {
0 otherwise

Model constraints

Assign workers to each shift to meet staffing requirement.

∑ assign𝑤,𝑑,𝑠 ≥ WorkersRequired𝑑,𝑠 ∀(𝑑, 𝑠) ∈ SLOTS


𝑤∈ WORKERS

Assign no more than 40 hours per week to each worker.

8 ∑ assign𝑤,𝑑,𝑠 ≤ 40 ∀𝑤 ∈ WORKERS
𝑑,𝑠∈ SLOTS

Assign no more than one shift in each 24 hour period.

assign𝑤,𝑑 + assign𝑤,𝑑 + assign𝑤,𝑑 ≤1 ∀𝑤 ∈ WORKERS


1 ,𝑠1 2 ,𝑠2 3 ,𝑠3

∀((𝑑1 , 𝑠1 ), (𝑑2 , 𝑠2 ), (𝑑3 , 𝑠3 )) ∈ BLOCKS

Do not assign any shift to a worker that is not needed during the week.

∑ assign𝑤,𝑑,𝑠 ≤ 𝑀SLOTS ⋅ needed𝑤 ∀𝑤 ∈ WORKERS


𝑑,𝑠∈ SLOTS

Do not assign any weekend shift to a worker that is not needed during the weekend.

∑ assign𝑤,𝑑,𝑠 ≤ 𝑀WEEKENDS ⋅ weekend𝑤 ∀𝑤 ∈ WORKERS


𝑑,𝑠∈ WEEKENDS

3.2. Workforce shift scheduling 90


Hands-On Mathematical Optimization with AMPL in Python

Model objective

The model objective is to minimize the overall number of workers needed to fill the shift and work requirements while
also attempting to meet worker preferences regarding weekend shift assignments. This is formulated here as an objective
for minimizing a weighted sum of the number of workers needed to meet all shift requirements and the number of workers
assigned to weekend shifts. The positive weight 𝛾 determines the relative importance of these two measures of a desirable
shift schedule.

min ( ∑ needed𝑤 + 𝛾 ∑ weekend𝑤 ))


𝑤∈ WORKERS 𝑤∈ WORKERS

3.2.3 AMPL implementation

%%writefile shift_schedule.mod

# sets
# ordered set of avaiable workers
set WORKERS ordered;
# ordered sets of days and shifts
set DAYS ordered;
set SHIFTS ordered;
# set of day, shift time slots
set SLOTS within {DAYS, SHIFTS};
# set of 24 hour time blocks
set BLOCKS within {SLOTS, SLOTS, SLOTS};
# set of weekend shifts
set WEEKENDS within SLOTS;

# parameters
# number of workers required for each slot
param WorkersRequired{SLOTS};
# max hours per week per worker
param Hours;
# weight of the weekend component in the objective function
param gamma default 0.1;

# variables
# assign[worker, day, shift] = 1 assigns worker to a time slot
var assign{WORKERS, SLOTS} binary;
# weekend[worker] = 1 worker is assigned weekend shift
var weekend{WORKERS} binary;
# needed[worker] = 1
var needed{WORKERS} binary;

# constraints
# assign a sufficient number of workers for each time slot
s.t. required_workers {(day, shift) in SLOTS}:
WorkersRequired[day, shift] == sum{worker in WORKERS} assign[worker, day, shift];
# workers limited to forty hours per week assuming 8 hours per shift
s.t. forty_hour_limit {worker in WORKERS}:
8 * sum{(day, shift) in SLOTS} assign[worker, day, shift] <= Hours;
# workers are assigned no more than one time slot per 24 time block
s.t. required_rest {worker in WORKERS, (d1, s1, d2, s2, d3, s3) in BLOCKS}:
assign[worker, d1, s1] + assign[worker, d2, s2] + assign[worker, d3, s3] <= 1;
# determine if a worker is assigned to any shift
(continues on next page)

3.2. Workforce shift scheduling 91


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


s.t. is_needed {worker in WORKERS}:
sum{(day, shift) in SLOTS} assign[worker, day, shift] <= card(SLOTS) *␣
↪needed[worker];

# determine if a worker is assigned to a weekend shift


s.t. is_weekend {worker in WORKERS}:
6 * weekend[worker] >= sum{(day, shift) in WEEKENDS} assign[worker, day, shift];

# choose(uncomment) one of the following objective functions

# minimize a blended objective of needed workers and needed weekend workers


#minimize minimize_workers:
#sum{worker in WORKERS} needed[worker] + gamma * sum{worker in WORKERS}␣
↪weekend[worker];

# weighted version: since we are minimizing the objective function a smaller weight␣
↪will give higher

# priority to a given worker


# weight is obtained with the ord function, that returns the index of a given worker␣
↪in the weights SET

minimize minimize_workers:
sum{worker in WORKERS} ord(worker) * needed[worker] +
gamma * sum{worker in WORKERS} ord(worker) * weekend[worker];

Overwriting shift_schedule.mod

def shift_schedule(N=10, hours=40):


"""return a solved model assigning N workers to shifts"""

workers = [f"W{i:02d}" for i in range(1, N + 1)]


days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
shifts = ["morning", "evening", "night"]
slots = [(d, s) for d in days for s in shifts]
blocks = [(slots[i] + slots[i + 1] + slots[i + 2]) for i in range(len(slots) - 2)]
weekends = [(d, s) for (d, s) in slots if d in ["Sat", "Sun"]]
workers_required = {
(d, s): (1 if s in ["night"] or d in ["Sun"] else 2)
for d in days
for s in shifts
}

m = AMPL()
m.read("shift_schedule.mod")

m.set["WORKERS"] = workers
m.set["DAYS"] = days
m.set["SHIFTS"] = shifts
m.set["SLOTS"] = slots
m.set["BLOCKS"] = blocks
m.set["WEEKENDS"] = weekends
m.param["WorkersRequired"] = workers_required
m.param["Hours"] = hours

m.option["solver"] = SOLVER
m.solve()

return m
(continues on next page)

3.2. Workforce shift scheduling 92


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

m = shift_schedule(10, 40)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 29.5


12934 simplex iterations
7 branching nodes

3.2.4 Visualizing the solution

Scheduling applications generate a considerable amount of data to be used by the participants. The following cells demon-
strate the preparation of charts and reports that can be used to communicate scheduling information to the store manage-
ment and shift workers.

import matplotlib.pyplot as plt


from matplotlib.patches import Rectangle

def visualize(m):
workers = m.set["WORKERS"].to_list()
slots = m.set["SLOTS"].to_list()
days = m.set["DAYS"].to_list()

assign = m.var["assign"].to_dict()
needed = m.var["needed"].to_dict()
weekend = m.var["weekend"].to_dict()

bw = 1.0
fig, ax = plt.subplots(1, 1, figsize=(12, 1 + 0.3 * len(workers)))
ax.set_title("Shift Schedule")

# x axis styling
ax.set_xlim(0, len(slots))
colors = ["teal", "gold", "magenta"]
for i in range(len(slots) + 1):
ax.axvline(i, lw=0.3)
ax.fill_between(
[i, i + 1], [0] * 2, [len(workers)] * 2, alpha=0.1, color=colors[i % 3]
)
for i in range(len(days) + 1):
ax.axvline(3 * i, lw=1)
ax.set_xticks([3 * i + 1.5 for i in range(len(days))])
ax.set_xticklabels(days)
ax.set_xlabel("Shift")

# y axis styling
ax.set_ylim(0, len(workers))
for j in range(len(workers) + 1):
ax.axhline(j, lw=0.3)
ax.set_yticks([j + 0.5 for j in range(len(workers))])
ax.set_yticklabels(workers)
ax.set_ylabel("Worker")

(continues on next page)

3.2. Workforce shift scheduling 93


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


# show shift assignments
for i, slot in enumerate(slots):
day, shift = slot
for j, worker in enumerate(workers):
if round(assign[worker, day, shift]):
ax.add_patch(Rectangle((i, j + (1 - bw) / 2), 1, bw, edgecolor="b"))
ax.text(
i + 1 / 2, j + 1 / 2, worker, ha="center", va="center", color="w"
)

# display needed and weekend data


for j, worker in enumerate(workers):
if not needed[worker]:
ax.fill_between(
[0, len(slots)], [j, j], [j + 1, j + 1], color="k", alpha=0.3
)
if needed[worker] and not weekend[worker]:
ax.fill_between(
[15, len(slots)], [j, j], [j + 1, j + 1], color="k", alpha=0.3
)

visualize(m)

3.2.5 Implementing the Schedule with Reports

Optimal planning models can generate large amounts of data that need to be summarized and communicated to individuals
for implementation.

3.2. Workforce shift scheduling 94


Hands-On Mathematical Optimization with AMPL in Python

Creating a master schedule with categorical data

The following cell creates a pandas DataFrame comprising all active assignments from the solved model. The data consists
of all (worker, day, shift) tuples for which the binary decision variable m.assign equals one.
The data is categorical consisting of a unique id for each worker, a day of the week, or the name of a shift. Each of the
categories has a natural ordering that should be used in creating reports. This is implemented using the Categori-
calDtype class.

import pandas as pd

# get the schedule from AMPL and convert to pandas DataFrame with defined columns
schedule = m.var["assign"].to_list()
schedule = pd.DataFrame(
[[s[0], s[1], s[2]] for s in schedule if s[3]], columns=["worker", "day", "shift"]
)

# create and assign a worker category type


worker_type = pd.CategoricalDtype(
categories=m.set["WORKERS"].to_list(), ordered=True
)
schedule["worker"] = schedule["worker"].astype(worker_type)

# create and assign a day category type


day_type = pd.CategoricalDtype(
categories=m.set["DAYS"].to_list(), ordered=True
)
schedule["day"] = schedule["day"].astype(day_type)

# create and assign a shift category type


shift_type = pd.CategoricalDtype(
categories=m.set["SHIFTS"].to_list(), ordered=True
)
schedule["shift"] = schedule["shift"].astype(shift_type)

# demonstrate sorting and display of the master schedule


schedule.sort_values(by=["day", "shift", "worker"])

worker day shift


1 W01 Mon morning
8 W02 Mon morning
42 W06 Mon morning
49 W07 Mon morning
7 W02 Mon evening
16 W03 Mon evening
31 W05 Mon evening
41 W06 Mon evening
24 W04 Mon night
32 W05 Mon night
28 W04 Tue morning
46 W06 Tue morning
51 W07 Tue morning
5 W01 Tue evening
12 W02 Tue evening
38 W05 Tue night
22 W03 Wed morning
47 W06 Wed morning
(continues on next page)

3.2. Workforce shift scheduling 95


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


52 W07 Wed morning
6 W01 Wed evening
13 W02 Wed evening
23 W03 Wed night
29 W04 Wed night
37 W05 Thu morning
45 W06 Thu morning
4 W01 Thu evening
11 W02 Thu evening
50 W07 Thu evening
21 W03 Thu night
27 W04 Thu night
14 W03 Fri morning
30 W05 Fri morning
0 W01 Fri evening
39 W06 Fri evening
48 W07 Fri evening
15 W03 Fri night
40 W06 Fri night
17 W03 Sat morning
25 W04 Sat morning
34 W05 Sat morning
43 W06 Sat morning
2 W01 Sat evening
9 W02 Sat evening
33 W05 Sat evening
35 W05 Sat night
19 W03 Sun morning
26 W04 Sun morning
3 W01 Sun evening
18 W03 Sun evening
44 W06 Sun evening
10 W02 Sun night
20 W03 Sun night
36 W05 Sun night

Reports for workers

Each worker should receive a report detailing their shift assignments. The reports are created by sorting the master
schedule by worker, day, and shift, then grouping by worker.
# sort schedule by worker
schedule = schedule.sort_values(by=["worker", "day", "shift"])

# print worker schedules


for worker, worker_schedule in schedule.groupby("worker"):
print(f"\n Work schedule for {worker}")
if len(worker_schedule) > 0:
for s in worker_schedule.to_string(index=False).split("\n"):
print(s)
else:
print(" no assigned shifts")

Work schedule for W01


worker day shift
(continues on next page)

3.2. Workforce shift scheduling 96


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


W01 Mon morning
W01 Tue evening
W01 Wed evening
W01 Thu evening
W01 Fri evening
W01 Sat evening
W01 Sun evening

Work schedule for W02


worker day shift
W02 Mon morning
W02 Mon evening
W02 Tue evening
W02 Wed evening
W02 Thu evening
W02 Sat evening
W02 Sun night

Work schedule for W03


worker day shift
W03 Mon evening
W03 Wed morning
W03 Wed night
W03 Thu night
W03 Fri morning
W03 Fri night
W03 Sat morning
W03 Sun morning
W03 Sun evening
W03 Sun night

Work schedule for W04


worker day shift
W04 Mon night
W04 Tue morning
W04 Wed night
W04 Thu night
W04 Sat morning
W04 Sun morning

Work schedule for W05


worker day shift
W05 Mon evening
W05 Mon night
W05 Tue night
W05 Thu morning
W05 Fri morning
W05 Sat morning
W05 Sat evening
W05 Sat night
W05 Sun night

Work schedule for W06


worker day shift
W06 Mon morning
W06 Mon evening
W06 Tue morning
(continues on next page)

3.2. Workforce shift scheduling 97


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


W06 Wed morning
W06 Thu morning
W06 Fri evening
W06 Fri night
W06 Sat morning
W06 Sun evening

Work schedule for W07


worker day shift
W07 Mon morning
W07 Tue morning
W07 Wed morning
W07 Thu evening
W07 Fri evening

Work schedule for W08


no assigned shifts

Work schedule for W09


no assigned shifts

Work schedule for W10


no assigned shifts

Reports for store managers

The store managers need reports listing workers by assigned day and shift.
# sort by day, shift, worker
schedule = schedule.sort_values(by=["day", "shift", "worker"])

for day, day_schedule in schedule.groupby("day"):


print(f"\nShift schedule for {day}")
for shift, shift_schedule in day_schedule.groupby("shift"):
print(f" {shift} shift: ", end="")
print(", ".join([worker for worker in shift_schedule["worker"].values]))

Shift schedule for Mon


morning shift: W01, W02, W06, W07
evening shift: W02, W03, W05, W06
night shift: W04, W05

Shift schedule for Tue


morning shift: W04, W06, W07
evening shift: W01, W02
night shift: W05

Shift schedule for Wed


morning shift: W03, W06, W07
evening shift: W01, W02
night shift: W03, W04

Shift schedule for Thu


morning shift: W05, W06
evening shift: W01, W02, W07
(continues on next page)

3.2. Workforce shift scheduling 98


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


night shift: W03, W04

Shift schedule for Fri


morning shift: W03, W05
evening shift: W01, W06, W07
night shift: W03, W06

Shift schedule for Sat


morning shift: W03, W04, W05, W06
evening shift: W01, W02, W05
night shift: W05

Shift schedule for Sun


morning shift: W03, W04
evening shift: W01, W03, W06
night shift: W02, W03, W05

3.2.6 Suggested Exercises

1. How many workers will be required to operate the food store if all workers are limited to four shifts per week, i.e.,
32 hours? How about 3 shifts or 24 hours per week?
2. Add a second class of workers called “manager”. There needs to be one manager on duty for every shift, that
morning shifts require 3 staff on duty, evening shifts 2, and night shifts 1.
3. Add a third class of workers called “part_time”. Part time workers are limited to no more than 30 hours per week.
4. Modify the problem formulation and objective to spread the shifts out amongst all workers, attempting to equalize
the total number of assigned shifts, and similar numbers of day, evening, night, and weekend shifts.
5. Find the minimum cost staffing plan assuming managers cost 30 euros per hour + 100 euros per week in fixed
benefits, regular workers cost 20 euros per hour plus 80 euros per week in fixed benefits, and part time workers cost
15 euros per week with no benefits.

3.3 Production model using disjunctions

# install dependencies and select solver


%pip install -q amplpy

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

3.3. Production model using disjunctions 99


Hands-On Mathematical Optimization with AMPL in Python

3.3.1 Disjunctions

Disjunctions appear in applications where there is choice among discrete alternatives. Given two logical propositions 𝛼
and 𝛽, the “or” disjunction is denoted by ∨ and defined by the truth table

𝛼 𝛽 𝛼∨𝛽
False False False
True False True
False True True
True True True

The “exclusive or” is denoted by ⊻ and defined by the truth table

𝛼 𝛽 𝛼⊻𝛽
False False False
True False True
False True True
True True False

This notebook shows how to express disjunctions in AMPL models using the Generalized Disjunctive Programming syntax
for a simple production model.

3.3.2 Multi-product factory optimization

A small production facility produces two products, 𝑋 and 𝑌 . With current technology 𝛼, the facility is subject to the
following conditions and constraints:
• Product 𝑋 requires 1 hour of labor A, 2 hours of labor B, and 100€ of raw material. Product 𝑋 sells for 270€
per unit. The daily demand is limited to 40 units.
• Product 𝑌 requires 1 hour of labor A, 1 hour of labor B, and 90€ of raw material. Product 𝑌 sells for 210€ per
unit with unlimited demand.
• There are 80 hours per day of labor A available at a cost of 50€/hour.
• There are 100 hours per day of labor B available at a cost of 40€/hour.
Using the given data we see that the net profit for each unit of 𝑋 and 𝑌 is 40€ and 30€, respectively. The optimal product
strategy is the solution to a linear optimization

max profit
𝑥,𝑦≥0

s.t. profit = 40𝑥 + 30𝑦


𝑥 ≤ 40 (demand)
𝑥 + 𝑦 ≤ 80 (labor A)
2𝑥 + 𝑦 ≤ 100 (labor B)

%%writefile multi_product_factory.mod

# decision variables
var profit;
var production_x >= 0;
var production_y >= 0;
(continues on next page)

3.3. Production model using disjunctions 100


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

# profit objective
maximize maximize_profit: profit;

# constraints
s.t. profit_expr: profit == 40 * production_x + 30 * production_y;
s.t. demand: production_x <= 40;
s.t. laborA: production_x + production_y <= 80;
s.t. laborB: 2 * production_x + production_y <= 100;

Overwriting multi_product_factory.mod

m = AMPL()
m.read("multi_product_factory.mod")
m.option["solver"] = SOLVER
m.solve()

print(f"Profit = {m.var['profit'].value():.2f} €")


print(f"Production X = {m.var['production_x'].value()}")
print(f"Production Y = {m.var['production_y'].value()}")

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 2600


2 simplex iterations
0 barrier iterations
Profit = 2600.00 €
Production X = 20.0
Production Y = 60.0

Now suppose a new technology 𝛽 is available that affects that lowers the cost of product 𝑋. With the new technology,
only 1.5 hours of labor B is required per unit of 𝑋.
The net profit for unit of product 𝑋 with technology 𝛼 is equal to 270 − 100 − 50 − 2 ⋅ 40 = 40€
The net profit for unit of product 𝑋 with technology 𝛽 is equal to 270 − 100 − 50 − 1.5 ⋅ 40 = 60€
The decision here is whether to use technology 𝛼 or 𝛽. There are several commonly used techniques for embedding
disjunctions into mixed-integer linear optimization problems. The “big-M” technique introduces a binary decision variable
for every exclusive-or disjunction between two constraints.

3.3.3 MILO implementation

We can formulate this problem as the following mixed-integer linear optimization (MILO) problem:

max profit
𝑥,𝑦≥0,𝑧∈𝔹

s.t. 𝑥 ≤ 40 (demand)
𝑥 + 𝑦 ≤ 80 (labor A)
profit ≤ 40𝑥 + 30𝑦 + 𝑀 𝑧
profit ≤ 60𝑥 + 30𝑦 + 𝑀 (1 − 𝑧)
2𝑥 + 𝑦 ≤ 100 + 𝑀 𝑧
1.5𝑥 + 𝑦 ≤ 100 + 𝑀 (1 − 𝑧).

where the variable 𝑧 ∈ {0, 1} “activates” the constraints related to the old or new technology, respectively, and 𝑀 is a
big enough number. The corresponding AMPL implementation is given by:

3.3. Production model using disjunctions 101


Hands-On Mathematical Optimization with AMPL in Python

%%writefile multi_product_factory_milo.mod

# decision variables
var profit;
var production_x >= 0;
var production_y >= 0;
var z binary;
param M = 10000;

# profit objective
maximize maximize_profit: profit;

# constraints
s.t. profit_constr_1: profit <= 40 * production_x + 30 * production_y + M * z;
s.t. profit_constr_2: profit <= 60 * production_x + 30 * production_y + M * (1 - z);
s.t. demand: production_x <= 40;
s.t. laborA: production_x + production_y <= 80;
s.t. laborB_1: 2 * production_x + production_y <= 100 + M * z;
s.t. laborB_2: 1.5 * production_x + production_y <= 100 + M * (1 - z);

Overwriting multi_product_factory_milo.mod

m = AMPL()
m.read("multi_product_factory_milo.mod")
m.option["solver"] = SOLVER
m.solve()

print(f"Profit = {m.var['profit'].value():.2f} €")


print(f"Production X = {m.var['production_x'].value()}")
print(f"Production Y = {m.var['production_y'].value()}")

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 3600


8 simplex iterations
1 branching nodes
Profit = 3600.00 €
Production X = 40.0
Production Y = 40.0

3.3.4 Disjunctive programming implementation

Alternatively, we can formulate our problem using a disjunction, preserving the logical structure, as follows:

max profit
𝑥,𝑦≥0

s.t. 𝑥 ≤ 40 (demand)
𝑥 + 𝑦 ≤ 80 (labor A)
profit = 40𝑥 + 30𝑦 profit = 60𝑥 + 30𝑦
[ ]⊻[ ]
2𝑥 + 𝑦 ≤ 100 1.5𝑥 + 𝑦 ≤ 100

This formulation, if allowed by the software at hand, has the benefit that the software can smartly divide the solution of this
problem into sub-possibilities depending on the disjunction. AMPL supports disjunctions, as illustrated in the following
implementation:

3.3. Production model using disjunctions 102


Hands-On Mathematical Optimization with AMPL in Python

%%writefile multi_product_factory_milo_disjunctive.mod

var profit >= -1000, <= 10000;


var x >= 0, <= 1000;
var y >= 0, <= 1000;

maximize maximize_profit: profit;

s.t. demand: x <= 40;


s.t. laborA: x + y <= 80;
s.t. technologies:
((profit == 40 * x + 30 * y and 2 * x + y <= 100)
or
(profit == 60 * x + 30 * y and 1.5 * x + y <= 100))
and not
((profit == 40 * x + 30 * y and 2 * x + y <= 100)
and
(profit == 60 * x + 30 * y and 1.5 * x + y <= 100))
;

Overwriting multi_product_factory_milo_disjunctive.mod

m = AMPL()
m.read("multi_product_factory_milo_disjunctive.mod")
m.option["solver"] = SOLVER
m.solve()

print(f"Profit = {m.var['profit'].value():.2f} €")


print(f"x = {m.var['x'].value()}")
print(f"y = {m.var['y'].value()}")

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 3600


12 simplex iterations
1 branching nodes
Profit = 3600.00 €
x = 40.0
y = 40.0

3.4 Machine Scheduling

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

3.4. Machine Scheduling 103


Hands-On Mathematical Optimization with AMPL in Python

3.4.1 Problem description

“Which job should be done next?” is a question one face in modern life, whether for a busy student working on course
assignments, a courier delivering packages, a server waiting on tables in a busy restaurant, a computer processing threads,
or a machine on a complex assembly line. There are empirical answers to this question, among them “first in, first out”,
“last in, first out”, or “shortest job first”.
What we consider in this notebook is the modeling finding solutions to this class of problem using optimization techniques.
This notebook demonstrates the formulation of a model for scheduling a single machine scheduling using disjunctive
programming in AMPL. The problem is to schedule a set of jobs on a single machine given the release time, duration,
and due time for each job. Date for the example problem is from Christelle Gueret, Christian Prins, Marc Sevaux,
“Applications of Optimization with Xpress-MP,” Chapter 5, Dash Optimization, 2000.

Job data

The problem is to schedule a sequence of jobs for a single machine. The problem data presented as a Pandas dataframe
where each row corresponds to a job. The dataframe in indexed the names of the jobs. The data for each job consists
of the time when it is released for machine processing, the duration of the job, and when the it is due. The objective is,
if possible, to schedule the jobs on the machine to satisfy the due dates. If that is not possible, then the objective is to
schedule the jobs to minimizing some measure of schedule “badness”.

import pandas as pd

jobs = pd.DataFrame(
{
"A": {"release": 2, "duration": 5, "due": 10},
"B": {"release": 5, "duration": 6, "due": 21},
"C": {"release": 4, "duration": 8, "due": 15},
"D": {"release": 0, "duration": 4, "due": 10},
"E": {"release": 0, "duration": 2, "due": 5},
"F": {"release": 8, "duration": 3, "due": 15},
"G": {"release": 9, "duration": 2, "due": 22},
}
).T

jobs

release duration due


A 2 5 10
B 5 6 21
C 4 8 15
D 0 4 10
E 0 2 5
F 8 3 15
G 9 2 22

3.4. Machine Scheduling 104


Hands-On Mathematical Optimization with AMPL in Python

Schedule data

A schedule is also represented here by a Pandas dataframe indexed by job names. The columns indicate the start, finish,
and the amount by each job is past due. The following cell creates a schedule when the jobs are executed in the order
given by the jobs dataframe.

def schedule_jobs(jobs, seq):


"""
Schedule a sequence of jobs based on their release times, durations, and due␣
↪times.

Args:
jobs (DataFrame): Job information with columns "release", "duration", and "due
↪".

seq (DataFrame): A list of job indices representing the sequence in which the␣
↪jobs will be scheduled.

Return:
A DataFrame containing schedule information, including "start", "finish", and
↪"past" due times.

"""
schedule = pd.DataFrame(index=jobs.index)
t = 0
for job in seq:
t = max(t, jobs.loc[job]["release"])
schedule.loc[job, "start"] = t
t += jobs.loc[job, "duration"]
schedule.loc[job, "finish"] = t
schedule.loc[job, "past"] = max(0, t - jobs.loc[job, "due"])

return schedule

schedule = schedule_jobs(jobs, jobs.index)


schedule

start finish past


A 2.0 7.0 0.0
B 7.0 13.0 0.0
C 13.0 21.0 6.0
D 21.0 25.0 15.0
E 25.0 27.0 22.0
F 27.0 30.0 15.0
G 30.0 32.0 10.0

Gantt chart

A traditional means of visualizing scheduling data in the form of a Gantt chart. The next cell presents a function gantt
that plots a Gantt chart given jobs and schedule information.

import matplotlib.pyplot as plt


from matplotlib.lines import Line2D

def gantt(jobs, schedule, title="Job Schedule"):


(continues on next page)

3.4. Machine Scheduling 105


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


"""
Plot a Gantt chart for a given job schedule.

Args:
jobs (DataFrame): Contains job release times and due times.
schedule (DataFrame): Contains job start times, finish times, and past due␣
↪times.

title (str, optional): Title of the Gantt chart. Defaults to "Job Schedule".
"""
w = 0.25 # bar width
fig, ax = plt.subplots(1, 1, figsize=(10, 0.7 * len(jobs.index)))

for k, job in enumerate(jobs.index):


r = jobs.loc[job, "release"]
d = jobs.loc[job, "due"]
s = schedule.loc[job, "start"]
f = schedule.loc[job, "finish"]

# Show job release-due window


ax.fill_between(
[r, d], [-k - w, -k - w], [-k + w, -k + w], lw=1, color="k", alpha=0.2
)
ax.plot(
[r, r, d, d, r], [-k - w, -k + w, -k + w, -k - w, -k - w], lw=0.5, color=
↪ "k"
)

# Show job start-finish window


color = "g" if f <= d else "r"
ax.fill_between(
[s, f], [-k - w, -k - w], [-k + w, -k + w], color=color, alpha=0.6
)
ax.text(
(s + f) / 2.0,
-k,
"Job " + job,
color="white",
weight="bold",
ha="center",
va="center",
)

# If past due
if f > d:
ax.plot([d] * 2, [-k + w, -k + 2 * w], lw=0.5, color="k")
ax.plot([f] * 2, [-k + w, -k + 2 * w], lw=0.5, color="k")
ax.plot([d, f], [-k + 1.5 * w] * 2, solid_capstyle="butt", lw=1, color="k
↪ ")
ax.text(
f + 0.5,
-k + 1.5 * w,
f"{schedule.loc[job, 'past']} past due",
va="center",
)

total_past_due = schedule["past"].sum()
ax.set_ylim(-len(jobs.index), 1)
(continues on next page)

3.4. Machine Scheduling 106


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


ax.set_title(f"{title}: Total past due = {total_past_due}")
ax.set_xlabel("Time")
ax.set_ylabel("Jobs")
ax.set_yticks([]) # range(len(jobs.index)), jobs.index)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.spines["top"].set_visible(False)

custom_lines = [
Line2D([0], [0], c="k", lw=10, alpha=0.2),
Line2D([0], [0], c="g", lw=10, alpha=0.6),
Line2D([0], [0], c="r", lw=10, alpha=0.6),
]
ax.legend(
custom_lines,
["Job release/due window", "Completed on time", "Completed past due"],
)

gantt(jobs, schedule, "Execute Jobs in Order")

3.4.2 Empirical Scheduling

To provide a comparison to scheduling with optimization models, the following cells implement two well known and
accepted empirical rules for scheduling jobs on a single machine:
• First in, first out (FIFO)
• Earliest due data (EDD)

3.4. Machine Scheduling 107


Hands-On Mathematical Optimization with AMPL in Python

First-in, first-out (FIFO)

One of the most common scheduling rules is to execute jobs in the order they are released for processing, in other words
“first-in, first-out” (FIFO). The following cell creates a Pandas dataframe is indexed by job names. The start time, finish
time, and, if past due, the amount by which the job is past due.

fifo = schedule_jobs(jobs, jobs.sort_values(by="release").index)


gantt(jobs, fifo, "First in, First out")

Earliest due date (EDD)

When due dates are known, a common scheduling rule is to prioritize jobs by due date. This strategy will be familiar to
any student deciding which homework assignment should to work on next.

edd = schedule_jobs(jobs, jobs.sort_values(by="due").index)


gantt(jobs, edd, "First in, First out")

3.4. Machine Scheduling 108


Hands-On Mathematical Optimization with AMPL in Python

3.4.3 Optimal Scheduling

The modeling starts by defining the problem data.

Symbol Description
release𝑗 when job 𝑗 is available
duration𝑗 how long job 𝑗
due𝑗 when job 𝑗 is due

The essential decision variable is the time at which the job starts processing.

Symbol Description
start𝑗 when job 𝑗 starts
finish𝑗 when job 𝑗 finishes
past𝑗 how long job 𝑗 is past due

Depending on application and circumstances, various objectives can be considered. Suitable objectives include the total
number of late jobs, the longest past due interval, or the sum of all past due intervals. The following AMPL model
minimizes the sum of past due intervals, that is

min ∑ past𝑗
𝑗

Constraints describe the required logical relationships among the decision variables. For example, a job cannot start until
it is released for processing

start𝑗 ≥ release𝑗

3.4. Machine Scheduling 109


Hands-On Mathematical Optimization with AMPL in Python

Once started, machine processing continues until the job is finished. The finish time for each job is compared to the due
time, and any past due interval is stored the past𝑗 decision variable.

finish𝑗 = start𝑗 + duration𝑗


past𝑗 ≥ finish𝑗 − due𝑗
past𝑗 ≥ 0

The final set of constraints require that no pair of jobs be operating on the same machine at the same time. For this
purpose, we consider each unique pair (𝑖, 𝑗) where the constraint 𝑖 < 𝑗 to imposed to avoid considering the same pair
twice. Then for any unique pair 𝑖 and 𝑗, either 𝑖 finishes before 𝑗 starts, or 𝑗 finishes before 𝑖 starts. This is expressed as
the family of disjunctions

[finish𝑖 ≤ start𝑗 ] ⊻ [finish𝑗 ≤ start𝑖 ] ∀𝑖 < 𝑗

This model and constraints can be directly translated to AMPL.


%%writefile job_machine_scheduling.mod

set JOBS;
set PAIRS within{JOBS, JOBS};

param release{JOBS};
param duration{JOBS};
param due{JOBS};

var start{JOBS} >= 0, <= 300;


var finish{JOBS} >= 0, <= 300;
var past{JOBS} >= 0, <= 300;

s.t. job_release {job in JOBS}: start[job] >= release[job];


s.t. job_duration {job in JOBS}: finish[job] == start[job] + duration[job];
s.t. past_due_constraint {job in JOBS}: past[job] >= finish[job] - due[job];
s.t. machine_deconflict {(job_a, job_b) in PAIRS}:
(finish[job_a] <= start[job_b] or finish[job_b] <= start[job_a])
and not
(finish[job_a] <= start[job_b] and finish[job_b] <= start[job_a])
;

minimize minimize_past: sum{job in JOBS} past[job];

Overwriting job_machine_scheduling.mod

def build_model(jobs):
m = AMPL()
m.read("job_machine_scheduling.mod")
m.set_data(jobs, "JOBS")

pairs = [(i, j) for i in jobs.index for j in jobs.index if i < j]


m.set["PAIRS"] = pairs

return m

def solve_model(m, solver_name=SOLVER):


m.option["solver"] = solver_name
m.solve()
(continues on next page)

3.4. Machine Scheduling 110


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


schedule = pd.DataFrame(
{
"start": m.var["start"].to_dict(),
"finish": m.var["finish"].to_dict(),
"past": m.var["past"].to_dict(),
}
)
schedule["past"] = schedule["past"].round()
return schedule

model = build_model(jobs)
schedule = solve_model(model)

display(schedule)

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 16


2447 simplex iterations
85 branching nodes

start finish past


A 6.0 11.0 1.0
B 14.0 20.0 0.0
C 22.0 30.0 15.0
D 2.0 6.0 0.0
E 0.0 2.0 0.0
F 11.0 14.0 0.0
G 20.0 22.0 -0.0

gantt(jobs, schedule, "Minimize total past due")

3.4. Machine Scheduling 111


Hands-On Mathematical Optimization with AMPL in Python

3.5 Recharging strategy for an electric vehicle

Whether it is to visit family, take a sightseeing tour or call on business associates, planning a road trip is a familiar and
routine task. Here we consider a road trip on a pre-determined route for which need to plan rest and recharging stops.
This example demonstrates use of AMPL disjunctions to model the decisions on where to stop.

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "cbc"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

3.5.1 Problem Statement

Given the current location 𝑥, battery charge 𝑐, and planning horizon 𝐷, the task is to plan a series of recharging and rest
stops. Data is provided for the location and the charging rate available at each charging stations. The objective is to drive
from location 𝑥 to location 𝑥 + 𝐷 in as little time as possible subject to the following constraints:
• To allow for unforeseen events, the state of charge should never drop below 20% of the maximum capacity.
• The the maximum charge is 𝑐𝑚𝑎𝑥 = 80 kWh.
• For comfort, no more than 4 hours should pass between stops, and that a rest stop should last at least 𝑡𝑟𝑒𝑠𝑡 .
• Any stop includes a 𝑡𝑙𝑜𝑠𝑡 = 10 minutes of “lost time”.
For this first model we make several simplifying assumptions that can be relaxed as a later time.
• Travel is at a constant speed 𝑣 = 100 km per hour and a constant discharge rate 𝑅 = 0.24 kWh/km
• The batteries recharge at a constant rate determined by the charging station.
• Only consider stops at the recharging stations.

3.5.2 Modeling

The problem statement identifies four state variables.


• 𝑐 the current battery charge
• 𝑟 the elapsed time since the last rest stop
• 𝑡 elapsed time since the start of the trip
• 𝑥 the current location
The charging stations are located at positions 𝑑𝑖 for 𝑖 ∈ 𝐼 with capacity 𝐶𝑖 . The arrival time at charging station 𝑖 is given

3.5. Recharging strategy for an electric vehicle 112


Hands-On Mathematical Optimization with AMPL in Python

by
𝑑𝑒𝑝
𝑐𝑖𝑎𝑟𝑟 = 𝑐𝑖−1 − 𝑅(𝑑𝑖 − 𝑑𝑖−1 )
𝑑𝑒𝑝 𝑑 − 𝑑𝑖−1
𝑟𝑖𝑎𝑟𝑟 = 𝑟𝑖−1 + 𝑖
𝑣
𝑎𝑟𝑟 𝑑𝑒𝑝 𝑑𝑖 − 𝑑𝑖−1
𝑡𝑖 = 𝑡𝑖−1 +
𝑣

where the script 𝑡𝑑𝑒𝑝


𝑖−1 refers to departure from the prior location. At each charging location there is a decision to make of
whether to stop, rest, and recharge. If the decision is positive, then

𝑐𝑖𝑑𝑒𝑝 ≤ 𝑐𝑚𝑎𝑥
𝑟𝑖𝑑𝑒𝑝 = 0
𝑐𝑖𝑑𝑒𝑝 − 𝑐𝑖𝑎𝑟𝑟
𝑡𝑑𝑒𝑝
𝑖 ≥ 𝑡𝑎𝑟𝑟
𝑖 + 𝑡𝑙𝑜𝑠𝑡 +
𝐶𝑖
𝑡𝑑𝑒𝑝
𝑖 ≥ 𝑡𝑎𝑟𝑟
𝑖 + 𝑡𝑟𝑒𝑠𝑡

which account for the battery charge, the lost time and time required for battery charging, and allows for a minimum rest
time. On the other hand, if a decision is make to skip the charging and rest opportunity,

𝑐𝑖𝑑𝑒𝑝 = 𝑐𝑖𝑎𝑟𝑟
𝑟𝑖𝑑𝑒𝑝 = 𝑟𝑖𝑎𝑟𝑟
𝑡𝑑𝑒𝑝
𝑖 = 𝑡𝑎𝑟𝑟
𝑖

The latter sets of constraints have an exclusive-or relationship. That is, either one or the other of the constraint sets hold,
but not both.
min 𝑡𝑎𝑟𝑟
𝑛+1
s.t. 𝑟𝑖𝑎𝑟𝑟 ≤ 𝑟𝑚𝑎𝑥 ∀𝑖 ∈ 𝐼
𝑐𝑖𝑎𝑟𝑟 ≥𝑐 𝑚𝑖𝑛
∀𝑖 ∈ 𝐼
𝑑𝑒𝑝
𝑐𝑖𝑎𝑟𝑟 = 𝑐𝑖−1
− 𝑅(𝑑𝑖 − 𝑑𝑖−1 ) ∀𝑖 ∈ 𝐼
𝑑𝑒𝑝 𝑑 − 𝑑𝑖−1
𝑟𝑖𝑎𝑟𝑟 = 𝑟𝑖−1 + 𝑖 ∀𝑖 ∈ 𝐼
𝑣
𝑎𝑟𝑟 𝑑𝑒𝑝 𝑑𝑖 − 𝑑𝑖−1
𝑡𝑖 = 𝑡𝑖−1 + ∀𝑖 ∈ 𝐼
𝑣
𝑐𝑖𝑑𝑒𝑝 ≤ 𝑐𝑚𝑎𝑥
⎡ 𝑑𝑒𝑝 ⎤ 𝑐𝑖𝑑𝑒𝑝 = 𝑐𝑖𝑎𝑟𝑟
⎢𝑟𝑖 = 0 ⎥ ⎡ 𝑑𝑒𝑝 ⎤
⎢ 𝑡𝑑𝑒𝑝 ≥ 𝑡𝑎𝑟𝑟 + 𝑡 𝑐𝑖𝑑𝑒𝑝 −𝑐𝑖𝑎𝑟𝑟 ⎥ ⊻ ⎢𝑟𝑖 = 𝑟𝑖𝑎𝑟𝑟 ⎥ ∀ 𝑖 ∈ 𝐼.
⎢ 𝑖 𝑖 𝑙𝑜𝑠𝑡 + 𝐶𝑖 ⎥ 𝑑𝑒𝑝 𝑎𝑟𝑟
𝑑𝑒𝑝 ⎣ 𝑡𝑖 = 𝑡𝑖 ⎦
⎣ 𝑡𝑖 ≥ 𝑡𝑎𝑟𝑟
𝑖 + 𝑡 𝑟𝑒𝑠𝑡 ⎦

3.5.3 Charging Station Information

import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

# specify number of charging stations


n_charging_stations = 20
(continues on next page)

3.5. Recharging strategy for an electric vehicle 113


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

# randomly distribute charging stations along a fixed route


np.random.seed(1842)
d = np.round(np.cumsum(np.random.triangular(20, 150, 223, n_charging_stations)), 1)

# randomly assign changing capacities


c = np.random.choice([50, 100, 150, 250], n_charging_stations, p=[0.2, 0.4, 0.3, 0.1])

# assign names to the charging stations


s = [f"S_{i:02d}" for i in range(n_charging_stations)]

stations = pd.DataFrame([s, d, c]).T


stations.columns = ["name", "location", "kw"]
display(stations)

name location kw
0 S_00 191.6 150
1 S_01 310.6 100
2 S_02 516.0 50
3 S_03 683.6 50
4 S_04 769.9 50
5 S_05 869.7 100
6 S_06 1009.1 150
7 S_07 1164.7 100
8 S_08 1230.8 100
9 S_09 1350.8 250
10 S_10 1508.4 100
11 S_11 1639.8 100
12 S_12 1809.4 150
13 S_13 1947.3 250
14 S_14 2145.2 150
15 S_15 2337.5 100
16 S_16 2415.6 100
17 S_17 2590.0 100
18 S_18 2691.2 100
19 S_19 2896.2 100

3.5.4 Route Information

# current location (km) and charge (kw)


x = 0

# planning horizon
D = 2000

# visualize
fig, ax = plt.subplots(1, 1, figsize=(15, 3))

def plot_stations(stations, x, D, ax=ax):


for station in stations.index:
xs = stations.loc[station, "location"]
ys = stations.loc[station, "kw"]
ax.plot([xs, xs], [0, ys], "b", lw=10, solid_capstyle="butt")
(continues on next page)

3.5. Recharging strategy for an electric vehicle 114


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


ax.text(xs, 0 - 30, stations.loc[station, "name"], ha="center")

ax.plot([x, x + D], [0, 0], "r", lw=5, solid_capstyle="butt", label="plan horizon


↪ ")
ax.plot([x, x + D], [0, 0], "r.", ms=20)

ax.axhline(0)
ax.set_ylim(-50, 300)
ax.set_xlabel("Distance")
ax.set_ylabel("kw")
ax.set_title("charging stations")
ax.legend()

plot_stations(stations, x, D)

3.5.5 Car Information

# charge limits (kw)


c_max = 150
c_min = 0.2 * c_max
c = c_max

# velocity km/hr and discharge rate kwh/km


v = 100.0
R = 0.24

# lost time
t_lost = 10 / 60
t_rest = 10 / 60

# rest time
r_max = 3

3.5. Recharging strategy for an electric vehicle 115


Hands-On Mathematical Optimization with AMPL in Python

3.5.6 AMPL Model

%%writefile ev_plan.mod

param n;

# locations and road segments between location x and x + D


set STATIONS; # 1..n
set LOCATIONS; # 0, 1..n, D
set SEGMENTS; # 1..n + 1

param C{STATIONS};
param D;
param c_min;
param c_max;
param v;
param R;

param r_max;
param location{LOCATIONS};
param dist{SEGMENTS};
param t_lost;

# distance traveled
var x{LOCATIONS} >= 0, <= 10000;

# arrival and departure charge at each charging station


var c_arr{LOCATIONS} >= c_min, <= c_max;
var c_dep{LOCATIONS} >= c_min, <= c_max;

# arrival and departure times from each charging station


var t_arr{LOCATIONS} >= 0, <= 100;
var t_dep{LOCATIONS} >= 0, <= 100;

# arrival and departure rest from each charging station


var r_arr{LOCATIONS} >= 0, <= r_max;
var r_dep{LOCATIONS} >= 0, <= r_max;

minimize min_time: t_arr[n + 1];

s.t. drive_time {i in SEGMENTS}: t_arr[i] == t_dep[i-1] + dist[i]/v;


s.t. rest_time {i in SEGMENTS}: r_arr[i] == r_dep[i-1] + dist[i]/v;
s.t. drive_distance {i in SEGMENTS}: x[i] == x[i-1] + dist[i];
s.t. discharge {i in SEGMENTS}: c_arr[i] == c_dep[i-1] - R * dist[i];

s.t. recharge {i in STATIONS}:


# list of constraints that apply if there is no stop at station i
((c_dep[i] == c_arr[i] and t_dep[i] == t_arr[i] and r_dep[i] == r_arr[i])
or
# list of constraints that apply if there is a stop at station i
(t_dep[i] == t_lost + t_arr[i] + (c_dep[i] - c_arr[i])/C[i] and
c_dep[i] >= c_arr[i] and r_dep[i] == 0))
and not
((c_dep[i] == c_arr[i] and t_dep[i] == t_arr[i] and r_dep[i] == r_arr[i])
and
(t_dep[i] == t_lost + t_arr[i] + (c_dep[i] - c_arr[i])/C[i] and
c_dep[i] >= c_arr[i] and r_dep[i] == 0));

3.5. Recharging strategy for an electric vehicle 116


Hands-On Mathematical Optimization with AMPL in Python

Overwriting ev_plan.mod

def ev_plan(stations, x, D):


# data preprocessing

# find stations between x and x + D


on_route = stations[(stations["location"] >= x) & (stations["location"] <= x + D)]

# adjust the index to match the model directly


on_route.index += 1

n = len(on_route)

# get the values of the location parameter


location = on_route["location"].to_dict()
location[0] = x
location[n + 1] = x + D

# get the values for the dist parameter


dist = {}
for s in range(1, n + 2):
dist[s] = location[s] - location[s - 1]

# define the indexing sets


# note the +1 at the end because Python ranges are not inclusive at the endpoint
STATIONS = list(range(1, n + 1)) # 1 to n
LOCATIONS = list(range(n + 2)) # 0 to n + 1
SEGMENTS = list(range(1, n + 2)) # 1 to n + 1

# instantiate AMPL and load model


m = AMPL()
m.read("ev_plan.mod")

m.set["STATIONS"] = STATIONS
m.set["LOCATIONS"] = LOCATIONS
m.set["SEGMENTS"] = SEGMENTS

# load data
m.param["C"] = on_route["kw"]
m.param["location"] = location
m.param["D"] = D
m.param["n"] = n
m.param["c_min"] = c_min
m.param["c_max"] = c_max
m.param["r_max"] = r_max
m.param["t_lost"] = t_lost
m.param["v"] = v
m.param["R"] = R
m.param["dist"] = dist

# initial conditions
m.var["x"][0].fix(x)
m.var["t_dep"][0].fix(0.0)
m.var["r_dep"][0].fix(0.0)
m.var["c_dep"][0].fix(c)

# set solver and solve


(continues on next page)

3.5. Recharging strategy for an electric vehicle 117


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


m.option["solver"] = SOLVER
m.solve()

return m

def get_results(model):
x = [(int(k), v) for k, v in model.var["x"].to_list()]
t_arr = [v for k, v in model.var["t_arr"].to_list()]
t_dep = [v for k, v in model.var["t_dep"].to_list()]
c_arr = [v for k, v in model.var["c_arr"].to_list()]
c_dep = [v for k, v in model.var["c_dep"].to_list()]

results = pd.DataFrame(x, columns=["index", "location"]).set_index("index")


results["t_arr"] = t_arr
results["t_dep"] = t_dep
results["c_arr"] = c_arr
results["c_dep"] = c_dep
results["t_stop"] = results["t_dep"] - results["t_arr"]
results["t_stop"] = results["t_stop"].round(6)

return results

m = ev_plan(stations, 0, 2000)
results = get_results(m)
display(results)

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 24.142507


12091 simplex iterations
12091 barrier iterations
102 branching nodes

location t_arr t_dep c_arr c_dep t_stop


index
0 0.0 0.000000 0.000000 30.0000 150.0000 0.000000
1 191.6 1.916000 2.389227 104.0160 150.0000 0.473227
2 310.6 3.579227 4.031493 121.4400 150.0000 0.452267
3 516.0 6.085493 6.535840 100.7040 114.8880 0.450347
4 683.6 8.211840 8.211840 74.6640 74.6640 0.000000
5 769.9 9.074840 9.241507 53.9520 53.9520 0.166667
6 869.7 10.239507 10.740733 30.0000 63.4560 0.501227
7 1009.1 12.134733 12.848120 30.0000 112.0080 0.713387
8 1164.7 14.404120 14.570787 74.6640 74.6640 0.166667
9 1230.8 15.231787 15.231787 58.8000 58.8000 0.000000
10 1350.8 16.431787 17.078453 30.0000 150.0000 0.646667
11 1508.4 18.654453 18.654453 112.1760 112.1760 -0.000000
12 1639.8 19.968453 20.135121 80.6400 80.6401 0.166668
13 1809.4 21.831121 22.236507 39.9361 75.7440 0.405386
14 1947.3 23.615507 23.615507 42.6480 42.6480 0.000000
15 2000.0 24.142507 0.000000 30.0000 30.0000 -24.142507

# visualize

(continues on next page)

3.5. Recharging strategy for an electric vehicle 118


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


def visualize(m):
D = m.param["D"].value()

results = get_results(m)

results["t_stop"] = results["t_dep"] - results["t_arr"]

fig, ax = plt.subplots(2, 1, figsize=(15, 8), sharex=True)

# plot stations
for station in stations.index:
xs = stations.loc[station, "location"]
ys = stations.loc[station, "kw"]
ax[0].plot([xs, xs], [0, ys], "b", lw=10, solid_capstyle="butt")
ax[0].text(xs, 0 - 30, stations.loc[station, "name"], ha="center")

# plot planning horizon


ax[0].plot(
[x, x + D], [0, 0], "r", lw=5, solid_capstyle="butt", label="plan horizon"
)
ax[0].plot([x, x + D], [0, 0], "r.", ms=20)

# annotations
ax[0].axhline(0)
ax[0].set_ylim(-50, 300)
ax[0].set_ylabel("kw")
ax[0].set_title("charging stations")
ax[0].legend()

SEGMENTS = m.set["SEGMENTS"].to_list()

# plot battery charge


for i in SEGMENTS:
xv = [results.loc[i - 1, "location"], results.loc[i, "location"]]
cv = [results.loc[i - 1, "c_dep"], results.loc[i, "c_arr"]]
ax[1].plot(xv, cv, "g")

STATIONS = m.set["STATIONS"].to_list()

# plot charge at stations


for i in STATIONS:
xv = [results.loc[i, "location"]] * 2
cv = [results.loc[i, "c_arr"], results.loc[i, "c_dep"]]
ax[1].plot(xv, cv, "g")

# mark stop locations


for i in STATIONS:
if results.loc[i, "t_stop"] > 0:
ax[1].axvline(results.loc[i, "location"], color="r", ls="--")

# show constraints on battery charge


ax[1].axhline(c_max, c="g")
ax[1].axhline(c_min, c="g")
ax[1].set_ylim(0, 1.1 * c_max)
ax[1].set_ylabel("Charge (kw)")

(continues on next page)

3.5. Recharging strategy for an electric vehicle 119


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


visualize(ev_plan(stations, 0, 2000))

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 24.142507


12091 simplex iterations
12091 barrier iterations
102 branching nodes

3.5.7 Suggested Exercises

1. Does increasing the battery capacity 𝑐𝑚𝑎𝑥 significantly reduce the time required to travel 2000 km? Explain what
you observe.
2. “The best-laid schemes of mice and men go oft awry” (Robert Burns, “To a Mouse”). Modify this model so that it
can be used to update a plans in response to real-time measurements. How does the charging strategy change as a
function of planning horizon 𝐷?

3.6 BIM production revisited

# install dependencies and select solver


%pip install -q amplpy

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
(continues on next page)

3.6. BIM production revisited 120


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


license_uuid="default", # license to use
) # instantiate AMPL object and register magics

3.6.1 Problem description

We consider BIM raw material planning, but now with more sophisticated pricing and acquisition protocols. There are
now three suppliers, each of which can deliver the following materials:
• A: silicon, germanium and plastic
• B: copper
• C: all of the above
For the suppliers, the following conditions apply. Copper should be acquired in multiples of 100 gram, since it is delivered
in sheets of 100 gram. Unitary materials such as silicon, germanium and plastic may be acquired in any number, but the
price is in batches of 100. Meaning that 30 units of silicon with 10 units of germanium and 50 units of plastic cost as
much as 1 unit of silicon but half as much as 30 units of silicon with 30 units of germanium and 50 units of plastic.
Furthermore, supplier C sells all materials and offers a discount if purchased together: 100 gram of copper and a batch of
unitary material cost just 7. This set price is only applied to pairs, meaning that 100 gram of copper and 2 batches cost
13.
The summary of the prices in € is given in the following table:

Supplier Copper per sheet of 100 gram Batch of units Together


A - 5 -
B 3 - -
C 4 6 7

Next, for stocked products inventory costs are incurred, whose summary is given in the following table:

Copper per 10 gram Silicon per unit Germanium per unit Plastic per unit
0.1 0.02 0.02 0.02

The holding price of copper is per 10 gram and the copper stocked is rounded up to multiples of 10 grams, meaning that
12 grams pay for 20.
The capacity limitations of the warehouse allow for a maximum of 10 kilogram of copper in stock at any moment, but
there are no practical limitations to the number of units of unitary products in stock.
Recall that BIM has the following stock at the beginning of the year:

Copper Silicon Germanium Plastic


480 1000 1500 1750

The company would like to have at least the following stock at the end of the year:

Copper Silicon Germanium Plastic


200 500 500 1000

The goal is to build an optimization model using the data above and solve it to minimize the acquisition and holding costs
of the products while meeting the required quantities for production. The production is made-to-order, meaning that no
inventory of chips is kept.

3.6. BIM production revisited 121


Hands-On Mathematical Optimization with AMPL in Python

from io import StringIO


import pandas as pd

demand_data = """
chip, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec
logic, 88, 125, 260, 217, 238, 286, 248, 238, 265, 293, 259, 244
memory, 47, 62, 81, 65, 95, 118, 86, 89, 82, 82, 84, 66
"""

demand_chips = pd.read_csv(StringIO(demand_data), index_col="chip")


display(demand_chips)

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
chip
logic 88 125 260 217 238 286 248 238 265 293 259 244
memory 47 62 81 65 95 118 86 89 82 82 84 66

use = dict()
use["logic"] = {"silicon": 1, "plastic": 1, "copper": 4}
use["memory"] = {"germanium": 1, "plastic": 1, "copper": 2}
use = pd.DataFrame.from_dict(use).fillna(0).astype(int)
display(use)

logic memory
silicon 1 0
plastic 1 1
copper 4 2
germanium 0 1

demand = use.dot(demand_chips)
display(demand)

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov \
silicon 88 125 260 217 238 286 248 238 265 293 259
plastic 135 187 341 282 333 404 334 327 347 375 343
copper 446 624 1202 998 1142 1380 1164 1130 1224 1336 1204
germanium 47 62 81 65 95 118 86 89 82 82 84

Dec
silicon 244
plastic 310
copper 1108
germanium 66

%%writefile BIMproduction_v1.mod

set periods ordered;


set products;
set supplying_batches;
set supplying_copper;
set unitary_products;

param delta{products, periods}; # demand


param pi{supplying_batches}; # aquisition price
param kappa{supplying_copper}; # price of copper sheet
(continues on next page)

3.6. BIM production revisited 122


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


param beta;
param gamma{products}; # unitary holding costs
param Alpha{products}; # initial stock
param Omega{products}; # desired stock
param copper_bucket_size;
param stock_limit;
param batch_size;
param copper_sheet_mass;

var y{periods, supplying_copper} integer >= 0;


var x{unitary_products, periods, supplying_batches} >= 0;
var ss{products, periods} >= 0;
var uu{products, periods} >= 0;
var bb{periods, supplying_batches} integer >= 0;
var pp{periods} integer >= 0;
var rr{periods} integer >= 0;

s.t. units_in_batches {t in periods, s in supplying_batches}:


sum{p in unitary_products} x[p,t,s] <= batch_size * bb[t,s];
s.t. copper_in_buckets {t in periods}:
ss['copper', t] <= copper_bucket_size * rr[t];
s.t. inventory_capacity {t in periods}:
ss['copper', t] <= stock_limit;
s.t. pairs_in_batches {t in periods}:
pp[t] <= bb[t,'C'];
s.t. pairs_in_sheets {t in periods}:
pp[t] <= y[t,'C'];
s.t. bought {t in periods, p in products}:
(if p == 'copper' then
copper_sheet_mass * sum{s in supplying_copper} y[t,s]
else
sum{s in supplying_batches} x[p,t,s])
== uu[p,t];

var acquisition_cost =
sum{t in periods}(
sum{s in supplying_batches} pi[s] * bb[t,s] +
sum{s in supplying_copper} kappa[s] * y[t,s] -
beta * pp[t]
);
var inventory_cost =
sum{t in periods}(
gamma['copper'] * rr[t] +
sum{p in unitary_products} gamma[p] * ss[p,t]
);

minimize total_cost: acquisition_cost + inventory_cost;

s.t. balance {p in products, t in periods}:


(if t == first(periods) then
Alpha[p]
else
ss[p,prev(t)]) +
uu[p,t] == delta[p,t] + ss[p,t];
s.t. finish {p in products}:
ss[p, last(periods)] >= Omega[p];

3.6. BIM production revisited 123


Hands-On Mathematical Optimization with AMPL in Python

Overwriting BIMproduction_v1.mod

def BIMproduction_v1(
demand,
existing,
desired,
stock_limit,
supplying_copper,
supplying_batches,
price_copper_sheet,
price_batch,
discounted_price,
batch_size,
copper_sheet_mass,
copper_bucket_size,
unitary_products,
unitary_holding_costs,
):
m = AMPL()
m.read("BIMproduction_v1.mod")

m.set["periods"] = demand.columns
m.set["products"] = demand.index
m.param["delta"] = demand

m.set["supplying_batches"] = supplying_batches
m.param["pi"] = price_batch

m.set["supplying_copper"] = supplying_copper
m.param["kappa"] = price_copper_sheet

m.param["beta"] = price_batch["C"] + price_copper_sheet["C"] - discounted_price

m.set["unitary_products"] = unitary_products
m.param["gamma"] = unitary_holding_costs

m.param["Alpha"] = existing
m.param["Omega"] = desired

m.param["batch_size"] = batch_size
m.param["copper_bucket_size"] = copper_bucket_size
m.param["stock_limit"] = stock_limit
m.param["copper_sheet_mass"] = copper_sheet_mass

return m

m1 = BIMproduction_v1(
demand=demand,
existing={"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
desired={"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
stock_limit=10000,
supplying_copper=["B", "C"],
supplying_batches=["A", "C"],
price_copper_sheet={"B": 300, "C": 400},
price_batch={"A": 500, "C": 600},
discounted_price=700,
batch_size=100,
(continues on next page)

3.6. BIM production revisited 124


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


copper_sheet_mass=100,
copper_bucket_size=10,
unitary_products=["silicon", "germanium", "plastic"],
unitary_holding_costs={"copper": 10, "silicon": 2, "germanium": 2, "plastic": 2},
)

m1.option["solver"] = SOLVER
m1.option["highs_options"] = "outlev=1"
m1.solve()

HiGHS 1.5.1: tech:outlev=1


Running HiGHS 1.5.1 [date: 2023-06-22, git hash: 93f1876]
Copyright (c) 2023 HiGHS under MIT licence terms
Presolving model
152 rows, 236 cols, 444 nonzeros
113 rows, 197 cols, 366 nonzeros
96 rows, 156 cols, 285 nonzeros

Solving MIP model with:


96 rows
156 cols (0 binary, 72 integer, 23 implied int., 61 continuous)
285 nonzeros

Nodes | B&B Tree | Objective Bounds | ␣


↪Dynamic Constraints | Work
Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | ␣
↪Cuts InLp Confl. | LpIters Time

0 0 0 0.00% -inf inf inf ␣


↪ 0 0 0 0 0.0s
R 0 0 0 0.00% 109104 114244 4.50% ␣
↪ 0 0 0 67 0.1s
L 0 0 0 0.00% 109957.085557 110216 0.23% ␣
↪ 926 61 0 331 0.9s

2.8% inactive integer columns, restarting


Model after restart has 92 rows, 147 cols (11 bin., 58 int., 21 impl., 57 cont.), and␣
↪270 nonzeros

0 0 0 0.00% 109957.143248 110216 0.23% ␣


↪ 32 0 0 466 0.9s
0 0 0 0.00% 110065.335725 110216 0.14% ␣
↪ 32 15 2 500 0.9s
L 0 0 0 0.00% 110065.431752 110216 0.14% ␣
↪ 48 16 2 503 1.0s

13.0% inactive integer columns, restarting


Model after restart has 71 rows, 118 cols (12 bin., 46 int., 7 impl., 53 cont.), and␣
↪218 nonzeros

0 0 0 0.00% 110065.431752 110216 0.14% ␣


↪ 16 0 0 695 1.0s
0 0 0 0.00% 110065.431752 110216 0.14% ␣
↪ 16 15 0 718 1.0s

13.8% inactive integer columns, restarting


(continues on next page)

3.6. BIM production revisited 125


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Model after restart has 57 rows, 96 cols (3 bin., 42 int., 6 impl., 45 cont.), and␣
↪173 nonzeros

0 0 0 0.00% 110091.208095 110216 0.11% ␣


↪ 11 0 0 783 1.1s
0 0 0 0.00% 110091.208095 110216 0.11% ␣
↪ 11 11 0 796 1.1s

Solving report
Status Optimal
Primal bound 110216
Dual bound 110206.854428
Gap 0.0083% (tolerance: 0.01%)
Solution status feasible
110216 (objective)
0 (bound viol.)
2.30926389122e-14 (int. viol.)
0 (row viol.)
Timing 1.31 (total)
0.00 (presolve)
0.00 (postsolve)
Nodes 11
LP iterations 1510 (total)
453 (strong br.)
347 (separation)
466 (heuristics)
HiGHS 1.5.1: optimal solution; objective 110216
1510 simplex iterations
11 branching nodes
absmipgap=9.14557, relmipgap=8.29786e-05

stock = m1.var["ss"].to_pandas().unstack()
stock.columns = stock.columns.get_level_values(1)
stock = stock.reindex(index=demand.index, columns=demand.columns)
display(stock)

Jan Feb Mar Apr May Jun Jul Aug \


silicon 912.0 787.0 527.0 310.0 72.0 15.0 0.0 0.0
plastic 1615.0 1428.0 1087.0 805.0 472.0 68.0 1.0 36.0
copper 4354.0 3730.0 2528.0 1530.0 388.0 8.0 44.0 14.0
germanium 1453.0 1391.0 1310.0 1245.0 1150.0 1032.0 946.0 857.0

Sep Oct Nov Dec


silicon 0.0 56.0 54.0 500.0
plastic 24.0 0.0 0.0 1000.0
copper 90.0 54.0 50.0 2042.0
germanium 775.0 693.0 609.0 543.0

import matplotlib.pyplot as plt, numpy as np

stock.T.plot(drawstyle="steps-mid", grid=True, figsize=(13, 4))


plt.xticks(np.arange(len(stock.columns)), stock.columns)
plt.show()

3.6. BIM production revisited 126


Hands-On Mathematical Optimization with AMPL in Python

m1.var["pp"].to_pandas().T

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
pp.val 0.0 0.0 0.0 0.0 0.0 3.0 5.0 6.0 6.0 7.0 6.0 20.0

df = m1.var["uu"].to_pandas().unstack()
df.columns = df.columns.get_level_values(1)
df = df.reindex(index=demand.index, columns=demand.columns)
display(df)

Jan Feb Mar Apr May Jun Jul Aug Sep \


silicon 0.0 0.0 0.0 0.0 0.0 229.0 233.0 238.0 265.0
plastic 0.0 0.0 0.0 0.0 0.0 0.0 267.0 362.0 335.0
copper 0.0 0.0 0.0 0.0 0.0 1000.0 1200.0 1100.0 1300.0
germanium 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

Oct Nov Dec


silicon 349.0 257.0 690.0
plastic 351.0 343.0 1310.0
copper 1300.0 1200.0 3100.0
germanium 0.0 0.0 0.0

b = m1.var["bb"].to_pandas().unstack().T
b.index = b.index.get_level_values(1)
b = b.reindex(columns=demand.columns)
b.index.names = [None]
display(b)

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
A 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
C 0.0 0.0 0.0 0.0 0.0 3.0 5.0 6.0 6.0 7.0 6.0 20.0

y = m1.var["y"].to_pandas().unstack().T
y.index = y.index.get_level_values(1)
y = y.reindex(columns=demand.columns)
y.index.names = [None]
display(y)

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
B 0.0 0.0 0.0 0.0 0.0 7.0 7.0 5.0 7.0 6.0 6.0 11.0
C 0.0 0.0 0.0 0.0 0.0 3.0 5.0 6.0 6.0 7.0 6.0 20.0

3.6. BIM production revisited 127


Hands-On Mathematical Optimization with AMPL in Python

x = m1.var["x"].to_pandas().unstack(1)
x.columns = x.columns.get_level_values(1)
x = x.reindex(columns=demand.columns)
x.index.names = ["materials", "supplier"]
display(x)

Jan Feb Mar Apr May Jun Jul Aug Sep \


materials supplier
germanium A 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
C 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
plastic A 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
C 0.0 0.0 0.0 0.0 0.0 0.0 267.0 362.0 335.0
silicon A 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
C 0.0 0.0 0.0 0.0 0.0 229.0 233.0 238.0 265.0

Oct Nov Dec


materials supplier
germanium A 0.0 0.0 0.0
C 0.0 0.0 0.0
plastic A 0.0 0.0 0.0
C 351.0 343.0 1310.0
silicon A 0.0 0.0 0.0
C 349.0 257.0 690.0

3.7 Extra material: Cryptarithms puzzle

The July 1924 issue of the famous British magazine The Strand included a word puzzle by Henry E. Dudeney in his
regular contribution “Perplexities”. The puzzle is to assign a unique digit to each letter appearing in the equation

S E N D
+ M O R E
= M O N E Y

such that the arithmetic equation is satisfied, and the leading digit for M is non-zero. There are many more examples of
these puzzles, but this is perhaps the most well-known.

# install dependencies and select solver


%pip install -q amplpy

SOLVER = "cbc"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

3.7. Extra material: Cryptarithms puzzle 128


Hands-On Mathematical Optimization with AMPL in Python

3.7.1 Modeling and Solution

There are several possible approaches to modeling this puzzle in AMPL.


One approach would be to using a matrix of binary variables 𝑥𝑎,𝑑 indexed by letter 𝑎 and digit 𝑑 such that 𝑥𝑎,𝑑 = 1
designates the corresponding assignment. The problem constraints can then be implemented by summing the binary
variables along the two axes. The arithmetic constraint becomes a more challenging.
Another approach is to use integer variables indexed by letters, then setup an linear expression to represent the puzzle. If
we use the notation 𝑛𝑎 to represent the digit assigned to letter 𝑎, the algebraic constraint becomes

1000𝑛𝑠 + 100𝑛𝑒 + 10𝑛𝑛 + 𝑛𝑑


+1000𝑛𝑚 + 100𝑛𝑜 + 10𝑛𝑟 + 𝑛𝑒
= 10000𝑛𝑚 + 1000𝑛𝑜 + 100𝑛𝑛 + 10𝑛𝑒 + 𝑛𝑦

The requirement that no two letters be assigned the same digit can be represented as a disjunction. Letting 𝑛𝑎 and 𝑛𝑏
denote the integers assigned to letters 𝑎 and 𝑏, the disjunction becomes

[𝑛𝑎 𝑛𝑏 ] ⊻ [𝑛𝑏 𝑛𝑎 ] ∀𝑎𝑏

%%writefile cryptarithms.mod

set LETTERS;
set PAIRS within {LETTERS, LETTERS};

var n{LETTERS} integer >= 0, <= 9;

s.t. message:
1000*n['S'] + 100*n['E'] + 10*n['N'] + n['D']
+ 1000*n['M'] + 100*n['O'] + 10*n['R'] + n['E']
== 10000*n['M'] + 1000*n['O'] + 100*n['N'] + 10*n['E'] + n['Y'];

# leading digit must be non-zero


s.t. leading_digit_nonzero: n['M'] >= 1;

# assign a different number to each letter


s.t. unique_assignment{(a, b) in PAIRS}:
(n[a] >= n[b] + 1
or
n[b] >= n[a] + 1)
and not
(n[a] >= n[b] + 1
and
n[b] >= n[a] + 1);

Overwriting cryptarithms.mod

m = AMPL()
m.read("cryptarithms.mod")

LETTERS = ["S", "E", "N", "D", "M", "O", "R", "Y"]


PAIRS = [(a, b) for a in LETTERS for b in LETTERS if a < b]

m.set["LETTERS"] = LETTERS
m.set["PAIRS"] = PAIRS

(continues on next page)

3.7. Extra material: Cryptarithms puzzle 129


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


m.option["solver"] = SOLVER
m.solve()

n = m.var["n"].to_dict()

def letters2num(s):
return " ".join(map(lambda s: f"{int(round(n[s], 0))}", list(s)))

print(" ", letters2num("SEND"))


print(" + ", letters2num("MORE"))
print(" ----------")
print("= ", letters2num("MONEY"))

cbc 2.10.7: cbc 2.10.7: optimal solution


9 simplex iterations
9 barrier iterations
Objective = find a feasible point.
9 5 6 7
+ 1 0 8 5
----------
= 1 0 6 5 2

3.7.2 Suggested exercises

1. AMPL includes Logic, Nonlinear & Constraint Programming Extensions. Rewrite the model with different com-
binations of logical operators and compare the performance with one obtained with the constraint solver gecode.
2. There are many more examples of cryptarithm puzzles. Refactor this code and create a function that can be used
to solve generic puzzles of this type.

3.8 Extra material: Strip packing

Strip packing (SP) refers to the problem of packing rectangles onto a two dimensional strip of fixed width.
Many variants of this problem have been studied, the most basic is the pack a set of rectangles onto the shortest possible
strip without rotation. Other variants allow rotation of the rectangles, require a packing that allows cutting the rectangles
out of the strip with edge-to-edge cuts (guillotine packing), extends the strip to three dimensions, or extends the packing
to non-rectangular shapes.
The extensive study of strip packing problems is motivated by their many industrial applications including
• placement of macro cells in semiconductor layouts,
• wood and textile cutting operations,
• laying out workstations in manufacturing facilities,
• allocating communications bandwidth between two endpoints,
• planning and scheduling 𝐶𝑂2 utilization for enhanced oil recovery,
• scheduling allocations of a common resource.

3.8. Extra material: Strip packing 130


Hands-On Mathematical Optimization with AMPL in Python

Finding optimal solutions to strip packing problems is combinatorially difficult. Strip packing belongs to a class of prob-
lems called “NP-hard” for which known solution algorithms require effort that grows exponentially with problem size.
For that reason much research on strip packing has been directed towards practical heuristic algorithms for finding good,
though not optimal, for industrial applications.
Here we develop AMPL models to find optimal solutions to smaller but economically relevant problems. We use the
problem of packing boxes onto shortest possible shelf of fixed width.

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

3.8.1 Problem Statment

Imagine a collection of 𝑁 boxes that are to placed on shelf. The shelf depth is 𝐷, and the dimensions of the boxes are
(𝑤𝑖 , 𝑑𝑖 ) for 𝑖 = 0, … , 𝑁 − 1. The boxes can be rotated, if needed, to fit on the shelf. How wide of a shelf is needed?
We will start by creating a function to generate a table of 𝑁 boxes. For concreteness, we assume the dimensions are in
millimeters.

import random
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

# random seed
random.seed(1842)

# generate boxes
def generate_boxes(N, max_width=200, max_depth=200):
boxes = pd.DataFrame()
boxes["w"] = [random.randint(0.2 * max_width, max_width) for i in range(N)]
boxes["d"] = [random.randint(0.2 * max_depth, max_depth) for i in range(N)]
return boxes

N = 8
boxes = generate_boxes(8)
display(boxes)

# set shelf width as a multiple of the deepest box


D = 2 * boxes["d"].max()
print("Shelf Depth = ", D)

w d
0 82 103
1 73 48
(continues on next page)

3.8. Extra material: Strip packing 131


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


2 171 53
3 73 99
4 167 85
5 151 172
6 54 130
7 126 94

Shelf Depth = 344

3.8.2 A lower and upper bounds on shelf width

A lower bound on the shelf width is established by from the area required to place all boxes on the shelf.

1 𝑁−1
𝑊𝑙𝑏 = ∑𝑤𝑑
𝐷 𝑖=0 𝑖 𝑖

An upper bound is established by aligning the boxes along the front of the shelf without rotation. To set the stage for later
calculations, the position of the rectangle on the shelf is defined by bounding box (𝑥𝑖,1 , 𝑦𝑖,1 ) and (𝑥𝑖,2 , 𝑦𝑖,2 ) extending
from the lower left corner to the upper right corner.
𝑥𝑖,2 = 𝑥𝑖,1 + 𝑤𝑖
𝑦𝑖,2 = 𝑦𝑖,1 + 𝑑𝑖

An additional binary variable 𝑟𝑖 designates whether the rectangle has been rotated. The following cell performs these
calculations to create and display a data frame showing the bounding boxes.
def pack_boxes_V0(boxes):
soln = boxes.copy()
soln["x1"] = soln["w"].cumsum() - soln["w"]
soln["x2"] = soln["w"].cumsum()
soln["y1"] = 0
soln["y2"] = soln["d"]
soln["r"] = 0
return soln

def show_boxes(soln, D):


"""Show bounding boxes on a diagram of the shelf."""
fig, ax = plt.subplots(1, 1, figsize=(14, 4))
for i, x, y, w, h, r in zip(
soln.index, soln["x1"], soln["y1"], soln["w"], soln["d"], soln["r"]
):
c = "g"
if r:
h, w = w, h
c = "r"
ax.add_patch(Rectangle((x, y), w, h, edgecolor="k", facecolor=c, alpha=0.6))
xc = x + w / 2
yc = y + h / 2
ax.annotate(
i, (xc, yc), color="w", weight="bold", fontsize=12, ha="center", va=
↪"center"

)
(continues on next page)

3.8. Extra material: Strip packing 132


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

W_lb = (soln["w"] * soln["d"]).sum() / D


ax.set_xlim(0, 1.1 * soln["w"].sum())
ax.set_ylim(0, D * 1.1)
ax.axhline(D, label="shelf depth $D$", lw=0.8)
ax.axvline(W_lb, label="lower bound $W_{lb}$", color="g", lw=0.8)
ax.axvline(soln["x2"].max(), label="shelf width $W$", color="r", lw=0.8)
ax.fill_between([0, ax.get_xlim()[1]], [D, D], color="b", alpha=0.1)
ax.set_title(f"shelf width $W$ = {soln['x2'].max():.0f}")
ax.set_xlabel("width")
ax.set_ylabel("depth")
ax.set_aspect("equal")
ax.legend(loc="upper right")

soln = pack_boxes_V0(boxes)
display(soln)
show_boxes(soln, D)

w d x1 x2 y1 y2 r
0 82 103 0 82 0 103 0
1 73 48 82 155 0 48 0
2 171 53 155 326 0 53 0
3 73 99 326 399 0 99 0
4 167 85 399 566 0 85 0
5 151 172 566 717 0 172 0
6 54 130 717 771 0 130 0
7 126 94 771 897 0 94 0

3.8. Extra material: Strip packing 133


Hands-On Mathematical Optimization with AMPL in Python

3.8.3 Modeling Strategy

At this point the reader may have some ideas on how to efficiently pack boxes on the shelf. For example, one might start
by placing the larger boxes to left edge of the shelf, then rotating and placing the smaller boxes with a goal of minimized
the occupied with of the shelf.
Modeling for optimization takes a different approach. The strategy is to describe constraints that must be satisfied for
any solution to the problem, then let the solver find the a choice for the decision variables that minimizes width. The
constraints include:
• The bounding boxes must fit within the boundaries of the shelf, and to the left of vertical line drawn at 𝑥 = 𝑊 .
• The boxes can be rotated 90 degrees.
• The boxes must not overlap in either the 𝑥 or 𝑦 dimensions.

3.8.4 Version 1: An AMPL model to line up the boxes

For this first AMPL model we look to reproduce a lineup of the boxes on the shelf. In the case the problem is to minimize
𝑊 where
min 𝑊
subject to:
𝑥𝑖,2 = 𝑥𝑖,1 + 𝑤𝑖 ∀𝑖
𝑥𝑖,2 ≤ 𝑊 ∀𝑖
𝑥𝑖,1 , 𝑥𝑖,2 ≥ 0 ∀𝑖

[𝑥𝑖,2 ≤ 𝑥𝑗,1 ] ⊻ [𝑥𝑗,2 ≤ 𝑥𝑖,1 ] ∀𝑖 < 𝑗

This first model does not consider rotation or placement of the boxes in the 𝑦 dimension, so those decisions are not
included.
The disjunctive constraints specify relationships between 𝑥𝑖,1 and 𝑥𝑖,2 to prevent overlapping positions of boxes on the
shelf. The disjunctions require that either that box 𝑖 is to the left of box 𝑗 or that box 𝑗 is the left of box 𝑖. This is specified
as an exclusive or disjunction because both conditions can be true at the same time. This disjunctive relationship must
hold for every pair of boxes that are different from each other, but specifying 𝑖 doesn’t overlap with 𝑗 assures 𝑗 doesn’t
overlap 𝑖. So it is only necessary to specify disjunctions for all pairs 𝑖, 𝑗 where 𝑖 < 𝑗.
The corresponding AMPL model is a direct implementation of this model. One feature of the implementation is the use
of a set PAIRS to identify the disjunctions. Defining this set simplifies coding for the corresponding disjunction.
%%writefile pack_boxes_V1.mod

set BOXES;
set PAIRS within {BOXES,BOXES};

param w{BOXES};

param W_ub;

var W >= 0, <= W_ub;


var x1{BOXES} >= 0, <= W_ub;
var x2{BOXES} >= 0, <= W_ub;
(continues on next page)

3.8. Extra material: Strip packing 134


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

minimize minimize_width: W;

s.t. bounding_box {i in BOXES}:


x2[i] == x1[i] + w[i];
s.t. width {i in BOXES}:
x2[i] <= W;
s.t. no_overlap {(i, j) in PAIRS}:
(x2[i] <= x1[j] or x2[j] <= x1[i])
and not
(x2[i] <= x1[j] and x2[j] <= x1[i]);

Overwriting pack_boxes_V1.mod

def pack_boxes_V1(boxes):
# a simple upper bound on shelf width
W_ub = boxes["w"].sum()

BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]

m = AMPL()
m.read("pack_boxes_V1.mod")

m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS

m.param["w"] = boxes["w"]
m.param["W_ub"] = int(W_ub)

m.option["solver"] = SOLVER
m.solve()

soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
soln["x2"] = m.var["x2"].to_dict()
soln["y1"] = 0
soln["y2"] = soln["y1"] + soln["d"]
soln["r"] = 0
return soln

soln = pack_boxes_V1(boxes)
display(soln)
show_boxes(soln, D)

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 896.9999999


344603 simplex iterations
73081 branching nodes
absmipgap=0.000150937, relmipgap=1.68269e-07

w d x1 x2 y1 y2 r
0 82 103 0.0 82.0 0 103 0
1 73 48 770.0 843.0 0 48 0
2 171 53 432.0 603.0 0 53 0
(continues on next page)

3.8. Extra material: Strip packing 135


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


3 73 99 82.0 155.0 0 99 0
4 167 85 603.0 770.0 0 85 0
5 151 172 281.0 432.0 0 172 0
6 54 130 843.0 897.0 0 130 0
7 126 94 155.0 281.0 0 94 0

3.8.5 Version 2: Rotating boxes

Rotating the boxes is an option for packing the boxes more tightly on the shelf. The boxes can be placed either in
their original orientation or in a rotated orientation. This introduces a second exclusive or disjunction to the model that
determines the orientation of the bounding box. A boolean indicator variable 𝑟𝑖 tracks which boxes were rotated which
is used in the show_boxes function to show which boxes have been rotated.

min 𝑊
subject to:
𝑥𝑖,2 ≤ 𝑊 ∀𝑖
𝑥𝑖,1 , 𝑥𝑖,2 ≥ 0 ∀𝑖
𝑦𝑖,1 = 0 ∀𝑖

[𝑥𝑖,2 ≤ 𝑥𝑗,1 ] ⊻ [𝑥𝑗,2 ≤ 𝑥𝑖,1 ] ∀𝑖 < 𝑗

¬𝑟𝑖 𝑟𝑖
⎡𝑥 = 𝑥 + 𝑤 ⎤ ⊻ ⎡𝑥 = 𝑥 + 𝑑 ⎤ ∀𝑖 < 𝑗
⎢ 𝑖,2 𝑖,1 𝑖⎥ ⎢ 𝑖,2 𝑖,1 𝑖⎥
⎣ 𝑦𝑖,2 = 𝑦𝑖,1 + 𝑑𝑖 ⎦ ⎣𝑦𝑖,2 = 𝑦𝑖,1 + 𝑤𝑖 ⎦
For this version of the model the boxes will be lined up against the edge of the shelf with 𝑦𝑖,1 = 0. Decision variables
are now included in the model for rotation 𝑟 to the 𝑦 dimension of the bounding boxes.

%%writefile pack_boxes_V2.mod

set BOXES;
(continues on next page)

3.8. Extra material: Strip packing 136


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


set PAIRS within {BOXES,BOXES};

param w{BOXES};
param d{BOXES};

param W_ub;

var W >= 0, <= W_ub;


var x1{BOXES} >= 0, <= W_ub;
var x2{BOXES} >= 0, <= W_ub;
var y1{BOXES} >= 0, <= W_ub;
var y2{BOXES} >= 0, <= W_ub;
var r{BOXES} binary;

minimize minimize_width: W;

s.t. width {i in BOXES}:


x2[i] <= W;
s.t. yloc {i in BOXES}:
y1[i] == 0;

s.t. rotate {i in BOXES}:


r[i] == 0 ==>
(x2[i] == x1[i] + w[i] and y2[i] == y1[i] + d[i])
else
(x2[i] == x1[i] + d[i] and y2[i] == y1[i] + w[i]);

s.t. no_overlap {(i, j) in PAIRS}:


(x2[i] <= x1[j] or x2[j] <= x1[i])
and not
(x2[i] <= x1[j] and x2[j] <= x1[i])
;

Overwriting pack_boxes_V2.mod

def pack_boxes_V2(boxes):
W_ub = boxes["w"].sum()

BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]

m = AMPL()
m.read("pack_boxes_V2.mod")

m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS

m.param["w"] = boxes["w"]
m.param["d"] = boxes["d"]
m.param["W_ub"] = int(W_ub)

m.option["solver"] = SOLVER
m.solve()

soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
(continues on next page)

3.8. Extra material: Strip packing 137


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


soln["x2"] = m.var["x2"].to_dict()
soln["y1"] = m.var["y1"].to_dict()
soln["y2"] = m.var["y2"].to_dict()
soln["r"] = m.var["r"].to_dict()

return soln

soln = pack_boxes_V2(boxes)
display(soln)
show_boxes(soln, D)

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 639.999502


7.40492e+06 simplex iterations
1.05588e+06 branching nodes
absmipgap=0.0608127, relmipgap=9.502e-05

w d x1 x2 y1 y2 r
0 82 103 1.859995e+02 267.999502 0.0 103.0 0.0
1 73 48 5.300000e+01 101.000000 0.0 73.0 1.0
2 171 53 1.421085e-14 53.000000 0.0 171.0 1.0
3 73 99 5.669995e+02 639.999502 0.0 99.0 0.0
4 167 85 1.009995e+02 185.999502 0.0 167.0 1.0
5 151 172 3.619995e+02 512.999502 0.0 172.0 0.0
6 54 130 5.129995e+02 566.999502 0.0 130.0 0.0
7 126 94 2.679995e+02 361.999502 0.0 126.0 1.0

3.8. Extra material: Strip packing 138


Hands-On Mathematical Optimization with AMPL in Python

3.8.6 Version 3: Placing and Rotating boxes in two dimensions

Obviously the packages can be packed closer together by allowing boxes to be stacked deeper into the shelf. New con-
straints are needed to maintain the bounding boxes within the shelf depth, and to avoid overlaps in the 𝑦 dimension.

min 𝑊
subject to:
𝑥𝑖,2 ≤ 𝑊 ∀𝑖
𝑦𝑖,2 ≤ 𝐷 ∀𝑖
𝑥𝑖,1 , 𝑥𝑖,2 ≥ 0 ∀𝑖
𝑦𝑖,1 , 𝑦𝑖,2 ≥ 0 ∀𝑖

[𝑥𝑖,2 ≤ 𝑥𝑗,1 ] ⊻ [𝑥𝑗,2 ≤ 𝑥𝑖,1 ] ⊻ [𝑦𝑖,2 ≤ 𝑦𝑗,1 ] ⊻ [𝑦𝑗,2 ≤ 𝑦𝑖,1 ] ∀𝑖 < 𝑗

¬𝑟𝑖 𝑟𝑖
⎡𝑥 = 𝑥 + 𝑤 ⎤ ⊻ ⎡𝑥 = 𝑥 + 𝑑 ⎤ ∀𝑖 < 𝑗
⎢ 𝑖,2 𝑖,1 𝑖⎥ ⎢ 𝑖,2 𝑖,1 𝑖⎥
⎣ 𝑦𝑖,2 = 𝑦𝑖,1 + 𝑑𝑖 ⎦ ⎣𝑦𝑖,2 = 𝑦𝑖,1 + 𝑤𝑖 ⎦

%%writefile pack_boxes_V3.mod

set BOXES;
set PAIRS within {BOXES,BOXES};

param w{BOXES};
param d{BOXES};
param D;

param W_ub;

var W >= 0, <= W_ub;


var x1{BOXES} >= 0, <= W_ub;
var x2{BOXES} >= 0, <= W_ub;
var y1{BOXES} >= 0, <= W_ub;
var y2{BOXES} >= 0, <= W_ub;
var r{BOXES} binary;

minimize minimize_width: W;

s.t. width {i in BOXES}:


x2[i] <= W;
s.t. height {i in BOXES}:
y2[i] <= D;

s.t. rotate {i in BOXES}:


r[i] == 0 ==> (x2[i] == x1[i] + w[i] and y2[i] == y1[i] + d[i])
else
(x2[i] == x1[i] + d[i] and y2[i] == y1[i] + w[i]);

s.t. no_overlap {(i, j) in PAIRS}:


(x2[i] <= x1[j] or x2[j] <= x1[i] or y2[i] <= y1[j] or y2[j] <= y1[i])
and not
(x2[i] <= x1[j] and x2[j] <= x1[i] and y2[i] <= y1[j] and y2[j] <= y1[i])
;

3.8. Extra material: Strip packing 139


Hands-On Mathematical Optimization with AMPL in Python

Overwriting pack_boxes_V3.mod

def pack_boxes_V3(boxes, D):


W_ub = boxes["w"].sum()

BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]

m = AMPL()
m.read("pack_boxes_V3.mod")

m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS

m.param["w"] = boxes["w"]
m.param["d"] = boxes["d"]
m.param["W_ub"] = int(W_ub)
m.param["D"] = int(D)

m.option["solver"] = SOLVER
m.solve()

r = m.var["r"].to_dict()
r = {i: int(round(r[i])) for i in r}

soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
soln["x2"] = m.var["x2"].to_dict()
soln["y1"] = m.var["y1"].to_dict()
soln["y2"] = m.var["y2"].to_dict()
soln["r"] = r
return soln

soln = pack_boxes_V3(boxes, D)
display(soln)
show_boxes(soln, D)

HiGHS 1.5.1:HiGHS 1.5.1: optimal solution; objective 265.999999


2.86135e+06 simplex iterations
140133 branching nodes
absmipgap=8.4e-05, relmipgap=3.15789e-07

w d x1 x2 y1 y2 r
0 82 103 0.000000 82.000000 151.0 254.0 0
1 73 48 217.999999 265.999999 126.0 199.0 1
2 171 53 94.999999 265.999999 205.0 258.0 0
3 73 99 0.000000 99.000000 271.0 344.0 1
4 167 85 99.000000 265.999999 258.0 343.0 0
5 151 172 0.000000 172.000000 0.0 151.0 1
6 54 130 82.000000 212.000000 151.0 205.0 1
7 126 94 171.999999 265.999999 0.0 126.0 1

3.8. Extra material: Strip packing 140


Hands-On Mathematical Optimization with AMPL in Python

3.8.7 Advanced Topic: Symmetry Breaking

One of the issues in combinatorial problem is the challenge of symmetries. A symmetry is a situation where a change in
solution configuration leaves the objective unchanged. Strip packing problems are especially susceptible to symmetries.
Symmetries can significantly increase the effort needed to find and and verify an optimal solution. Trespalacios and
Grossmann recently presented modification to the strip packing problem to reduce the number of symmetries. This is
described in the following paper and implemented in the accompanying AMPL model.
Trespalacios, F., & Grossmann, I. E. (2017). Symmetry breaking for generalized disjunctive programming formulation
of the strip packing problem. Annals of Operations Research, 258(2), 747-759.
%%writefile pack_boxes_V4.mod

set BOXES;
set PAIRS within {BOXES,BOXES};

param w{BOXES};
param d{BOXES};
param D;

param W_ub;

var W >= 0, <= W_ub;


var x1{BOXES} >= 0, <= W_ub;
var x2{BOXES} >= 0, <= W_ub;
var y1{BOXES} >= 0, <= W_ub;
var y2{BOXES} >= 0, <= W_ub;
var r{BOXES} binary;

minimize minimize_width: W;

s.t. width {i in BOXES}:


x2[i] <= W;
s.t. height {i in BOXES}:
y2[i] <= D;

(continues on next page)

3.8. Extra material: Strip packing 141


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


s.t. rotate {i in BOXES}:
r[i] == 0 ==> (x2[i] == x1[i] + w[i] and y2[i] == y1[i] + d[i])
else
(x2[i] == x1[i] + d[i] and y2[i] == y1[i] + w[i])
;

s.t. no_overlap {(i, j) in PAIRS}:


x2[i] <= x1[j] or
x2[j] <= x1[i] or
(y2[i] <= y1[j] and x2[i] >= x1[j] + 1 and x2[j] >= x1[i] + 1) or
(y2[j] <= y1[i] and x2[i] >= x1[j] + 1 and x2[j] >= x1[i] + 1);

Overwriting pack_boxes_V4.mod

def pack_boxes_V4(boxes, D):


W_ub = boxes["w"].sum()

BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]

m = AMPL()
m.read("pack_boxes_V4.mod")

m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS

m.param["w"] = boxes["w"]
m.param["d"] = boxes["d"]
m.param["W_ub"] = int(W_ub)
m.param["D"] = int(D)

m.option["solver"] = SOLVER
m.solve()

r = m.var["r"].to_dict()
r = {i: int(round(r[i])) for i in r}

soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
soln["x2"] = m.var["x2"].to_dict()
soln["y1"] = m.var["y1"].to_dict()
soln["y2"] = m.var["y2"].to_dict()
soln["r"] = r

return soln

soln = pack_boxes_V4(boxes, D)
display(soln)
show_boxes(soln, D)

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 266


1.4118e+06 simplex iterations
74606 branching nodes
absmipgap=0.00274, relmipgap=1.03008e-05

3.8. Extra material: Strip packing 142


Hands-On Mathematical Optimization with AMPL in Python

w d x1 x2 y1 y2 r
0 82 103 178.0 260.0 0.0 103.0 0
1 73 48 0.0 48.0 53.0 126.0 1
2 171 53 0.0 171.0 0.0 53.0 0
3 73 99 167.0 266.0 258.0 331.0 1
4 167 85 0.0 167.0 258.0 343.0 0
5 151 172 94.0 266.0 107.0 258.0 1
6 54 130 48.0 178.0 53.0 107.0 1
7 126 94 0.0 94.0 126.0 252.0 1

3.9 Extra material: Job shop scheduling

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

3.9. Extra material: Job shop scheduling 143


Hands-On Mathematical Optimization with AMPL in Python

3.9.1 Job Shop Scheduling

Job shop scheduling is one of the classic problems in Operations Research.


A job shop refers to a collection of machines that process jobs. The jobs may be simple or complex, such as the production
of a custom part, a print job at printing house, a patient treated in a hospital, or a meal produced in a fast food restaurant.
The term “machines” can refer any device, person, or resource involved in the processing the jobs. In what follows in this
notebook, a job comprises a series of tasks that requiring use of particular machines for known duration, and which must
be completed in specified order.
The job shop scheduling problem is to schedule a set of jobs on the available machines to optimize a metric of productivity.
A typical metric is the makespan which refers to the time needed to process all jobs.
At a basic level, the data required to specify a job shop scheduling problem consists of two tables. The first table is a
decomposition of the jobs into a series of tasks, each task listing the job name, name of the required machine, and task
duration. A second table list task pairs where the first task must be completed before the second task can be started.
Real applications of job shop scheduling often include additional considerations such as time that may be required to set
up machines prior to the start of the next job, or limits on the time a job can wait between processing steps.

3.9.2 Job shop example

The following example of a job shop is from from Christelle Gueret, Christian Prins, Marc Sevaux, “Applications of
Optimization with Xpress-MP,” Dash Optimization, 2000.
In this example, there are three printed paper products that must pass through color printing presses in a particular order.
The given data consists of a flow sheet showing the order in which each job passes through the color presses

and a table of data showing, in minutes, the amount of time each job requires on each machine.

Machine Color Paper 1 Paper 2 Paper 3


1 Blue 45 20 12
2 Green - 10 17
3 Yellow 10 34 28

What is the minimum amount of time (i.e, what is the makespan) for this set of jobs?

3.9. Extra material: Job shop scheduling 144


Hands-On Mathematical Optimization with AMPL in Python

3.9.3 Task decomposition

The first step in the analysis is to decompose the process into a series of tasks. Each task c(job, machine) pair. Some
tasks cannot start until a prerequisite task is completed.

Task (Job,Machine) Duration Prerequisite Task


(Paper 1, Blue) 45 -
(Paper 1, Yellow) 10 (Paper 1,Blue)
(Paper 2, Blue) 20 (Paper 2, Green)
(Paper 2, Green) 10 -
(Paper 2, Yellow) 34 (Paper 2, Blue)
(Paper 3, Blue) 12 (Paper 3, Yellow)
(Paper 3, Green) 17 (Paper 3, Blue)
(Paper 3, Yellow) 28 -

We convert this to a JSON style representation where tasks are denoted by (Job,Machine) tuples in Python. The task data
is stored in a Python dictionary indexed by (Job,Machine) tuples. The task data consists of a dictionary with duration
(‘dur’) and (Job,Machine) pair for any prerequisite task.
TASKS = {
("Paper_1", "Blue"): {"dur": 45, "prec": None},
("Paper_1", "Yellow"): {"dur": 10, "prec": ("Paper_1", "Blue")},
("Paper_2", "Blue"): {"dur": 20, "prec": ("Paper_2", "Green")},
("Paper_2", "Green"): {"dur": 10, "prec": None},
("Paper_2", "Yellow"): {"dur": 34, "prec": ("Paper_2", "Blue")},
("Paper_3", "Blue"): {"dur": 12, "prec": ("Paper_3", "Yellow")},
("Paper_3", "Green"): {"dur": 17, "prec": ("Paper_3", "Blue")},
("Paper_3", "Yellow"): {"dur": 28, "prec": None},
}

3.9.4 Model formulation

Each task is indexed by an ordered pair (𝑗, 𝑚) where 𝑗 refers to a job, and 𝑚 refers to a machine. Associated with each
task is data describing the time needed to perform the task, and a preceding task that must be completed before the index
task can start.

Parameter Description
dur𝑗,𝑚 Duration of task (𝑗, 𝑚)
prec𝑗,𝑚 A task (𝑘, 𝑛) = prec𝑗,𝑚 that must be completed before task (𝑗, 𝑚)

The choice of decision variables for this problem are key to modeling. We introduce 𝑚𝑎𝑘𝑒𝑠𝑝𝑎𝑛 as the time needed to
complete all tasks. 𝑚𝑎𝑘𝑒𝑠𝑝𝑎𝑛 is a candidate objective function. Variable 𝑠𝑡𝑎𝑟𝑡𝑗,𝑚 denotes the time when task (𝑗, 𝑚)
begins.

Decision Variables Description


makespan Completion of all jobs
start𝑗,𝑚 Start time for task (𝑗, 𝑚)

The constraints include lower bounds on the start and an upper bound on the completion of each task (𝑗, 𝑚)

start𝑗,𝑚 ≥ 0 (3.1)
start𝑗,𝑚 + dur𝑗,𝑚 ≤ makespan
(3.2)

3.9. Extra material: Job shop scheduling 145


Hands-On Mathematical Optimization with AMPL in Python

Any preceding tasks must be completed before task (𝑗, 𝑚) can start.

start𝑘,𝑛 + dur𝑘,𝑛 ≤ start𝑗,𝑚 for (𝑘, 𝑛) = prec𝑗,𝑚 (3.3)

Finally, for every task performed on machine 𝑚, there can be no overlap among those tasks. This leads to a set of pair-wise
disjunctive constraints for each machine.

[start𝑗,𝑚 + dur𝑗,𝑚 ≤ start𝑘,𝑚 ] ⊻ [start𝑘,𝑚 + dur𝑘,𝑚 ≤ start𝑗,𝑚 ] (3.4)

avoids conflicts for use of the same machine.

3.9.5 AMPL implementation

The job shop scheduling problem is implemented below in AMPL. The implementation consists of a function job-
shop_model(TASKS) that accepts a dictionary of tasks and returns an AMPL model.

%%writefile jobshop_model.mod

set JOBS;
set MACHINES;
set TASKS within {JOBS, MACHINES};
set TASKORDER within {TASKS, TASKS};
set DISJUNCTIONS within {JOBS, JOBS, MACHINES};

param dur{TASKS};
param ub;

var makespan >= 0, <= ub;


var start{TASKS} >= 0, <= ub;

minimize minimize_makespan: makespan;

s.t. finish_tasks {(j,m) in TASKS}:


start[j, m] + dur[j, m] <= makespan;

s.t. preceding {(j, m, k, n) in TASKORDER}:


start[k, n] + dur[k, n] <= start[j, m];

s.t. no_overlap {(j, k, m) in DISJUNCTIONS}:


(start[j, m] + dur[j, m] <= start[k, m]
or
start[k, m] + dur[k, m] <= start[j, m])
and not
(start[j, m] + dur[j, m] <= start[k, m]
and
start[k, m] + dur[k, m] <= start[j, m]);

Overwriting jobshop_model.mod

def jobshop_model(TASKS):
m = AMPL()
m.read("jobshop_model.mod")

jobs = list(set([j for (j, m) in TASKS]))


machines = list(set([m for (j, m) in TASKS]))
(continues on next page)

3.9. Extra material: Job shop scheduling 146


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


tasks = list(TASKS.keys())
taskorder = [
(j, m, k, n)
for (j, m) in tasks
for (k, n) in tasks
if (k, n) == TASKS[(j, m)]["prec"]
]
disjunctions = [
(j, k, m) for (j, m) in tasks for (k, n) in tasks if m == n and j < k
]
dur = {(j, m): TASKS[(j, m)]["dur"] for (j, m) in TASKS}
ub = sum(dur[i] for i in dur)

m.set["JOBS"] = jobs
m.set["MACHINES"] = machines
m.set["TASKS"] = tasks
m.set["TASKORDER"] = taskorder
m.set["DISJUNCTIONS"] = disjunctions

m.param["dur"] = dur
m.param["ub"] = ub

return m

jobshop_model(TASKS)

<amplpy.ampl.AMPL at 0x7f54c3021600>

def jobshop_solve(model):
model.option["solver"] = SOLVER
model.solve()

start = model.var["start"].to_dict()
dur = model.param["dur"].to_dict()
tasks = model.set["TASKS"].to_dict()

results = [
{
"Job": j,
"Machine": m,
"Start": start[j, m],
"Duration": dur[j, m],
"Finish": start[j, m] + dur[j, m],
}
for j, m in tasks
]
return results

def jobshop(TASKS):
return jobshop_solve(jobshop_model(TASKS))

results = jobshop(TASKS)
results

3.9. Extra material: Job shop scheduling 147


Hands-On Mathematical Optimization with AMPL in Python

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 97


32 simplex iterations
0 branching nodes

[{'Job': 'Paper_1',
'Machine': 'Blue',
'Start': 41.99999999999993,
'Duration': 45.0,
'Finish': 86.99999999999993},
{'Job': 'Paper_1',
'Machine': 'Yellow',
'Start': 86.99999999999993,
'Duration': 10.0,
'Finish': 96.99999999999993},
{'Job': 'Paper_2',
'Machine': 'Blue',
'Start': 9.999999999999972,
'Duration': 20.0,
'Finish': 29.99999999999997},
{'Job': 'Paper_2',
'Machine': 'Green',
'Start': 0.0,
'Duration': 10.0,
'Finish': 10.0},
{'Job': 'Paper_2',
'Machine': 'Yellow',
'Start': 29.99999999999997,
'Duration': 34.0,
'Finish': 63.99999999999997},
{'Job': 'Paper_3',
'Machine': 'Blue',
'Start': 29.99999999999997,
'Duration': 12.0,
'Finish': 41.99999999999997},
{'Job': 'Paper_3',
'Machine': 'Green',
'Start': 79.99999999999997,
'Duration': 17.0,
'Finish': 96.99999999999997},
{'Job': 'Paper_3',
'Machine': 'Yellow',
'Start': 1.999999999999972,
'Duration': 28.0,
'Finish': 29.99999999999997}]

3.9.6 Printing schedules

import pandas as pd

schedule = pd.DataFrame(results)

print("\nSchedule by Job")
print(schedule.sort_values(by=["Job", "Start"]).set_index(["Job", "Machine"]))

print("\nSchedule by Machine")
(continues on next page)

3.9. Extra material: Job shop scheduling 148


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


print(schedule.sort_values(by=["Machine", "Start"]).set_index(["Machine", "Job"]))

Schedule by Job
Start Duration Finish
Job Machine
Paper_1 Blue 42.0 45.0 87.0
Yellow 87.0 10.0 97.0
Paper_2 Green 0.0 10.0 10.0
Blue 10.0 20.0 30.0
Yellow 30.0 34.0 64.0
Paper_3 Yellow 2.0 28.0 30.0
Blue 30.0 12.0 42.0
Green 80.0 17.0 97.0

Schedule by Machine
Start Duration Finish
Machine Job
Blue Paper_2 10.0 20.0 30.0
Paper_3 30.0 12.0 42.0
Paper_1 42.0 45.0 87.0
Green Paper_2 0.0 10.0 10.0
Paper_3 80.0 17.0 97.0
Yellow Paper_3 2.0 28.0 30.0
Paper_2 30.0 34.0 64.0
Paper_1 87.0 10.0 97.0

3.9.7 Visualizing Results with Gantt Charts

import matplotlib.pyplot as plt


import matplotlib as mpl

def visualize(results):
schedule = pd.DataFrame(results)
JOBS = sorted(list(schedule["Job"].unique()))
MACHINES = sorted(list(schedule["Machine"].unique()))
makespan = schedule["Finish"].max()

bar_style = {"alpha": 1.0, "lw": 25, "solid_capstyle": "butt"}


text_style = {"color": "white", "weight": "bold", "ha": "center", "va": "center"}
colors = mpl.cm.Dark2.colors

schedule.sort_values(by=["Job", "Start"])
schedule.set_index(["Job", "Machine"], inplace=True)

fig, ax = plt.subplots(2, 1, figsize=(12, 5 + (len(JOBS) + len(MACHINES)) / 4))

for jdx, j in enumerate(JOBS, 1):


for mdx, m in enumerate(MACHINES, 1):
if (j, m) in schedule.index:
xs = schedule.loc[(j, m), "Start"]
xf = schedule.loc[(j, m), "Finish"]
ax[0].plot([xs, xf], [jdx] * 2, c=colors[mdx % 7], **bar_style)
ax[0].text((xs + xf) / 2, jdx, m, **text_style)
(continues on next page)

3.9. Extra material: Job shop scheduling 149


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


ax[1].plot([xs, xf], [mdx] * 2, c=colors[jdx % 7], **bar_style)
ax[1].text((xs + xf) / 2, mdx, j, **text_style)

ax[0].set_title("Job Schedule")
ax[0].set_ylabel("Job")
ax[1].set_title("Machine Schedule")
ax[1].set_ylabel("Machine")

for idx, s in enumerate([JOBS, MACHINES]):


ax[idx].set_ylim(0.5, len(s) + 0.5)
ax[idx].set_yticks(range(1, 1 + len(s)))
ax[idx].set_yticklabels(s)
ax[idx].text(
makespan,
ax[idx].get_ylim()[0] - 0.2,
"{0:0.1f}".format(makespan),
ha="center",
va="top",
)
ax[idx].plot([makespan] * 2, ax[idx].get_ylim(), "r--")
ax[idx].set_xlabel("Time")
ax[idx].grid(True)

fig.tight_layout()

visualize(results)

3.9. Extra material: Job shop scheduling 150


Hands-On Mathematical Optimization with AMPL in Python

3.9.8 Application to the scheduling of batch processes

We will now turn our attention to the application of the job shop scheduling problem to the short term scheduling of batch
processes. We illustrate these techniques using Example II from Dunn (2013).

Recipe Mixer Reactor Separator Packaging


A 1.0 5.0 4.0 1.5
B - - 4.5 1.0
C - 3.0 5.0 1.5

Single product strategies

Before going further, we create a function to streamline the generation of the TASKS dictionary.

def recipe_to_tasks(jobs, machines, durations):


TASKS = {}
for j in jobs:
prec = (None, None)
for m, d in zip(machines, durations):
task = (j, m)
if prec == (None, None):
TASKS.update({(j, m): {"dur": d, "prec": None}})
else:
TASKS.update({(j, m): {"dur": d, "prec": prec}})
prec = task
return TASKS

recipeA = recipe_to_tasks(
"A", ["Mixer", "Reactor", "Separator", "Packaging"], [1, 5, 4, 1.5]
)
visualize(jobshop(recipeA))

Solution determined by presolve;


objective minimize_makespan = 11.5.

3.9. Extra material: Job shop scheduling 151


Hands-On Mathematical Optimization with AMPL in Python

recipeB = recipe_to_tasks("B", ["Separator", "Packaging"], [4.5, 1])


visualize(jobshop(recipeB))

Solution determined by presolve;


objective minimize_makespan = 5.5.

recipeC = recipe_to_tasks("C", ["Separator", "Reactor", "Packaging"], [5, 3, 1.5])


visualize(jobshop(recipeC))

Solution determined by presolve;


objective minimize_makespan = 9.5.

3.9. Extra material: Job shop scheduling 152


Hands-On Mathematical Optimization with AMPL in Python

Multiple Overlapping tasks

Let’s now consider an optimal scheduling problem where we are wish to make two batches of Product A.

TASKS = recipe_to_tasks(
["A1", "A2", "A3", "A4"],
["Mixer", "Reactor", "Separator", "Packaging"],
[1, 5, 4, 1.5],
)
results = jobshop(TASKS)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 26.5


3411 simplex iterations
79 branching nodes
absmipgap=1e-06, relmipgap=3.77358e-08
Makespan = 26.5

3.9. Extra material: Job shop scheduling 153


Hands-On Mathematical Optimization with AMPL in Python

Earlier we found that it took 11.5 hours to produce one batch of product A. As we see here, we can produce a second
batch with only 5.0 additional hours because some of the tasks overlap. The overlapping of tasks is the key to gaining
efficiency in batch processing facilities.
Let’s next consider production of a single batch each of products A, B, and C.

# update is used to append dictionaries


TASKS = recipeA
TASKS.update(recipeB)
TASKS.update(recipeC)

for k, v in TASKS.items():
print(k, v)

results = jobshop(TASKS)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))

('A', 'Mixer') {'dur': 1, 'prec': None}


('A', 'Reactor') {'dur': 5, 'prec': ('A', 'Mixer')}
('A', 'Separator') {'dur': 4, 'prec': ('A', 'Reactor')}
('A', 'Packaging') {'dur': 1.5, 'prec': ('A', 'Separator')}
('B', 'Separator') {'dur': 4.5, 'prec': None}
('B', 'Packaging') {'dur': 1, 'prec': ('B', 'Separator')}
('C', 'Separator') {'dur': 5, 'prec': None}
('C', 'Reactor') {'dur': 3, 'prec': ('C', 'Separator')}
('C', 'Packaging') {'dur': 1.5, 'prec': ('C', 'Reactor')}
HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 15
71 simplex iterations
1 branching nodes
Makespan = 15.0

3.9. Extra material: Job shop scheduling 154


Hands-On Mathematical Optimization with AMPL in Python

The individual production of A, B, and C required 11.5, 5.5, and 9.5 hours, respectively, for a total of 25.5 hours. As we
see here, by scheduling the production simultaneously, we can get all three batches done in just 15 hours.
As we see below, each additional set of three products takes an additional 13 hours. So there is considerable efficiency
gained by scheduling over longer intervals whenever possible.

TASKS = recipe_to_tasks(
["A1", "A2"], ["Mixer", "Reactor", "Separator", "Packaging"], [1, 5, 4, 1.5]
)
TASKS.update(recipe_to_tasks(["B1", "B2"], ["Separator", "Packaging"], [4.5, 1]))
TASKS.update(
recipe_to_tasks(["C1", "C2"], ["Separator", "Reactor", "Packaging"], [5, 3, 1.5])
)

results = jobshop(TASKS)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))

HiGHS 1.5.1:HiGHS 1.5.1: optimal solution; objective 28


12290 simplex iterations
622 branching nodes
Makespan = 27.99999999999888

3.9. Extra material: Job shop scheduling 155


Hands-On Mathematical Optimization with AMPL in Python

Adding time for unit clean out

A common feature of batch unit operations is a requirement that equipment be cleaned prior to reuse.
In most cases the time needed for clean out would be specific to the equipment and product. But for the purposes this
notebook, we implement can implement a simple clean out policy with a single non-negative parameter 𝑡𝑐𝑙𝑒𝑎𝑛 ≥ 0 which,
if specified, requires a period no less than 𝑡𝑐𝑙𝑒𝑎𝑛 between the finish of one task and the start of another on every piece of
equipment.
This policy is implemented by modifying the usual disjunctive constraints to avoid machine conflicts to read

[start𝑗,𝑚 + dur𝑗,𝑚 + 𝑡𝑐𝑙𝑒𝑎𝑛 ≤ start𝑘,𝑚 ] ∨ [start𝑘,𝑚 + dur𝑘,𝑚 + 𝑡𝑐𝑙𝑒𝑎𝑛 ≤ start𝑗,𝑚 ] (3.5)

For this purpose, we write a new JobShopModel_Clean


%%writefile jobshop_model_clean.mod

set JOBS;
set MACHINES;
set TASKS within {JOBS, MACHINES};
set TASKORDER within {TASKS, TASKS};
set DISJUNCTIONS within {JOBS, JOBS, MACHINES};

param dur{TASKS};
param ub;

var makespan >= 0, <= ub;


var start{TASKS} >= 0, <= ub;

minimize minimize_makespan: makespan;


(continues on next page)

3.9. Extra material: Job shop scheduling 156


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

s.t. finish_tasks {(j,m) in TASKS}:


start[j, m] + dur[j, m] <= makespan;

s.t. preceding {(j, m, k, n) in TASKORDER}:


start[k, n] + dur[k, n] <= start[j, m];

s.t. no_overlap {(j, k, m) in DISJUNCTIONS}:


(start[j, m] + dur[j, m] + tclean <= start[k, m]
or
start[k, m] + dur[k, m] + tclean <= start[j, m]);

Overwriting jobshop_model_clean.mod

def jobshop_model_clean(TASKS, tclean=0):


m = AMPL()
m.read("jobshop_model.mod")

jobs = list(set([j for (j, m) in TASKS]))


machines = list(set([m for (j, m) in TASKS]))
tasks = list(TASKS.keys())
taskorder = [
(j, m, k, n)
for (j, m) in tasks
for (k, n) in tasks
if (k, n) == TASKS[(j, m)]["prec"]
]
disjunctions = [
(j, k, m) for (j, m) in tasks for (k, n) in tasks if m == n and j < k
]
dur = {(j, m): TASKS[(j, m)]["dur"] for (j, m) in TASKS}
ub = sum(dur[i] for i in dur)

m.set["JOBS"] = jobs
m.set["MACHINES"] = machines
m.set["TASKS"] = tasks
m.set["TASKORDER"] = taskorder
m.set["DISJUNCTIONS"] = disjunctions

m.param["dur"] = dur
m.param["ub"] = ub

return m

model = jobshop_model_clean(TASKS, tclean=0.5)


results = jobshop_solve(model)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))

HiGHS 1.5.1:HiGHS 1.5.1: optimal solution; objective 28


12290 simplex iterations
622 branching nodes
Makespan = 27.99999999999888

3.9. Extra material: Job shop scheduling 157


Hands-On Mathematical Optimization with AMPL in Python

Adding a zero wait policy

One of the issues in the use of job shop scheduling for chemical process operations are situations where there it is not
possible to store intermediate materials. If there is no way to store intermediates, either in the processing equipment or
in external vessels, then a zero-wait policy may be required.
A zero-wait policy requires subsequent processing machines to be available immediately upon completion of any task. To
implement this policy, the usual precident sequencing constraint of a job shop scheduling problem, i.e.,

start𝑘,𝑛 + Dur𝑘,𝑛 ≤ start𝑗,𝑚 for (𝑘, 𝑛) = Prec𝑗,𝑚

is changed to

start𝑘,𝑛 + Dur𝑘,𝑛 = start𝑗,𝑚 for (𝑘, 𝑛) = Prec𝑗,𝑚 and ZW is True

if the zero-wait policy is in effect.


While this could be implemented on an equipment or product specific basis, here we add an optional ZW flag to the
JobShop function that, by default, is set to False.

%%writefile jobshop_model_clean_zw.mod

set JOBS;
set MACHINES;
set TASKS within {JOBS, MACHINES};
set TASKORDER within {TASKS, TASKS};
set DISJUNCTIONS within {JOBS, JOBS, MACHINES};

param dur{TASKS};
(continues on next page)

3.9. Extra material: Job shop scheduling 158


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


param ub;
param tclean;
param ZW default 1;

var makespan >= 0, <= ub;


var start{TASKS} >= 0, <= ub;

minimize minimize_makespan: makespan;

s.t. finish_tasks {(j,m) in TASKS}:


start[j, m] + dur[j, m] <= makespan;

s.t. preceding {(j, m, k, n) in TASKORDER}:


ZW == 1 ==> start[k, n] + dur[k, n] == start[j, m]
else
start[k, n] + dur[k, n] <= start[j, m];

s.t. disjunctions {(j, k, m) in DISJUNCTIONS}:


(start[j, m] + dur[j, m] + tclean <= start[k, m]
or
start[k, m] + dur[k, m] + tclean <= start[j, m]);

Overwriting jobshop_model_clean_zw.mod

def jobshop_model_clean_zw(TASKS, tclean=0, ZW=False):


m = AMPL()
m.read("jobshop_model_clean_zw.mod")

jobs = list(set([j for (j, m) in TASKS]))


machines = list(set([m for (j, m) in TASKS]))
tasks = list(TASKS.keys())
taskorder = [
(j, m, k, n)
for (j, m) in tasks
for (k, n) in tasks
if (k, n) == TASKS[(j, m)]["prec"]
]
disjunctions = [
(j, k, m) for (j, m) in tasks for (k, n) in tasks if m == n and j < k
]
dur = {(j, m): TASKS[(j, m)]["dur"] for (j, m) in TASKS}
ub = sum(dur[i] for i in dur)

m.set["JOBS"] = jobs
m.set["MACHINES"] = machines
m.set["TASKS"] = tasks
m.set["TASKORDER"] = taskorder
m.set["DISJUNCTIONS"] = disjunctions

m.param["dur"] = dur
m.param["ub"] = ub
m.param["tclean"] = tclean
m.param["ZW"] = 1 if ZW else 0

m.option["solver"] = SOLVER

(continues on next page)

3.9. Extra material: Job shop scheduling 159


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


return m

model = jobshop_model_clean_zw(TASKS, tclean=0.5, ZW=True)


results = jobshop_solve(model)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))

HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 32


1552 simplex iterations
81 branching nodes
Makespan = 32.0

3.9.9 References

• Applegate, David, and William Cook. “A computational study of the job-shop scheduling problem.” ORSA Journal
on computing 3, no. 2 (1991): 149-156. pdf available
• Beasley, John E. “OR-Library: distributing test problems by electronic mail.” Journal of the operational research
society 41, no. 11 (1990): 1069-1072. OR-Library
• Guéret, Christelle, Christian Prins, and Marc Sevaux. “Applications of optimization with Xpress-MP.” contract
(1999): 00034.
• Manne, Alan S. “On the job-shop scheduling problem.” Operations Research 8, no. 2 (1960): 219-223.

3.9. Extra material: Job shop scheduling 160


Hands-On Mathematical Optimization with AMPL in Python

3.9.10 Exercises

Task specific cleanout

Clean out operations are often slow and time consuming. Further more, the time required to perform a clean out frequently
depends on the type of machine, and the task performed by the machine. For this exercise, create a data format to include
task-specific clean out times, and model the job shop model to accommodate this additional information.

Computational impact of a zero-wait policy

Repeat the benchmark problem calculation, but with a zero-wait policy. Does the execution time increase or descrease
as a consequence of specifying zero-wait?

3.10 Extra material: Maintenance planning

3.10.1 Problem statement

A process unit is operating over a maintenance planning horizon from 1 to 𝑇 days. On day 𝑡 the unit makes a profit 𝑐[𝑡]
which is known in advance. The unit needs to shut down for 𝑃 maintenance periods during the planning period. Once
started, a maintenance period takes 𝑀 days to finish.
Find a maintenance schedule that allows the maximum profit to be produced.

3.10.2 Imports

# install dependencies and select solver


%pip install -q amplpy matplotlib

SOLVER = "cbc"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

import matplotlib.pyplot as plt


import numpy as np
import pandas as pd

3.10. Extra material: Maintenance planning 161


Hands-On Mathematical Optimization with AMPL in Python

3.10.3 Modeling with disjunctive constraints

The model is comprised of two sets of the binary variables indexed 1 to 𝑇 . Binary variables 𝑥𝑡 correspond to the operating
mode of the process unit, with 𝑥𝑡 = 1 indicating the unit is operating on day 𝑡 and able to earn a profit 𝑐𝑡 . Binary variable
𝑦𝑡 = 1 indicates the first day of a maintenance period during which the unit is not operating and earning 0 profit.

Objective

The planning objective is to maximize profit realized during the days the plant is operational.
𝑇
Profit = max ∑ 𝑐𝑡 𝑥𝑡
𝑥,𝑦
𝑡=1

subject to completing 𝑃 maintenance periods.

Constraints

Number of maintenance periods is equal to P.


Completing 𝑃 maintenance periods requires a total of 𝑃 starts.
𝑇
∑ 𝑦𝑡 = 𝑃
𝑡=1

Maintenance periods do not overlap.


No more than one maintenance period can start in any consecutive set of M days.
𝑀−1
∑ 𝑦𝑡+𝑠 ≤ 1 ∀𝑡 = 1, 2, … , 𝑇 − 𝑀 + 1
𝑠=0

This last requirement could be modified if some period of time should occur between maintenance periods.
The unit must shut down for M days following a maintenance start.
𝑀−1
The final requirement is a disjunctive constraint that says either 𝑦𝑡 = 0 or the sum ∑𝑠 𝑥𝑡+𝑠 = 0, but not both.
Mathematically, this forms a set of constraints reading
𝑀−1
(𝑦𝑡 = 0) ⊻ ( ∑ 𝑥𝑡+𝑠 = 0) ∀𝑡 = 1, 2, … , 𝑇 − 𝑀 + 1
𝑠=0

where ⊻ denotes an exclusive or condition.

3.10.4 AMPL solution

Parameter values

# problem parameters
T = 90 # planning period from 1..T
M = 3 # length of maintenance period
P = 4 # number of maintenance periods
(continues on next page)

3.10. Extra material: Maintenance planning 162


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

# daily profits
np.random.seed(0)
c = {k: np.random.uniform() for k in range(1, T + 1)}

AMPL model

The disjunctive constraints can be represented directly in AMPL using Logic, Nonlinear & Constraint Programming
Extensions. The extension transforms the disjunctive constraints to a mixed integer linear optimization (MILO) problem
using convex hull and cutting plane methods.

%%writefile maintenance_planning.mod

param T; # number of planning periods


param P; # number of maintenance periods
param M; # number of days for each maintenance period

set PH := 1..T; # set of planning horizon time periods

set Y := 1..(T - M + 1);


set S := 0..(M - 1);

param c{PH}; # profit

var x{PH} binary;


var y{PH} binary;

maximize profit: sum{t in PH} c[t] * x[t];

s.t. required_maintenance: P == sum{t in Y} y[t];


s.t. no_overlap {t in Y}: sum{s in S} y[t + s] <= 1;
s.t. required_shutdown {t in Y}:
(y[t] == 0 or sum{s in S} x[t + s] == 0)
and not
(y[t] == 0 and sum{s in S} x[t + s] == 0);

Writing maintenance_planning.mod

class MP(object):
def __init__(self, c, M, P):
self.T = len(c)
self.M = M
self.P = P
self.c = c

self.modfile = "maintenance_planning.mod"

self.ampl = AMPL()
self.ampl.option["solver"] = SOLVER
self.solved = False

def load_data(self):
self.ampl.param["T"] = self.T
self.ampl.param["P"] = self.P
(continues on next page)

3.10. Extra material: Maintenance planning 163


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


self.ampl.param["M"] = self.M
self.ampl.param["c"] = self.c

def set_solver(self, solver):


self.ampl.option["solver"] = solver

def set_modfile(self, modfile):


self.modfile = modfile

def solve(self):
self.ampl.read(self.modfile)
self.load_data()
self.ampl.solve()
self.solved = True

def plot_schedule(self):
if not self.solved:
self.solve()

T = self.T
M = self.M
P = self.P
PH = list(range(1, T + 1))
Y = list(range(1, T - M + 2))
S = list(range(M))

fig, ax = plt.subplots(3, 1, figsize=(9, 4))

ax[0].bar(PH, [self.c[t] for t in PH])


ax[0].set_title("daily profit $c_t$")

x = self.ampl.var["x"].to_dict()

ax[1].bar(PH, [x[t] for t in PH], label="normal operation")


ax[1].set_title("unit operating schedule $x_t$")

y = self.ampl.var["y"].to_dict()

ax[2].bar(Y, [y[t] for t in Y])


ax[2].set_title(str(P) + " maintenance starts $y_t$")
for a in ax:
a.set_xlim(0.1, T + 0.9)

plt.tight_layout()

model = MP(c, M, P)
model.plot_schedule()

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 41.92584964


21 simplex iterations
21 barrier iterations

3.10. Extra material: Maintenance planning 164


Hands-On Mathematical Optimization with AMPL in Python

3.10.5 Ramping constraints

Prior to maintenance shutdown, a large processing unit may take some time to safely ramp down from full production.
And then require more time to safely ramp back up to full production following maintenance. To provide for ramp-down
and ramp-up periods, we modify the problem formation in the following ways.
• The variable denoting unit operation, 𝑥𝑡 is changed from a binary variable to a continuous variable 0 ≤ 𝑥𝑡 ≤ 1
denoting the fraction of total capacity at which the unit is operating on day 𝑡.
+,max −,max
• Two new variable sequences, 0 ≤ 𝑢+ 𝑡 ≤ 𝑢𝑡 and 0 ≤ 𝑢− 𝑡 ≤ 𝑢𝑡 , are introduced which denote the fraction
increase or decrease in unit capacity to completed on day 𝑡.
• An additional sequence of equality constraints is introduced relating 𝑥𝑡 to 𝑢+ −
𝑡 and 𝑢𝑡 .

𝑥𝑡 = 𝑥𝑡−1 + 𝑢+ −
𝑡 − 𝑢𝑡

We begin the AMPL model by specifying the constraints, then modifying the Big-M formulation to add the features
described above.
%%writefile maintenance_planning_ramp.mod

param T; # number of planning periods


param P; # number of maintenance periods
param M; # number of days for each maintenance period

param upos_max;
param uneg_max;

set PH := 1..T; # set of planning horizon time periods

set Y := 1..(T - M + 1);


set S := 0..(M - 1);

param c{PH}; # profit

var x{PH} >= 0, <= 1;


var y{PH} binary;
var upos{PH} >= 0, <= upos_max;
(continues on next page)

3.10. Extra material: Maintenance planning 165


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


var uneg{PH} >= 0, <= uneg_max;

maximize profit: sum{t in PH} c[t] * x[t];

s.t. required_maintenance: P == sum{t in Y} y[t];


s.t. no_overlap {t in Y}: sum{s in S} y[t + s] <= 1;
s.t. required_shutdown {t in Y}:
y[t] == 1 ==> sum{s in S} x[t + s] == 0;
s.t. ramp {t in PH: t != 1}:
x[t] == x[t-1] + upos[t] - uneg[t];

Writing maintenance_planning_ramp.mod

class MPRamp(MP):
def __init__(self, c, M, P, upos_max, uneg_max):
super(MPRamp, self).__init__(c, M, P)
super(MPRamp, self).set_modfile("maintenance_planning_ramp.mod")
self.upos_max = upos_max
self.uneg_max = uneg_max

def load_data(self):
super(MPRamp, self).load_data()
self.ampl.param["upos_max"] = self.upos_max
self.ampl.param["uneg_max"] = self.uneg_max

upos_max = 0.3334
uneg_max = 0.5000

model = MPRamp(c, M, P, upos_max, uneg_max)


model.plot_schedule()

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 39.53508979


4839 simplex iterations
4839 barrier iterations
12 branching nodes

3.10. Extra material: Maintenance planning 166


Hands-On Mathematical Optimization with AMPL in Python

3.10.6 Specifying the minimum number of operational days between maintenance


periods

Up to this point we have imposed no constraints on the frequency of maintenance periods. Without such constraints,
particularly when ramping constraints are imposed, is that maintenance periods will be scheduled back-to-back, which is
clearly not a useful result for most situations.
The next revision of the model is to incorporate a requirement that 𝑁 operational days be scheduled between any mainte-
nance periods. This does allow for maintenance to be postponed until the very end of the planning period. The disjunctive
constraints read
(𝑀+𝑁−1)∧(𝑡+𝑠≤𝑇 )
(𝑦𝑡 = 0) ⊻ ⎛
⎜ ∑ 𝑥𝑡+𝑠 = 0⎞
⎟ ∀𝑡 = 1, 2, … , 𝑇 − 𝑀 + 1
⎝ 𝑠=0 ⎠
where the upper bound on the summation is needed to handle the terminal condition.
Paradoxically, this is an example where the Big-M method provides a much faster solution.
(𝑀+𝑁−1)∧(𝑡+𝑠≤𝑇 )
∑ 𝑥𝑡+𝑠 ≤ (𝑀 + 𝑁 )(1 − 𝑦𝑡 ) ∀𝑡 = 1, 2, … , 𝑇 − 𝑀 + 1
𝑠=0

The following cell implements both sets of constraints.


%%writefile maintenance_planning_ramp_operational.mod

param T; # number of planning periods


param P; # number of maintenance periods
param M; # number of days for each maintenance period
param N;

param upos_max;
param uneg_max;

set PH := 1..T; # set of planning horizon time periods

set Y := 1..(T - M + 1);


set S := 0..(M - 1);
set W := 0..(M + N - 1);

param c{PH}; # profit

var x{PH} >= 0, <= 1;


var y{PH} binary;
var upos{PH} >= 0, <= upos_max;
var uneg{PH} >= 0, <= uneg_max;

maximize profit: sum{t in PH} c[t] * x[t];

# ramp constraint
s.t. ramp {t in PH: t > 1}:
x[t] == x[t-1] + upos[t] - uneg[t];
# required number P of maintenance starts
s.t. sumy: P == sum{t in Y} y[t];
# no more than one maintenance start in the period of length M
s.t. sprd {t in Y}:
sum{s in W: t + s <= T} y[t+s] <= 1;

(continues on next page)

3.10. Extra material: Maintenance planning 167


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


# Choose one of the following constraints (disj or bigm).
# Comment out the constraint not used.
#s.t. disj {t in Y}:
# y[t] == 1 ==> sum{s in W: t + s <= T} x[t+s] == 0;

s.t. bigm {t in Y}:


sum{s in W: t + s <= T} x[t+s] <= (M+N)*(1 - y[t]);

Writing maintenance_planning_ramp_operational.mod

class MPRampOperational(MPRamp):
def __init__(self, c, M, P, upos_max, uneg_max, N):
super(MPRampOperational, self).__init__(c, M, P, upos_max, uneg_max)
super(MPRampOperational, self).set_modfile(
"maintenance_planning_ramp_operational.mod"
)
self.N = N

def load_data(self):
super(MPRampOperational, self).load_data()
self.ampl.param["N"] = self.N

N = 10 # minimum number of operational days between maintenance periods


model = MPRampOperational(c, M, P, upos_max, uneg_max, N)
model.plot_schedule()

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 26.64390078


90873 simplex iterations
90873 barrier iterations
492 branching nodes

3.10. Extra material: Maintenance planning 168


Hands-On Mathematical Optimization with AMPL in Python

3.10.7 Exercises

1. Rather than specify how many maintenance periods must be accommodated, modify the model so that the process
unit can operate no more than 𝑁 days without a maintenance shutdown. (Hint. You may to introduce an additional
set of binary variables, 𝑧𝑡 to denote the start of an operational period.)
2. Do a systematic comparison of the Big-M, Convex Hull, and Cutting Plane techniques for implementing the dis-
junctive constraints. Your comparison should include a measure of complexity (such as the number of decision
variables and constraints in the resulting transformed problems), computational effort, and the effect of solver (such
as HiGHS vs cbc).

3.10. Extra material: Maintenance planning 169


CHAPTER

FOUR

4. NETWORK OPTIMIZATION

In the previous chapters, we dealt with general problems by first, formulating all the necessary constraints, and then,
passing the problem to an LP or MILP solver. In a way, we were blind to the problem structure. However, it is often
worth studying the problem structure first, as exploiting it can give us ideas for better solution methods. In this chapter,
we consider a very general class of problems with such a special structure – the network flow problems.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of network optimization:
• A dinner seating arrangement problem
• A franchise gasoline distribution problem
• A cryptocurrency arbitrage problem
• Extra material: Energy dispatch problem
• Extra material: Forex arbitrage
Go to the next chapter about convex optimization.

4.1 Dinner seating arrangement

# install dependencies and select solver


%pip install -q amplpy

SOLVER = "cbc"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

from IPython.display import display


import networkx as nx
import matplotlib.pyplot as plt
import pandas as pd

170
Hands-On Mathematical Optimization with AMPL in Python

4.1.1 Problem description

Consider organizing a wedding dinner. Your objective is for guests from different families to mingle with each other. One
way to encourage mingling is to seat people at the tables so that no more people than 𝑘 members of the same family take
a seat at the same table.
How could we solve a problem like this? First, we need the problem data – for each family 𝑓 we need to know the number
of its members 𝑚𝑓 , and for each table 𝑡 we need to know its capacity 𝑐𝑡 . Using this data and the tools we learned so far,
we can formulate this problem as a linear optimization (LO).
We can use variable 𝑥𝑓𝑡 for the number of persons from family 𝑓 to be seated at table 𝑡. Since we were not provided with
any objective function, we can focus on finding a feasible solution by setting the objective function to be constant, say 0,
which means that we do not differentiate between feasible solutions.
The mathematical formulation of this seating problem is:

min 0
𝑥𝑓𝑡

s.t. ∑ 𝑥𝑓𝑡 ≤ 𝑐𝑡 ∀𝑡 ∈ 𝑇
𝑓

∑ 𝑥𝑓𝑡 = 𝑚𝑓 ∀𝑓 ∈ 𝐹
𝑡
0 ≤ 𝑥𝑓𝑡 ≤ 𝑘.

The constraints ensure that the seating capacity for each table is not exceeded, that each family is fully seated, and that
the number of elements of each family at each table does not exceed the threshold 𝑘.

4.1.2 Implementation

The problem statement will be satisfied by finding a feasible solution, if one exists. Because no specific objective has been
specified, the mathematical formulation uses a constant 0 as essentially a placeholder for the objective function. Some
optimization solvers, however, issue warning messages if they detect a constant objective, and others will fail to execute at
all. A simple work around for these cases is to replace the constant objective with a ‘dummy’ variable that doesn’t appear
elsewhere in the optimization problem.

%%writefile table_seat.mod

set F;
set T;

param M{F};
param C{T};

param k;

var x{F, T} >= 0, <= k;


var dummy >= 0, <= 1, default 0;

minimize goal: dummy;

s.t. capacity {t in T}: sum{f in F} x[f, t] <= C[t];


s.t. seat {f in F}: sum{t in T} x[f, t] == M[f];

Overwriting table_seat.mod

4.1. Dinner seating arrangement 171


Hands-On Mathematical Optimization with AMPL in Python

def table_seat(members, capacity, k):


m = AMPL()
m.read("table_seat.mod")

m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))

m.param["M"] = members
m.param["C"] = capacity

m.param["k"] = k

m.option["solver"] = SOLVER

return m

def get_solution(model):
df = model.var["x"].to_pandas()
df.index.names = ["family", "table"]
df = df.unstack()
df.columns = df.columns.get_level_values(1)

return df.round(5)

def report(model, results, type=int):


solve_result = model.get_value("solve_result")
print(f"Solve result: {solve_result}")
if solve_result == "solved":
soln = get_solution(model).astype(type)
display(soln)
print(f'objective: {model.obj["goal"].value()}')
print(f"places at table: {list(soln.sum(axis=0))}")
print(f"members seated: {list(soln.sum(axis=1))}")

Let us now consider and solve a specific instance of this problem with six families with sizes 𝑚 = (6, 8, 2, 9, 13, 1), five
tables with capacities 𝑐 = (8, 8, 10, 4, 9), and a threshold 𝑘 = 3 for the number of members of each family that can be
seated at the same table.

seatplan = table_seat(members=[6, 8, 2, 9, 13, 1], capacity=[8, 8, 10, 4, 9], k=3)


%time results = seatplan.solve()
report(seatplan, results, type=float)

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 0


0 simplex iterations
CPU times: user 0 ns, sys: 1.35 ms, total: 1.35 ms
Wall time: 16.4 ms
Solve result: solved

table 0 1 2 3 4
family
0 0.0 3.0 1.0 0.0 2.0
1 3.0 0.0 3.0 0.0 2.0
2 0.0 0.0 0.0 0.0 2.0
3 1.0 2.0 3.0 3.0 0.0
(continues on next page)

4.1. Dinner seating arrangement 172


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


4 3.0 3.0 3.0 1.0 3.0
5 1.0 0.0 0.0 0.0 0.0

objective: 0.0
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]

A peculiar fact is that although we did not explicitly require that all variables 𝑥𝑓𝑡 be integer, the optimal solution turned
out to be integer anyway. This is no coincidence as it follows from a certain property of the problem we solve. This also
means we can solve larger versions of this problem with LO instead of MILO solvers to find integer solutions, gaining a
large computational advantage.

4.1.3 Minimize the maximum group size

Our objective was that we make members of different families mingle as much as possible. Is 𝑘 = 3 the lowest possible
number for which a feasible table allocation exists or can we make the tables even more diverse by bringing this number
down?
In order to find out, we change the objective function and try to minimize 𝑘, obtaining the following problem:

%%writefile table_seat_minimize_max_group_at_table.mod

set F;
set T;

param M{F};
param C{T};

var x{F,T} >= 0;


var k >= 0;

minimize goal: k;

s.t. capacity {t in T}: sum{f in F} x[f,t] <= C[t];


s.t. seat {f in F}: sum{t in T} x[f,t] == M[f];
s.t. bound {f in F, t in T}: x[f,t] <= k;

Overwriting table_seat_minimize_max_group_at_table.mod

def table_seat_minimize_max_group_at_table(members, capacity):


m = AMPL()
m.read("table_seat_minimize_max_group_at_table.mod")

m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))

m.param["M"] = members
m.param["C"] = capacity

m.option["solver"] = SOLVER

return m

We now solve the same instance as before.

4.1. Dinner seating arrangement 173


Hands-On Mathematical Optimization with AMPL in Python

seatplan = table_seat_minimize_max_group_at_table(
members=[6, 8, 2, 9, 13, 1], capacity=[8, 8, 10, 4, 9]
)
%time results = seatplan.solve()
report(seatplan, results, type=float)

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 2.6


0 simplex iterations
CPU times: user 0 ns, sys: 1.47 ms, total: 1.47 ms
Wall time: 16.3 ms
Solve result: solved

table 0 1 2 3 4
family
0 0.0 2.6 0.8 0.0 2.6
1 2.6 0.2 2.6 0.0 2.6
2 0.0 0.0 0.8 0.0 1.2
3 2.6 2.6 2.4 1.4 0.0
4 2.6 2.6 2.6 2.6 2.6
5 0.2 0.0 0.8 0.0 0.0

objective: 2.6
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]

Unfortunately, this solution is no longer integer. Mathematically, this is because the “structure” that previously ensured
integer solutions at no extra cost has been lost as a result of making 𝑘 a decision variable. To find the solution to this
problem we would need to impose that the variables are integers.

4.1.4 Minimize number of tables

We now add an integer variable y to minimize the number of tables in our model. In order to better compare the
diferences between a LO and a MILO implementation, we introduce the AMPL option relax_integrality. When
the option relax_integrality is set to 1 all the integer variables in the model are treated as linear. Conversely, if
relax_integrality is set to 0 the integrality restrictions are restored in the model.

%%writefile table_seat_minimize_number_of_tables.mod

set F;
set T;

param M{F};
param C{T};

param k;

var x{F,T} integer >= 0, <= k;


var y{T} binary;

minimize goal: sum{t in T} y[t];

s.t. capacity{t in T}: sum{f in F} x[f,t] <= C[t] * y[t];


s.t. seat{f in F}: sum{t in T} x[f,t] == M[f];

4.1. Dinner seating arrangement 174


Hands-On Mathematical Optimization with AMPL in Python

Overwriting table_seat_minimize_number_of_tables.mod

def table_seat_minimize_number_of_tables(members, capacity, k, relax=False):


m = AMPL()
m.read("table_seat_minimize_number_of_tables.mod")

m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))

m.param["M"] = members
m.param["C"] = capacity
m.param["k"] = k

m.option["solver"] = SOLVER

if relax:
m.option["relax_integrality"] = 1

return m

members = [6, 8, 2, 9, 13, 1]


capacity = [8, 8, 10, 4, 9]
k = 3

print("\nMILO problem\n")
seatplan = table_seat_minimize_number_of_tables(members, capacity, k)
%time results = seatplan.solve()
report(seatplan, results, type=int)

print("\nRelaxed problem\n")
seatplan = table_seat_minimize_number_of_tables(members, capacity, k, relax=True)
%time results = seatplan.solve()
report(seatplan, results, type=float)

MILO problem

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 5


0 simplex iterations
CPU times: user 477 µs, sys: 78 µs, total: 555 µs
Wall time: 17.3 ms
Solve result: solved

table 0 1 2 3 4
family
0 2 0 3 0 1
1 0 2 3 0 3
2 0 0 0 2 0
3 3 3 1 1 1
4 3 3 3 1 3
5 0 0 0 0 1

objective: 5.0
places at table: [8, 8, 10, 4, 9]
members seated: [6, 8, 2, 9, 13, 1]

(continues on next page)

4.1. Dinner seating arrangement 175


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Relaxed problem

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 5


0 simplex iterations
CPU times: user 464 µs, sys: 75 µs, total: 539 µs
Wall time: 15.6 ms
Solve result: solved

table 0 1 2 3 4
family
0 0.0 2.0 1.0 0.0 3.0
1 0.0 2.0 3.0 0.0 3.0
2 1.0 0.0 0.0 1.0 0.0
3 3.0 3.0 3.0 0.0 0.0
4 3.0 1.0 3.0 3.0 3.0
5 1.0 0.0 0.0 0.0 0.0

objective: 5.0
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]

4.2 Reformulation as max flow problem

However, using an MILO solver is not necessarily the best approach for problems like this. Many real-life situations
(assigning people to work/course groups) require solving really large problems. There are existing algorithms that can
leverage the special network structure of the problem at hand and scale better than LO solvers. To see this we can
visualize the seating problem using a graph where:
• the nodes on the left-hand side stand for the families and the numbers next to them provide the family size
• the nodes on the left-hand side stand for the tables and the numbers next to them provide the table size
• each left-to-right arrow stand comes with a number denoting the capacity of arc (𝑓, 𝑡) – how many people of family
𝑓 can be assigned to table 𝑡.

4.2. Reformulation as max flow problem 176


Hands-On Mathematical Optimization with AMPL in Python

If we see each family as a place of supply (people) and tables as places of demand (people), then we can see our original
problem as literally sending people from families 𝑓 to tables 𝑡 so that everyone is assigned to some table, the tables’
capacities are respected, and no table gets more than 𝑘 = 3 members of the same family.
An AMPL version of this model is given in the next cell. After that we will show how to reformulate the calculation using
network algorithms.

%%writefile table_seat_maximize_members_flow_to_tables.mod

set F;
set T;

param M{F};
param C{T};

param k;

var x{F,T} integer >= 0, <= k;

maximize goal: sum{f in F, t in T} x[f, t];

s.t. capacity {t in T}: sum{f in F} x[f,t] <= C[t];


s.t. seat {f in F}: sum{t in T} x[f,t] == M[f];

Overwriting table_seat_maximize_members_flow_to_tables.mod

4.2. Reformulation as max flow problem 177


Hands-On Mathematical Optimization with AMPL in Python

def table_seat_maximize_members_flow_to_tables(members, capacity, k, relax=False):


m = AMPL()
m.read("table_seat_maximize_members_flow_to_tables.mod")

m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))

m.param["M"] = members
m.param["C"] = capacity
m.param["k"] = k

m.option["solver"] = SOLVER

if relax:
m.option["relax_integrality"] = 1

return m

members = [6, 8, 2, 9, 13, 1]


capacity = [8, 8, 10, 4, 9]
k = 3

print("\nMILO problem\n")
seatplan = table_seat_maximize_members_flow_to_tables(members, capacity, k)
%time results = seatplan.solve()
report(seatplan, results, type=int)

print("\nRelaxed problem\n")
seatplan = table_seat_maximize_members_flow_to_tables(members, capacity, k,␣
↪relax=True)

%time results = seatplan.solve()


report(seatplan, results, type=float)

MILO problem

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 39


0 simplex iterations
CPU times: user 305 µs, sys: 51 µs, total: 356 µs
Wall time: 17.3 ms
Solve result: solved

table 0 1 2 3 4
family
0 1 2 3 0 0
1 1 0 3 1 3
2 0 2 0 0 0
3 3 0 1 2 3
4 3 3 3 1 3
5 0 1 0 0 0

objective: 39.0
places at table: [8, 8, 10, 4, 9]
members seated: [6, 8, 2, 9, 13, 1]

Relaxed problem
(continues on next page)

4.2. Reformulation as max flow problem 178


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 39


0 simplex iterations
CPU times: user 398 µs, sys: 67 µs, total: 465 µs
Wall time: 16 ms
Solve result: solved

table 0 1 2 3 4
family
0 0.0 3.0 0.0 0.0 3.0
1 1.0 1.0 3.0 0.0 3.0
2 0.0 0.0 2.0 0.0 0.0
3 3.0 3.0 2.0 1.0 0.0
4 3.0 1.0 3.0 3.0 3.0
5 1.0 0.0 0.0 0.0 0.0

objective: 39.0
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]

By adding two more nodes to the graph above, we can formulate the problem as a slightly different flow problem where all
the data is formulated as the arc capacity, see figure below. In a network like this, we can imagine a problem of sending
resources from the root node “door” to the sink node “seat”, subject to the restriction that for any node apart from 𝑠 and
𝑡, the sum of incoming and outgoing flows are equal (balance constraint). If there exists a flow in this new graph that
respects the arc capacities and the sum of outgoing flows at 𝑠 is equal to ∑𝑓∈𝐹 𝑚𝑓 = 39, it means that there exists a
family-to-table assignment that meets our requirements.

4.2. Reformulation as max flow problem 179


Hands-On Mathematical Optimization with AMPL in Python

def model_as_network(members, capacity, k):


# create lists of families and tables
families = [f"f{i}" for i in range(len(members))]
tables = [f"t{j}" for j in range(len(capacity))]

# create digraphy object


G = nx.DiGraph()

# add edges
G.add_edges_from(["door", f, {"capacity": n}] for f, n in zip(families, members))
G.add_edges_from([(f, t) for f in families for t in tables], capacity=k)
G.add_edges_from([t, "seat", {"capacity": n}] for t, n in zip(tables, capacity))

return G

members = [6, 8, 2, 9, 13, 1]


capacity = [8, 8, 10, 4, 9]

G = model_as_network(members, capacity=[8, 8, 10, 4, 9], k=3)

labels = {(e[0], e[1]): e[2] for e in G.edges(data="capacity")}

%time flow_value, flow_dict = nx.maximum_flow(G, 'door', 'seat')

4.2. Reformulation as max flow problem 180


Hands-On Mathematical Optimization with AMPL in Python

CPU times: user 3.37 ms, sys: 0 ns, total: 3.37 ms


Wall time: 3.38 ms

families = [f"f{i:.0f}" for i in range(len(members))]


tables = [f"t{j:.0f}" for j in range(len(capacity))]

pd.DataFrame(flow_dict).loc[tables, families].astype("int")

f0 f1 f2 f3 f4 f5
t0 2 0 0 2 3 1
t1 0 1 1 3 3 0
t2 1 3 0 3 3 0
t3 0 1 0 0 3 0
t4 3 3 1 1 1 0

Even for this very small example, we see that network algorithms generate a solution significantly faster.

4.3 Gasoline distribution

This notebook presents a transportation model to optimally allocate the delivery of a commodity from multiple sources
to multiple destinations. The model invites a discussion of the pitfalls in optimizing a global objective for customers who
may have an uneven share of the resulting benefits, then through model refinement arrives at a group cost-sharing plan to
delivery costs.
Didactically, notebook presents techniques for AMPL modeling and reporting including:
• Accessing the duals (i.e., shadow prices)
• Methods for reporting the solution and duals.
– .display() method for AMPL entities
– Manually formatted reports
– Pandas

# install dependencies and select solver


%pip install -q amplpy

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.6/5.6 MB 19.7 MB/s eta 0:00:00


?25hUsing default Community Edition License for Colab. Get yours at: https://ampl.com/
↪ce

Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

4.3. Gasoline distribution 181


Hands-On Mathematical Optimization with AMPL in Python

import pandas as pd
from IPython.display import HTML, display
import matplotlib.pyplot as plt

4.3.1 Problem: Distributing gasoline to franchise operators

YaYa Gas-n-Grub is franchiser and operator for a network of regional convenience stores selling gasoline and convenience
items in the United States. Each store is individually owned by a YaYa Gas-n-Grub franchisee who pays a fee to the
franchiser for services. Gasoline is delivered by truck from regional distribution terminals. Each delivery truck carries
8,000 gallons delivered at a fixed charge of 700€ per delivery, or 0.0875€ per gallon. The franchise owners are eager to
reduce delivery costs to boost profits. YaYa Gas-n-Grub decides to accept proposals from other distribution terminals, “A”
and “B”, to supply the franchise operators. Rather than a fixed fee per delivery, they proposed pricing based on location.
But they already have existing customers, “A” and “B” can only provide a limited amount of gasoline to new customers
totaling 100,000 and 80,000 gallons respectively. The only difference between the new suppliers and the current supplier
is the delivery charge.
The following chart shows the pricing of gasoline delivery in cents/gallon.

Franchisee Demand Terminal A 100,000 Terminal B 80,000 Current Supplier 500,000


Alice 30,000 8.3 10.2 8.75
Badri 40,000 8.1 12.0 8.75
Cara 50,000 8.3 - 8.75
Dan 80,000 9.3 8.0 8.75
Emma 30,000 10.1 10.0 8.75
Fujita 45,000 9.8 10.0 8.75
Grace 80,000 - 8.0 8.75
Helen 18,000 7.5 10.0 8.75
TOTAL 313,000

The operator of YaYa Gas-n-Grub has the challenge of allocating the gasoline delivery to minimize the cost to the franchise
owners. The following model will present a global objective to minimize the total cost of delivery to all franchise owners.

4.3.2 Model 1: Minimize total delivery cost

The decision variables for this example are labeled 𝑥𝑑,𝑠 where subscript 𝑑 ∈ 1, … , 𝑛𝑑 refers to the destination of the
delivery and subscript 𝑠 ∈ 1, … , 𝑛𝑠 to the source. The value of 𝑥𝑑,𝑠 is the volume of gasoline shipped to destination 𝑑
from source 𝑠. Given the cost rate 𝑟𝑑,𝑠 for shipping one unit of goods from 𝑑 to 𝑠, the objective is to minimize the total
cost of transporting gasoline from the sources to the destinations subject to meeting the demand requirements, 𝐷𝑑 , at all
destinations, and satisfying the supply constraints, 𝑆𝑠 , at all sources. The full mathematical formulation is:
𝑛𝑑 𝑛𝑠
min ∑ ∑ 𝑟𝑑,𝑠 𝑥𝑑,𝑠
𝑑=1 𝑠=1
𝑛𝑠
s.t. ∑ 𝑥𝑑,𝑠 = 𝐷𝑑 ∀ 𝑑 ∈ 1, … , 𝑛𝑑 (demand constraints)
𝑠=1
𝑛𝑑
∑ 𝑥𝑑,𝑠 ≤ 𝑆𝑠 ∀ 𝑠 ∈ 1, … , 𝑛𝑠 (supply constraints)
𝑑=1

4.3. Gasoline distribution 182


Hands-On Mathematical Optimization with AMPL in Python

4.3.3 Data Entry

The data is stored into Pandas DataFrame and Series objects. Note the use of a large rates to avoid assigning shipments
to destination-source pairs not allowed by the problem statement.

rates = pd.DataFrame(
[
["Alice", 8.3, 10.2, 8.75],
["Badri", 8.1, 12.0, 8.75],
["Cara", 8.3, 100.0, 8.75],
["Dan", 9.3, 8.0, 8.75],
["Emma", 10.1, 10.0, 8.75],
["Fujita", 9.8, 10.0, 8.75],
["Grace", 100, 8.0, 8.75],
["Helen", 7.5, 10.0, 8.75],
],
columns=["Destination", "Terminal A", "Terminal B", "Current Supplier"],
).set_index("Destination")

demand = pd.Series(
{
"Alice": 30000,
"Badri": 40000,
"Cara": 50000,
"Dan": 20000,
"Emma": 30000,
"Fujita": 45000,
"Grace": 80000,
"Helen": 18000,
},
name="demand",
)

supply = pd.Series(
{"Terminal A": 100000, "Terminal B": 80000, "Current Supplier": 500000},
name="supply",
)

display(HTML("<br><b>Gasoline Supply (Gallons)</b>"))


display(supply.to_frame())

display(HTML("<br><b>Gasoline Demand (Gallons)</b>"))


display(demand.to_frame())

display(HTML("<br><b>Transportation Rates (€ cents per Gallon)</b>"))


display(rates)

<IPython.core.display.HTML object>

supply
Terminal A 100000
Terminal B 80000
Current Supplier 500000

<IPython.core.display.HTML object>

4.3. Gasoline distribution 183


Hands-On Mathematical Optimization with AMPL in Python

demand
Alice 30000
Badri 40000
Cara 50000
Dan 20000
Emma 30000
Fujita 45000
Grace 80000
Helen 18000

<IPython.core.display.HTML object>

Terminal A Terminal B Current Supplier


Destination
Alice 8.3 10.2 8.75
Badri 8.1 12.0 8.75
Cara 8.3 100.0 8.75
Dan 9.3 8.0 8.75
Emma 10.1 10.0 8.75
Fujita 9.8 10.0 8.75
Grace 100.0 8.0 8.75
Helen 7.5 10.0 8.75

The following AMPL model is an implementation of the mathematical optimization model described above. The sets
and indices have been designated with more descriptive symbols readability and maintenance.

%%writefile transport.mod

set SOURCES;
set DESTINATIONS;

param Rates{DESTINATIONS, SOURCES};


param supply{SOURCES};
param demand{DESTINATIONS};

var x{DESTINATIONS, SOURCES} >= 0;

minimize total_cost:
sum{dst in DESTINATIONS, src in SOURCES} Rates[dst, src] * x[dst, src];

var cost_to_destination {dst in DESTINATIONS} =


sum{src in SOURCES} Rates[dst, src] * x[dst, src];

var shipped_to_destination {dst in DESTINATIONS} =


sum{src in SOURCES} x[dst, src];

var shipped_from_source {src in SOURCES} =


sum{dst in DESTINATIONS} x[dst, src];

s.t. supply_constraint {src in SOURCES}:


shipped_from_source[src] <= supply[src];

s.t. demand_constraint {dst in DESTINATIONS}:


shipped_to_destination[dst] == demand[dst];

Writing transport.mod

4.3. Gasoline distribution 184


Hands-On Mathematical Optimization with AMPL in Python

def transport(supply, demand, rates):


m = AMPL()
m.read("transport.mod")

m.set["SOURCES"] = rates.columns
m.set["DESTINATIONS"] = rates.index

m.param["Rates"] = rates
m.param["supply"] = supply
m.param["demand"] = demand

m.option["solver"] = SOLVER
m.solve()

return m

def get_results(m):
results = m.var["x"].to_pandas().unstack()
results.columns = results.columns.get_level_values(1)
results = results.reindex(index=rates.index, columns=rates.columns)
results["current costs"] = 700 * demand / 8000
results["contract costs"] = m.var["cost_to_destination"].to_pandas()
results["savings"] = results["current costs"].round(1) - results[
"contract costs"
].round(1)
results["contract rate"] = round(results["contract costs"] / demand, 4)
results["marginal cost"] = m.con["demand_constraint"].to_pandas()

return results

m = transport(supply, demand, rates / 100)

model1_results = get_results(m)

print(f"Old Delivery Costs = {sum(demand)*700/8000:.2f}€")


print(f"New Delivery Costs = {m.obj['total_cost'].value():.2f}€\n")
display(model1_results)

model1_results.plot(y="savings", kind="bar")

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 26113.5


1 simplex iterations
0 barrier iterations

Old Delivery Costs = 27387.50€


New Delivery Costs = 26113.50€

Terminal A Terminal B Current Supplier current costs \


Destination
Alice 0 0 30000 2625.0
Badri 40000 0 0 3500.0
Cara 42000 0 8000 4375.0
Dan 0 0 20000 1750.0
Emma 0 0 30000 2625.0
Fujita 0 0 45000 3937.5
(continues on next page)

4.3. Gasoline distribution 185


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Grace 0 80000 0 7000.0
Helen 18000 0 0 1575.0

contract costs savings contract rate marginal cost


Destination
Alice 2625.0 0.0 0.0875 0.0875
Badri 3240.0 260.0 0.0810 0.0855
Cara 4186.0 189.0 0.0837 0.0875
Dan 1750.0 0.0 0.0875 0.0875
Emma 2625.0 0.0 0.0875 0.0875
Fujita 3937.5 0.0 0.0875 0.0875
Grace 6400.0 600.0 0.0800 0.0875
Helen 1350.0 225.0 0.0750 0.0795

<Axes: xlabel='Destination'>

4.3. Gasoline distribution 186


Hands-On Mathematical Optimization with AMPL in Python

4.3.4 Model 2: Minimize cost rate for franchise owners

Minimizing total costs provides no guarantee that individual franchise owners will benefit equally, or in fact benefit at all,
from minimizing total costs. In this example neither Emma or Fujita would save any money on delivery costs, and the
majority of savings goes to just two of the franchisees. Without a better distribution of the benefits there may be little
enthusiasm among the franchisees to adopt change. This observation motivates an attempt at a second model. In this case
the objective is minimize a common rate for the cost of gasoline distribution subject to meeting the demand and supply
constraints, 𝑆𝑠 , at all sources. The mathematical formulation of this different problem is as follows:

min 𝜌
𝑛𝑠
s.t. ∑ 𝑥𝑑,𝑠 = 𝐷𝑑 ∀ 𝑑 ∈ 1, … , 𝑛𝑑 (demand constraints)
𝑠=1
𝑛𝑑
∑ 𝑥𝑑,𝑠 ≤ 𝑆𝑠 ∀ 𝑠 ∈ 1, … , 𝑛𝑠 (supply constraints)
𝑑=1
𝑛𝑠
∑ 𝑟𝑑,𝑠 𝑥𝑑,𝑠 ≤ 𝜌𝐷𝑑 ∀𝑑 ∈ 1, … , 𝑛𝑑 (common cost rate)
𝑠=1

The following AMPL model implements this formulation.

%%writefile transport2.mod

set SOURCES;
set DESTINATIONS;

param Rates{DESTINATIONS, SOURCES};


param supply{SOURCES};
param demand{DESTINATIONS};

var x{DESTINATIONS, SOURCES} >= 0;


var rate;

minimize delivery_rate: rate;

var cost_to_destination {dst in DESTINATIONS} =


sum{src in SOURCES} Rates[dst, src] * x[dst, src];

var total_cost = sum{dst in DESTINATIONS} cost_to_destination[dst];

s.t. rate_to_destination {dst in DESTINATIONS}:


cost_to_destination[dst] == rate * demand[dst];

var shipped_to_destination {dst in DESTINATIONS} =


sum{src in SOURCES} x[dst, src];

var shipped_from_source {src in SOURCES} =


sum{dst in DESTINATIONS} x[dst, src];

s.t. supply_constraint {src in SOURCES}:


shipped_from_source[src] <= supply[src];

s.t. demand_constraint {dst in DESTINATIONS}:


shipped_to_destination[dst] == demand[dst];

4.3. Gasoline distribution 187


Hands-On Mathematical Optimization with AMPL in Python

Writing transport2.mod

def transport2(supply, demand, rates):


m = AMPL()
m.read("transport2.mod")

m.set["SOURCES"] = rates.columns
m.set["DESTINATIONS"] = rates.index

m.param["Rates"] = rates
m.param["supply"] = supply
m.param["demand"] = demand

m.option["solver"] = SOLVER
m.solve()

return m

m = transport2(supply, demand, rates / 100)

print(f"Old Delivery Costs = $ {sum(demand)*700/8000}")


print(f"New Delivery Costs = $ {m.var['total_cost'].value()}")
results = get_results(m)
display(results.round(1))

results.plot(y="savings", kind="bar")
plt.show()

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 0.0875


12 simplex iterations
0 barrier iterations

Old Delivery Costs = $ 27387.5


New Delivery Costs = $ 27387.5

Terminal A Terminal B Current Supplier current costs \


Destination
Alice 22894.7 7105.3 0.0 2625.0
Badri 0.0 0.0 40000.0 3500.0
Cara 49754.6 245.4 0.0 4375.0
Dan 0.0 0.0 20000.0 1750.0
Emma 0.0 0.0 30000.0 2625.0
Fujita 0.0 0.0 45000.0 3937.5
Grace 0.0 0.0 80000.0 7000.0
Helen 9000.0 9000.0 0.0 1575.0

contract costs savings contract rate marginal cost


Destination
Alice 2625.0 0.0 0.1 0.0
Badri 3500.0 0.0 0.1 0.0
Cara 4375.0 0.0 0.1 0.0
Dan 1750.0 0.0 0.1 0.0
Emma 2625.0 0.0 0.1 0.0
Fujita 3937.5 0.0 0.1 0.0
Grace 7000.0 0.0 0.1 0.0
Helen 1575.0 0.0 0.1 0.0

4.3. Gasoline distribution 188


Hands-On Mathematical Optimization with AMPL in Python

4.3.5 Model 3: Minimize total cost for a cost-sharing plan

The prior two models demonstrated some practical difficulties in realizing the benefits of a cost optimization plan. Model
1 will likely fail in a franchiser/franchisee arrangement because the realized savings would be fore the benefit of a few.
Model 2 was an attempt to remedy the problem by solving for an allocation of deliveries that would lower the cost rate that
would be paid by each franchisee directly to the gasoline distributors. Perhaps surprisingly, the resulting solution offered
no savings to any franchisee. Inspecting the data shows the source of the problem is that two franchisees, Emma and
Fujita, simply have no lower cost alternative than the current supplier. Therefore finding a distribution plan with direct
payments to the distributors that lowers everyone’s cost is an impossible task.
The third model addresses this problem with a plan to share the cost savings among the franchisees. In this plan, the
franchiser would collect delivery fees from the franchisees to pay the gasoline distributors. The optimization objective
returns to the problem to minimizing total delivery costs, but then adds a constraint that defines a common cost rate
to charge all franchisees. By offering a benefit to all parties, the franchiser offers incentive for group participation in
contracting for gasoline distribution services.

4.3. Gasoline distribution 189


Hands-On Mathematical Optimization with AMPL in Python

In mathematical terms, the problem can be formulated as follows:


𝑛𝑑 𝑛𝑠
min ∑ ∑ 𝑟𝑑,𝑠 𝑥𝑑,𝑠
𝑑=1 𝑠=1
𝑛𝑠
s.t. ∑ 𝑥𝑑,𝑠 = 𝐷𝑑 ∀ 𝑑 ∈ 1, … , 𝑛𝑑 (demand constraints)
𝑠=1
𝑛𝑑
∑ 𝑥𝑑,𝑠 ≤ 𝑆𝑠 ∀ 𝑠 ∈ 1, … , 𝑛𝑠 (supply constraints)
𝑑=1
𝑛𝑠
∑ 𝑟𝑑,𝑠 𝑥𝑑,𝑠 = 𝜌𝐷𝑑 ∀𝑑 ∈ 1, … , 𝑛𝑑 (uniform cost sharing rate)
𝑠=1

%%writefile transport3.mod

set SOURCES;
set DESTINATIONS;

param Rates{DESTINATIONS, SOURCES};


param supply{SOURCES};
param demand{DESTINATIONS};

var x{DESTINATIONS, SOURCES} >= 0;


var rate;

minimize total_cost:
sum{dst in DESTINATIONS, src in SOURCES} Rates[dst, src] * x[dst, src];

var cost_to_destination {dst in DESTINATIONS} =


rate * demand[dst];

s.t. allocate_costs:
sum{dst in DESTINATIONS} cost_to_destination[dst] == m.total_cost;

var shipped_to_destination {dst in DESTINATIONS} =


sum{src in SOURCES} x[dst, src];

var shipped_from_source {src in SOURCES} =


sum{dst in DESTINATIONS} x[dst, src];

s.t. supply_constraint {src in SOURCES}:


shipped_from_source[src] <= supply[src];

s.t. demand_constraint {dst in DESTINATIONS}:


shipped_to_destination[dst] == demand[dst];

Writing transport3.mod

def transport3(supply, demand, rates):


m = AMPL()
m.read("transport3.mod")

m.set["SOURCES"] = rates.columns
m.set["DESTINATIONS"] = rates.index
(continues on next page)

4.3. Gasoline distribution 190


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

m.param["Rates"] = rates
m.param["supply"] = supply
m.param["demand"] = demand

m.option["solver"] = SOLVER
m.solve()

return m

m = transport(supply, demand, rates / 100)

print(f"Old Delivery Costs = {sum(demand)*700/8000:.2f}€")


print(f"New Delivery Costs = {m.obj['total_cost'].value():.2f}€\n")
model3_results = get_results(m)
display(model3_results)

model3_results.plot(y="savings", kind="bar")

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 26113.5


1 simplex iterations
0 barrier iterations

Old Delivery Costs = 27387.50€


New Delivery Costs = 26113.50€

Terminal A Terminal B Current Supplier current costs \


Destination
Alice 0 0 30000 2625.0
Badri 40000 0 0 3500.0
Cara 42000 0 8000 4375.0
Dan 0 0 20000 1750.0
Emma 0 0 30000 2625.0
Fujita 0 0 45000 3937.5
Grace 0 80000 0 7000.0
Helen 18000 0 0 1575.0

contract costs savings contract rate marginal cost


Destination
Alice 2625.0 0.0 0.0875 0.0875
Badri 3240.0 260.0 0.0810 0.0855
Cara 4186.0 189.0 0.0837 0.0875
Dan 1750.0 0.0 0.0875 0.0875
Emma 2625.0 0.0 0.0875 0.0875
Fujita 3937.5 0.0 0.0875 0.0875
Grace 6400.0 600.0 0.0800 0.0875
Helen 1350.0 225.0 0.0750 0.0795

<Axes: xlabel='Destination'>

4.3. Gasoline distribution 191


Hands-On Mathematical Optimization with AMPL in Python

4.3.6 Comparing model results

The following charts demonstrate the difference in outcomes for Model 1 and Model 3 (Model 2 was left out as entirely
inadequate). The group cost-sharing arrangement produces the same group savings, but distributes the benefits in a manner
likely to be more acceptable to the majority of participants.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 2, figsize=(12, 3))


alpha = 0.45

model1_results.plot(y=["savings"], kind="bar", ax=ax[0], color="g", alpha=alpha)


model1_results.plot(y="savings", marker="o", ax=ax[0], color="g", alpha=alpha)

model3_results.plot(y="savings", kind="bar", ax=ax[0], color="r", alpha=alpha)


model3_results.plot(y="savings", marker="o", ax=ax[0], color="r", alpha=alpha)
ax[0].legend(["Model 1", "Model 3"])
ax[0].set_title("Delivery costs by franchise")

model1_results.plot(y=["contract rate"], kind="bar", ax=ax[1], color="g", alpha=alpha)


model1_results.plot(y="contract rate", marker="o", ax=ax[1], color="g", alpha=alpha)

model3_results.plot(y="contract rate", kind="bar", ax=ax[1], color="r", alpha=alpha)


model3_results.plot(y="contract rate", marker="o", ax=ax[1], color="r", alpha=alpha)
ax[1].set_ylim(0.07, 0.09)
(continues on next page)

4.3. Gasoline distribution 192


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


ax[1].legend(["Model 1", "Model 3"])
ax[1].set_title("Contract rates by franchise")
plt.show()

4.3.7 Didactics: Reporting solutions

AMPL models can produce considerable amounts of data that must be summarized and presented for analysis and decision
making. In this application, for example, the individual franchise owners receive differing amounts of savings which is
certain to result in considerable discussion and possibly negotiation with the franchiser.
The following cells demonstrate techniques for extracting and displaying information generated by an AMPL model.

AMPL .display() method

AMPL provides a default .display() method for AMPL entities. The default display is often sufficient for model
reporting requirements, particularly when initially developing a new application.
# display elements of sets
m.display("SOURCES")
m.display("DESTINATIONS")

set SOURCES := 'Terminal A' 'Terminal B' 'Current Supplier';

set DESTINATIONS := Alice Badri Cara Dan Emma Fujita Grace Helen;

# display elements of an indexed parameter


m.display("Rates")

Rates [*,*]
: 'Current Supplier' 'Terminal A' 'Terminal B' :=
Alice 0.0875 0.083 0.102
Badri 0.0875 0.081 0.12
Cara 0.0875 0.083 1
Dan 0.0875 0.093 0.08
Emma 0.0875 0.101 0.1
Fujita 0.0875 0.098 0.1
Grace 0.0875 1 0.08
Helen 0.0875 0.075 0.1
;

4.3. Gasoline distribution 193


Hands-On Mathematical Optimization with AMPL in Python

# display elements of an auxiliary variable


m.display("shipped_to_destination")

shipped_to_destination [*] :=
Alice 30000
Badri 40000
Cara 50000
Dan 20000
Emma 30000
Fujita 45000
Grace 80000
Helen 18000
;

m.display("shipped_from_source")

shipped_from_source [*] :=
'Current Supplier' 133000
'Terminal A' 1e+05
'Terminal B' 80000
;

# display Objective
m.display("total_cost")

total_cost = 26113.5

# display indexed Constraints


m.display("supply_constraint") # dual is displayed by default
m.display(
"supply_constraint.lb, supply_constraint.body, supply_constraint.ub, supply_
↪constraint.dual"

)
m.display("demand_constraint")

supply_constraint [*] :=
'Current Supplier' 0
'Terminal A' -0.0045
'Terminal B' -0.0075
;

# $2 = supply_constraint.body
# $3 = supply_constraint.ub
# $4 = supply_constraint.dual
: supply_constraint.lb $2 $3 $4 :=
'Current Supplier' -Infinity 133000 5e+05 0
'Terminal A' -Infinity 1e+05 1e+05 -0.0045
'Terminal B' -Infinity 80000 80000 -0.0075
;

demand_constraint [*] :=
Alice 0.0875
Badri 0.0855
Cara 0.0875
Dan 0.0875
(continues on next page)

4.3. Gasoline distribution 194


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Emma 0.0875
Fujita 0.0875
Grace 0.0875
Helen 0.0795
;

# display decision variables


m.display("x")

x [*,*]
: 'Current Supplier' 'Terminal A' 'Terminal B' :=
Alice 30000 0 0
Badri 0 40000 0
Cara 8000 42000 0
Dan 20000 0 0
Emma 30000 0 0
Fujita 45000 0 0
Grace 0 0 80000
Helen 0 18000 0
;

# display decision variables with bounds and reduced cost


m.display("x.lb", "x", "x.ub", "x.rc")

: x.lb x x.ub x.rc :=


Alice 'Current Supplier' 0 30000 Infinity 0
Alice 'Terminal A' 0 0 Infinity 0
Alice 'Terminal B' 0 0 Infinity 0.022
Badri 'Current Supplier' 0 0 Infinity 0.002
Badri 'Terminal A' 0 40000 Infinity 0
Badri 'Terminal B' 0 0 Infinity 0.042
Cara 'Current Supplier' 0 8000 Infinity 0
Cara 'Terminal A' 0 42000 Infinity 0
Cara 'Terminal B' 0 0 Infinity 0.92
Dan 'Current Supplier' 0 20000 Infinity 0
Dan 'Terminal A' 0 0 Infinity 0.01
Dan 'Terminal B' 0 0 Infinity 0
Emma 'Current Supplier' 0 30000 Infinity 0
Emma 'Terminal A' 0 0 Infinity 0.018
Emma 'Terminal B' 0 0 Infinity 0.02
Fujita 'Current Supplier' 0 45000 Infinity 0
Fujita 'Terminal A' 0 0 Infinity 0.015
Fujita 'Terminal B' 0 0 Infinity 0.02
Grace 'Current Supplier' 0 0 Infinity 0
Grace 'Terminal A' 0 0 Infinity 0.917
Grace 'Terminal B' 0 80000 Infinity 0
Helen 'Current Supplier' 0 0 Infinity 0.008
Helen 'Terminal A' 0 18000 Infinity 0
Helen 'Terminal B' 0 0 Infinity 0.028
;

4.3. Gasoline distribution 195


Hands-On Mathematical Optimization with AMPL in Python

Manually formatted reports

Following solution, the value associated with AMPL entities are returned by calling the entity as a function. The following
cell demonstrates the construction of a custom report using Python f-strings and AMPL methods.

# Objective report
print("\nObjective: cost")
print(f"cost = {m.obj['total_cost'].value()}")

SOURCES = m.set["SOURCES"].to_list()
DESTINATIONS = m.set["DESTINATIONS"].to_list()

# Constraint reports
supply_constraint_body = m.get_data("supply_constraint.body").to_dict()
supply_constraint_rc = m.con["supply_constraint"].to_dict()
print("\nConstraint: supply_constraint")
for src in SOURCES:
print(
f"{src:12s} {supply_constraint_body[src]:8.2f} {supply_constraint_rc[src]:8.
↪2f}"

demand_constraint_body = m.get_data("demand_constraint.body").to_dict()
demand_constraint_rc = m.con["demand_constraint"].to_dict()
print("\nConstraint: demand_constraint")
for dst in DESTINATIONS:
print(
f"{dst:12s} {demand_constraint_body[dst]:8.2f} {demand_constraint_rc[dst]:8.
↪2f}"

# Decision variable reports


print("\nDecision variables: x")
x = m.var["x"].to_dict()
for src in SOURCES:
for dst in DESTINATIONS:
print(f"{src:12s} -> {dst:12s} {x[dst, src]:8.2f}")
print()

Objective: cost
cost = 26113.5

Constraint: supply_constraint
Terminal A 100000.00 -0.00
Terminal B 80000.00 -0.01
Current Supplier 133000.00 0.00

Constraint: demand_constraint
Alice 30000.00 0.09
Badri 40000.00 0.09
Cara 50000.00 0.09
Dan 20000.00 0.09
Emma 30000.00 0.09
Fujita 45000.00 0.09
Grace 80000.00 0.09
Helen 18000.00 0.08

(continues on next page)

4.3. Gasoline distribution 196


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Decision variables: x
Terminal A -> Alice 0.00
Terminal A -> Badri 40000.00
Terminal A -> Cara 42000.00
Terminal A -> Dan 0.00
Terminal A -> Emma 0.00
Terminal A -> Fujita 0.00
Terminal A -> Grace 0.00
Terminal A -> Helen 18000.00

Terminal B -> Alice 0.00


Terminal B -> Badri 0.00
Terminal B -> Cara 0.00
Terminal B -> Dan 0.00
Terminal B -> Emma 0.00
Terminal B -> Fujita 0.00
Terminal B -> Grace 80000.00
Terminal B -> Helen 0.00

Current Supplier -> Alice 30000.00


Current Supplier -> Badri 0.00
Current Supplier -> Cara 8000.00
Current Supplier -> Dan 20000.00
Current Supplier -> Emma 30000.00
Current Supplier -> Fujita 45000.00
Current Supplier -> Grace 0.00
Current Supplier -> Helen 0.00

Pandas

The Python Pandas library provides a highly flexible framework for data science applications. The next cell demonstrates
the translation of values of AMPL entities to Pandas DataFrames
suppliers = pd.DataFrame(supply)
suppliers["shipped"] = m.get_data("supply_constraint.body").to_pandas()
suppliers["sensitivity"] = m.con["supply_constraint"].to_pandas()

display(suppliers)

customers = pd.DataFrame(demand)
customers["shipped"] = m.get_data("demand_constraint.body").to_pandas()
customers["sensitivity"] = m.con["demand_constraint"].to_pandas()

display(customers)

shipments = m.var["x"].to_pandas()
shipments.index.names = ["destinations", "sources"]
shipments = shipments.unstack()
shipments.columns = shipments.columns.get_level_values(1)
display(shipments)
shipments.plot(kind="bar")
plt.show()

supply shipped sensitivity


Terminal A 100000 100000 -0.0045
(continues on next page)

4.3. Gasoline distribution 197


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Terminal B 80000 80000 -0.0075
Current Supplier 500000 133000 0.0000

demand shipped sensitivity


Alice 30000 30000 0.0875
Badri 40000 40000 0.0855
Cara 50000 50000 0.0875
Dan 20000 20000 0.0875
Emma 30000 30000 0.0875
Fujita 45000 45000 0.0875
Grace 80000 80000 0.0875
Helen 18000 18000 0.0795

sources Current Supplier Terminal A Terminal B


destinations
Alice 30000 0 0
Badri 0 40000 0
Cara 8000 42000 0
Dan 20000 0 0
Emma 30000 0 0
Fujita 45000 0 0
Grace 0 0 80000
Helen 0 18000 0

4.3. Gasoline distribution 198


Hands-On Mathematical Optimization with AMPL in Python

Graphviz

The graphviz utility is a collection of tools for visually graphs and directed graphs. Unfortunately, the package can
be troublesome to install on laptops in a way that is compatible with many JupyterLab installations. Accordingly, the
following cell is intended for use on Google Colab which provides a preinstalled version of graphviz.

import sys

if "google.colab" in sys.modules:
import graphviz
from graphviz import Digraph

dot = Digraph(
node_attr={"fontsize": "10", "shape": "rectangle", "style": "filled"},
edge_attr={"fontsize": "10"},
)

for index, row in suppliers.iterrows():


label = (
f"{index}"
+ f"\nsupply = {row['supply']}"
+ f"\nshipped = {row['shipped']}"
+ f"\nsens = {row['sensitivity']}"
)
dot.node(src, label=label, fillcolor="lightblue")

for index, row in customers.iterrows():


label = (
f"{dst}"
+ f"\ndemand = {row['demand']}"
+ f"\nshipped = {row['shipped']}"
+ f"\nsens = {row['sensitivity']:.6g}"
)
dot.node(dst, label=label, fillcolor="gold")

for src in SOURCES:


for dst in DESTINATIONS:
if shipments.loc[dst, src] > 0:
dot.edge(
src,
dst,
f"rate = {rates.loc[dst, src]}\nshipped = {shipments.loc[dst,␣
↪src]}",

display(dot)

<graphviz.graphs.Digraph at 0x78e4901657e0>

4.3. Gasoline distribution 199


Hands-On Mathematical Optimization with AMPL in Python

4.4 Cryptocurrency arbitrage search

Cryptocurrency exchanges are web services that enable the purchase, sale, and exchange of cryptocurrencies. These
exchanges provide liquidity for owners and establish the relative value of these currencies. Joining an exchange enables
a user to maintain multiple currencies in a digital wallet, buy and sell currencies, and use cryptocurrencies for financial
transactions.
In this example, we explore the efficiency of cryptocurrency exchanges by testing for arbitrage opportunities. An arbitrage
exists if a customer can realize a net profit through a sequence of risk-free trades. The efficient market hypothesis assumes
arbitrage opportunities are quickly identified and exploited by investors. As a result of their trading, prices reach a
new equilibrium so that any arbitrage opportunities would be small and fleeting in an efficient market. The question
here is whether it is possible, with real-time data and rapid execution, for a trader to profit from these fleeting arbitrage
opportunities.

4.4.1 Installations and Imports

AMPL and Solvers

First we install AMPL, solvers and necessary modules. This notebook uses the open-source library ccxt. ccxt supports
the real-time APIs of the largest and most common exchanges on which cryptocurrencies are traded.

# install dependencies and select solver


%pip install -q amplpy ccxt matplotlib networkx numpy pandas

SOLVER = "cbc"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

import os
import sys
from time import time
from timeit import default_timer as timer

import ccxt
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd

4.4. Cryptocurrency arbitrage search 200


Hands-On Mathematical Optimization with AMPL in Python

4.4.2 Cryptocurrency exchanges

Here we use the ccxt library and list current exchanges supported by ccxt.

print("Available exchanges:\n")
for i, exchange in enumerate(ccxt.exchanges):
print(f"{i+1:3d}) {exchange.ljust(20)}", end="" if (i + 1) % 4 else "\n")

Available exchanges:

1) ace 2) alpaca 3) ascendex 4)␣


↪bequant
5) bigone 6) binance 7) binancecoinm 8)␣
↪binanceus

9) binanceusdm 10) bit2c 11) bitbank 12) bitbay


13) bitbns 14) bitcoincom 15) bitfinex 16)␣
↪bitfinex2

17) bitflyer 18) bitforex 19) bitget 20)␣


↪bithumb

21) bitmart 22) bitmex 23) bitopro 24)␣


↪bitpanda

25) bitrue 26) bitso 27) bitstamp 28)␣


↪bitstamp1

29) bittrex 30) bitvavo 31) bkex 32) bl3p


33) blockchaincom 34) btcalpha 35) btcbox 36)␣
↪btcmarkets

37) btctradeua 38) btcturk 39) bybit 40) cex


41) coinbase 42) coinbaseprime 43) coinbasepro 44)␣
↪coincheck

45) coinex 46) coinfalcon 47) coinmate 48)␣


↪coinone

49) coinsph 50) coinspot 51) cryptocom 52)␣


↪currencycom

53) delta 54) deribit 55) digifinex 56) exmo


57) fmfwio 58) gate 59) gateio 60) gemini
61) hitbtc 62) hitbtc3 63) hollaex 64) huobi
65) huobijp 66) huobipro 67) idex 68)␣
↪independentreserve

69) indodax 70) kraken 71) krakenfutures 72) kucoin


73) kucoinfutures 74) kuna 75) latoken 76) lbank
77) lbank2 78) luno 79) lykke 80)␣
↪mercado

81) mexc 82) mexc3 83) ndax 84)␣


↪novadax

85) oceanex 86) okcoin 87) okex 88) okex5


89) okx 90) paymium 91) phemex 92)␣
↪poloniex

93) poloniexfutures 94) probit 95) tidex 96) timex


97) tokocrypto 98) upbit 99) wavesexchange 100) wazirx
101) whitebit 102) woo 103) yobit 104) zaif
105) zonda

4.4. Cryptocurrency arbitrage search 201


Hands-On Mathematical Optimization with AMPL in Python

4.4.3 Representing an exchange as a directed graph

First, we need some terminology. Trading between two specific currencies is called a market, with each exchange hosting
multiple markets. ccxt labels each market with a symbol common across exchanges. The market symbol is an upper-
case string with abbreviations for a pair of traded currencies separated by a slash (/). The first abbreviation is the base
currency, the second is the quote currency. Prices for the base currency are denominated in units of the quote currency.
As an example, 𝐸𝑇 𝐻/𝐵𝑇 𝐶 refers to a market for the base currency Ethereum (ETH) quoted in units of the Bitcoin
(BTC). The same market symbol can refer to an offer to sell the base currency (a ‘bid’) or to an offer to sell the base
currency (an ‘ask’). For example, 𝑥 ETH/BTC means you can buy 𝑥 units of BTC with one unit of ETH.
An exchange can be represented by a directed graph constructed from the market symbols available on that exchange.
There, currencies correspond to nodes on the directed graph. Market symbols correspond to edges in the directed graph,
with the source indicating the quote currency and the destination indicating the base currency. The following code develops
such a sample graph.

# global variables used in subsequent cells

# create an exchange object


exchange = ccxt.binanceus()

def get_exchange_dg(exchange, minimum_in_degree=1):


"""
Return a directed graph constructed from the market symbols on a specified␣
↪exchange.

"""
markets = exchange.load_markets()
symbols = markets.keys()

# create an edge for all market symbols


dg = nx.DiGraph()
for base, quote in [symbol.split("/") for symbol in symbols]:
dg.add_edge(quote, base, color="k", width=1)

# remove base currencies with in_degree less than minimum_in_degree


remove_nodes = [
node
for node in dg.nodes
if dg.out_degree(node) == 0 and dg.in_degree(node) < minimum_in_degree
]
for node in reversed(list(nx.topological_sort(dg))):
if node in remove_nodes:
dg.remove_node(node)
else:
break

# color quote currencies in gold


for node in dg.nodes():
dg.nodes[node]["color"] = "gold" if dg.out_degree(node) > 0 else "lightblue"

return dg

def draw_dg(dg, rad=0.0):


"""
Draw directed graph of markets symbols.
"""
(continues on next page)

4.4. Cryptocurrency arbitrage search 202


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


n_nodes = len(dg.nodes)
size = int(2.5 * np.sqrt(n_nodes))
fig = plt.figure(figsize=(size, size))
pos = nx.circular_layout(dg)
nx.draw(
dg,
pos,
with_labels=True,
node_color=[dg.nodes[node]["color"] for node in dg.nodes()],
edge_color=[dg.edges[u, v]["color"] for u, v in dg.edges],
width=[dg.edges[u, v]["width"] for u, v in dg.edges],
node_size=1000,
font_size=8,
arrowsize=15,
connectionstyle=f"arc3, rad={rad}",
)
nx.draw_networkx_edge_labels(
dg, pos, edge_labels={(src, dst): f"{src}/{dst}" for src, dst in dg.edges()}
)

return plt.gca()

minimum_in_degree = 5
exchange_dg = get_exchange_dg(exchange, minimum_in_degree)
ax = draw_dg(exchange_dg, 0.01)
ax.set_title(
exchange.name + "\n" + f"Minimum in Degree (Base Currencies) = {minimum_in_degree}
↪"

print(f"Number of nodes = {len(exchange_dg.nodes()):3d}")


print(f"Number of edges = {len(exchange_dg.edges()):3d}")

Number of nodes = 170


Number of edges = 533

4.4. Cryptocurrency arbitrage search 203


Hands-On Mathematical Optimization with AMPL in Python

4.4.4 Exchange order book

The order book for a currency exchange is the real-time inventory of trading orders.
A bid is an offer to buy up to a specified amount of the base currency at the price not exceeding the ‘bid price’ in the quote
currency. An ask is an offer to sell up to a specified amount of the base currency at a price no less than a value specified
given in the quote currency.
The exchange attempts to match the bid to ask order at a price less than or equal to the bid price. If a transaction occurs,
the buyer will receive an amount of base currency less than or equal to the bid volume and the ask volume, at a price less
than or equal to the bid price and no less than the specified value.
The order book for currency exchange is the real-time inventory of orders. The exchange order book maintains a list of
all active orders for symbols traded on the exchange. Incoming bids above the lowest ask or incoming asks below the

4.4. Cryptocurrency arbitrage search 204


Hands-On Mathematical Optimization with AMPL in Python

highest bid will be immediately matched and transactions executed following the rules of the exchange.
The following cell reads and displays a previously saved order book. Cells at the end of this notebook demonstrate how
to retrieve an order book from an exchange and save it as a Pandas DataFrame.
if "google.colab" in sys.modules:
csv = "Binance_US_orderbook_20230313_152616.csv"
order_book = pd.read_csv(
"https://raw.githubusercontent.com/mobook/MO-book/main/notebooks/04/" + csv,
index_col=0,
)

else:
import glob

# find all previously saved order books


fnames = sorted(glob.glob(f"*orderbook*".replace(" ", "_")))
fname = fnames[-1]

# read the last order book from the list of order books
print(f"\nReading: {fname}\n")
order_book = pd.read_csv(fname, index_col=0)

display(order_book)

Reading: Binance_US_orderbook_saved.csv

symbol timestamp base quote bid_price \


0 ETH/BTC 2023-03-02 15:36:06.529 ETH BTC 0.069735
1 BNB/BTC 2023-03-02 15:36:06.583 BNB BTC 0.012743
2 ADA/BTC 2023-03-02 15:36:06.637 ADA BTC 0.000015
3 SOL/BTC 2023-03-02 15:36:06.690 SOL BTC 0.000935
4 MATIC/BTC 2023-03-02 15:36:06.750 MATIC BTC 0.000052
5 MANA/BTC 2023-03-02 15:36:06.848 MANA BTC 0.000027
6 TRX/BTC 2023-03-02 15:36:06.905 TRX BTC 0.000003
7 ADA/ETH 2023-03-02 15:36:06.960 ADA ETH 0.000214
8 BTC/USDT 2023-03-02 15:36:07.012 BTC USDT 23373.920000
9 ETH/USDT 2023-03-02 15:36:07.065 ETH USDT 1630.200000
10 BNB/USDT 2023-03-02 15:36:07.118 BNB USDT 297.857700
11 ADA/USDT 2023-03-02 15:36:07.172 ADA USDT 0.348630
12 BUSD/USDT 2023-03-02 15:36:07.226 BUSD USDT 0.999500
13 SOL/USDT 2023-03-02 15:36:07.288 SOL USDT 21.857000
14 USDC/USDT 2023-03-02 15:36:07.342 USDC USDT 1.000100
15 MATIC/USDT 2023-03-02 15:36:07.394 MATIC USDT 1.203000
16 MANA/USDT 2023-03-02 15:36:07.447 MANA USDT 0.631000
17 TRX/USDT 2023-03-02 15:36:07.501 TRX USDT 0.069280
18 BTC/BUSD 2023-03-02 15:36:07.612 BTC BUSD 23371.440000
19 BNB/BUSD 2023-03-02 15:36:07.665 BNB BUSD 297.763000
20 ETH/BUSD 2023-03-02 15:36:07.719 ETH BUSD 1630.210000
21 MATIC/BUSD 2023-03-02 15:36:07.772 MATIC BUSD 1.203410
22 USDC/BUSD 2023-03-02 15:36:07.893 USDC BUSD 0.999900
23 MANA/BUSD 2023-03-02 15:36:07.950 MANA BUSD 0.630700
24 ADA/BUSD 2023-03-02 15:36:08.003 ADA BUSD 0.348000
25 SOL/BUSD 2023-03-02 15:36:08.056 SOL BUSD 21.830000
26 TRX/BUSD 2023-03-02 15:36:08.114 TRX BUSD 0.069290
27 BTC/USDC 2023-03-02 15:36:08.170 BTC USDC 23371.660000
28 ETH/USDC 2023-03-02 15:36:08.228 ETH USDC 1630.160000
29 SOL/USDC 2023-03-02 15:36:08.298 SOL USDC 21.830000
(continues on next page)

4.4. Cryptocurrency arbitrage search 205


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


30 ADA/USDC 2023-03-02 15:36:08.368 ADA USDC 0.348200
31 BTC/DAI 2023-03-02 15:36:08.433 BTC DAI 23366.440000
32 ETH/DAI 2023-03-02 15:36:08.485 ETH DAI 1629.890000
33 BTC/USD 2023-03-02 15:36:08.623 BTC USD 23373.010000
34 ETH/USD 2023-03-02 15:36:08.675 ETH USD 1630.490000
35 USDT/USD 2023-03-02 15:36:08.730 USDT USD 1.000000
36 BNB/USD 2023-03-02 15:36:08.782 BNB USD 297.900200
37 ADA/USD 2023-03-02 15:36:08.835 ADA USD 0.348800
38 BUSD/USD 2023-03-02 15:36:08.942 BUSD USD 0.999500
39 MATIC/USD 2023-03-02 15:36:08.998 MATIC USD 1.204000
40 USDC/USD 2023-03-02 15:36:09.051 USDC USD 0.999900
41 MANA/USD 2023-03-02 15:36:09.102 MANA USD 0.631000
42 DAI/USD 2023-03-02 15:36:09.155 DAI USD 0.999100
43 SOL/USD 2023-03-02 15:36:09.217 SOL USD 21.858100
44 TRX/USD 2023-03-02 15:36:09.270 TRX USD 0.069200

bid_volume ask_price ask_volume


0 0.012000 0.069759 0.050000
1 0.050000 0.012755 3.000000
2 2.000000 0.000015 2168.000000
3 1.420000 0.000936 15.120000
4 26.200000 0.000052 150.200000
5 831.000000 0.000027 1409.000000
6 4.000000 0.000003 25352.000000
7 994.900000 0.000214 891.600000
8 0.118619 23376.000000 0.045275
9 0.950000 1630.770000 0.500000
10 0.800000 297.891900 0.800000
11 1100.000000 0.348750 511.200000
12 293433.930000 0.999600 317175.730000
13 22.870000 21.863500 23.000000
14 307657.000000 1.000200 299181.000000
15 1664.600000 1.205000 5405.600000
16 157.000000 0.632200 571.000000
17 10824.900000 0.069330 10818.600000
18 0.021500 23376.830000 0.021500
19 1.670000 297.952500 1.340000
20 0.510000 1630.760000 0.255000
21 623.100000 1.204540 415.000000
22 329027.800000 1.000000 279879.620000
23 343.000000 0.632100 3054.000000
24 6582.900000 0.349000 997.000000
25 1.390000 21.900000 181.090000
26 10823.900000 0.069390 25220.400000
27 0.020000 23376.990000 0.051000
28 0.100000 1630.810000 0.215000
29 343.520000 21.880000 201.080000
30 8615.400000 0.349400 2433.100000
31 0.049540 23394.360000 0.049500
32 0.497400 1631.810000 1.490000
33 0.007463 23376.720000 0.048805
34 2.564550 1630.740000 0.580700
35 10407.870000 1.000100 954839.680000
36 2.100000 297.952500 1.342000
37 1500.000000 0.348900 3000.000000
38 79157.800000 0.999900 795593.480000
39 937.600000 1.204500 937.500000
(continues on next page)

4.4. Cryptocurrency arbitrage search 206


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


40 5050.300000 1.000000 517682.170000
41 372.340000 0.631600 572.340000
42 4534.660000 1.000100 7591.820000
43 55.730000 21.865900 18.290000
44 225602.200000 0.069400 224245.500000

4.4.5 Modelling the arbitrage search problem as a graph

Our goal will be to find arbitrage opportunities, i.e., the possibility to start from a given currency and, through a sequence
of executed trades, arrive back at the same currency with a higher balance than at the beginning. We will model this
problem as a network one.
A bid appearing in the order book for market symbol 𝑏/𝑞 is an order from a prospective counter party to purchase an
amount of the base currency 𝑏 at a bid price given in a quote currency 𝑞. For a currency trader, a bid in the order book is
an opportunity to convert the base currency 𝑏 into the quote currency 𝑞.
The order book can be represented as a directed graph where nodes correspond to individual currencies. A directed edge
𝑏 → 𝑞 from node 𝑏 to node 𝑞 describes an opportunity for us to convert currency 𝑏 into units of currency 𝑞. Let 𝑉𝑏 and 𝑉𝑞
denote the amounts of each currency held by us, and let 𝑥𝑏→𝑞 denote the amount of currency 𝑏 exchanged for currency 𝑗.
Following the transaction 𝑥𝑏→𝑞 we have the following changes to the currency holdings

Δ𝑉𝑏 = −𝑥𝑏→𝑞
Δ𝑉𝑞 = 𝑎𝑏→𝑞 𝑥𝑏→𝑞 ,

where 𝑎𝑏→𝑞 is a conversion coefficient equal to the price of 𝑏 expressed in terms of currency 𝑞. The capacity 𝑐𝑏→𝑞 of an
trading along edge 𝑏 → 𝑞 is specified by a relationship

𝑥𝑏→𝑞 ≤ 𝑐𝑏→𝑞 .

Because the arcs in our graph correspond to two types of orders - bid and ask - we need to build a consistent way of
expressing them in our 𝑎𝑏→𝑞 , 𝑐𝑏→𝑞 notation. So now, imagine that we are the party that accepts the buy and ask bids
existing in the graph.
For bid orders, we have a chance to convert the base currency 𝑏 into the quote currency 𝑞, for which we will use the
following notation:

𝑎𝑏→𝑞 = bid price


𝑐𝑏→𝑞 = bid volume

An ask order for symbol 𝑏/𝑞 is an order to sell the base currency at price not less than the ‘ask’ price given in terms of
the quote currency. The ask volume is the amount of base currency to be sold. For us, a sell order is an opportunity to
convert the quoted currency into the base currency such that
1
𝑎𝑞→𝑏 =
ask price
𝑐𝑞→𝑏 = ask volume × ask volume

The following cell creates a directed graph using data from an exchange order book. To distinguish between different
order types, we will highlight the big orders with green color, and ask orders with red color.

def order_book_to_dg(order_book):
"""
Convert an order book dataframe into a directed graph using the NetworkX library.
(continues on next page)

4.4. Cryptocurrency arbitrage search 207


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

Parameters:
-----------
order_book : pandas.DataFrame
A dataframe containing the order book information.

Returns:
--------
dg_order_book : networkx.DiGraph
A directed graph representing the order book.
"""

# create a dictionary of edges index by (src, dst)


dg_order_book = nx.DiGraph()

# loop over each order in the order book dataframe


for order in order_book.index:
# if the order is a 'bid', i.e., an order to purchase the base currency
if not np.isnan(order_book.at[order, "bid_volume"]):
src = order_book.at[order, "base"]
dst = order_book.at[order, "quote"]
# add an edge to the graph with the relevant attributes
dg_order_book.add_edge(
src,
dst,
kind="bid",
a=order_book.at[order, "bid_price"],
capacity=order_book.at[order, "bid_volume"],
weight=-np.log(order_book.at[order, "bid_price"]),
color="g",
width=0.5,
)

# if the order is an 'ask', i.e., an order to sell the base currency


if not np.isnan(order_book.at[order, "ask_volume"]):
src = order_book.at[order, "quote"]
dst = order_book.at[order, "base"]
# add an edge to the graph with the relevant attributes
dg_order_book.add_edge(
src,
dst,
kind="ask",
a=1.0 / order_book.at[order, "ask_price"],
capacity=order_book.at[order, "ask_volume"]
* order_book.at[order, "ask_price"],
weight=-np.log(1.0 / order_book.at[order, "ask_price"]),
color="r",
width=0.5,
)

# loop over each node in the graph and set the color attribute to "lightblue"
for node in dg_order_book.nodes():
dg_order_book.nodes[node]["color"] = "lightblue"

return dg_order_book

(continues on next page)

4.4. Cryptocurrency arbitrage search 208


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


order_book_dg = order_book_to_dg(order_book)

First, we simply print the content of the order book as a list of arcs.

# display contents of the directed graph


print(f"src --> dst kind a c")
print(f"------------------------------------------------------")
for src, dst in order_book_dg.edges():
print(
f"{src:5s} --> {dst:5s} {order_book_dg.edges[(src, dst)]['kind']}"
+ f"{order_book_dg.edges[(src, dst)]['a']: 16f} {order_book_dg.edges[(src,␣
↪dst)]['capacity']: 16f} "
)

src --> dst kind a c


------------------------------------------------------
ETH --> BTC bid 0.069735 0.012000
ETH --> ADA ask 4668.534080 0.190981
ETH --> USDT bid 1630.200000 0.950000
ETH --> BUSD bid 1630.210000 0.510000
ETH --> USDC bid 1630.160000 0.100000
ETH --> DAI bid 1629.890000 0.497400
ETH --> USD bid 1630.490000 2.564550
BTC --> ETH ask 14.335068 0.003488
BTC --> BNB ask 78.403701 0.038263
BTC --> ADA ask 66844.919786 0.032433
BTC --> SOL ask 1068.261938 0.014154
BTC --> MATIC ask 19391.118868 0.007746
BTC --> MANA ask 36968.576710 0.038113
BTC --> TRX ask 335570.469799 0.075549
BTC --> USDT bid 23373.920000 0.118619
BTC --> BUSD bid 23371.440000 0.021500
BTC --> USDC bid 23371.660000 0.020000
BTC --> DAI bid 23366.440000 0.049540
BTC --> USD bid 23373.010000 0.007463
BNB --> BTC bid 0.012743 0.050000
BNB --> USDT bid 297.857700 0.800000
BNB --> BUSD bid 297.763000 1.670000
BNB --> USD bid 297.900200 2.100000
ADA --> BTC bid 0.000015 2.000000
ADA --> ETH bid 0.000214 994.900000
ADA --> USDT bid 0.348630 1100.000000
ADA --> BUSD bid 0.348000 6582.900000
ADA --> USDC bid 0.348200 8615.400000
ADA --> USD bid 0.348800 1500.000000
SOL --> BTC bid 0.000935 1.420000
SOL --> USDT bid 21.857000 22.870000
SOL --> BUSD bid 21.830000 1.390000
SOL --> USDC bid 21.830000 343.520000
SOL --> USD bid 21.858100 55.730000
MATIC --> BTC bid 0.000052 26.200000
MATIC --> USDT bid 1.203000 1664.600000
MATIC --> BUSD bid 1.203410 623.100000
MATIC --> USD bid 1.204000 937.600000
MANA --> BTC bid 0.000027 831.000000
MANA --> USDT bid 0.631000 157.000000
(continues on next page)

4.4. Cryptocurrency arbitrage search 209


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


MANA --> BUSD bid 0.630700 343.000000
MANA --> USD bid 0.631000 372.340000
TRX --> BTC bid 0.000003 4.000000
TRX --> USDT bid 0.069280 10824.900000
TRX --> BUSD bid 0.069290 10823.900000
TRX --> USD bid 0.069200 225602.200000
USDT --> BTC ask 0.000043 1058.348400
USDT --> ETH ask 0.000613 815.385000
USDT --> BNB ask 0.003357 238.313520
USDT --> ADA ask 2.867384 178.281000
USDT --> BUSD ask 1.000400 317048.859708
USDT --> SOL ask 0.045738 502.860500
USDT --> USDC ask 0.999800 299240.836200
USDT --> MATIC ask 0.829876 6513.748000
USDT --> MANA ask 1.581778 360.986200
USDT --> TRX ask 14.423770 750.053538
USDT --> USD bid 1.000000 10407.870000
BUSD --> USDT bid 0.999500 293433.930000
BUSD --> BTC ask 0.000043 502.601845
BUSD --> BNB ask 0.003356 399.256350
BUSD --> ETH ask 0.000613 415.843800
BUSD --> MATIC ask 0.830192 499.884100
BUSD --> USDC ask 1.000000 279879.620000
BUSD --> MANA ask 1.582028 1930.433400
BUSD --> ADA ask 2.865330 347.953000
BUSD --> SOL ask 0.045662 3965.871000
BUSD --> TRX ask 14.411298 1750.043556
BUSD --> USD bid 0.999500 79157.800000
USDC --> USDT bid 1.000100 307657.000000
USDC --> BUSD bid 0.999900 329027.800000
USDC --> BTC ask 0.000043 1192.226490
USDC --> ETH ask 0.000613 350.624150
USDC --> SOL ask 0.045704 4399.630400
USDC --> ADA ask 2.862049 850.125140
USDC --> USD bid 0.999900 5050.300000
DAI --> BTC ask 0.000043 1158.020820
DAI --> ETH ask 0.000613 2431.396900
DAI --> USD bid 0.999100 4534.660000
USD --> BTC ask 0.000043 1140.900820
USD --> ETH ask 0.000613 946.970718
USD --> USDT ask 0.999900 954935.163968
USD --> BNB ask 0.003356 399.852255
USD --> ADA ask 2.866151 1046.700000
USD --> BUSD ask 1.000100 795513.920652
USD --> MATIC ask 0.830220 1129.218750
USD --> USDC ask 1.000000 517682.170000
USD --> MANA ask 1.583281 361.489944
USD --> DAI ask 0.999900 7592.579182
USD --> SOL ask 0.045733 399.927311
USD --> TRX ask 14.409222 15562.637700

Next, we draw the graph itself.

draw_dg(order_book_dg, 0.05)
plt.show()

4.4. Cryptocurrency arbitrage search 210


Hands-On Mathematical Optimization with AMPL in Python

4.4.6 Trading and Arbitrage

With the unified treatment of the bid and ask orders, we are ready to pose the mathematical problem of finding an arbitrage
opportunity. An arbitrage exists if it is possible to find a closed path and a sequence of transactions in the directed graph
resulting in a net increase in currency holdings. Given a path
𝑖0 → 𝑖1 → 𝑖2 → ⋯ → 𝑖𝑛−1 → 𝑖𝑛
the path is closed if 𝑖𝑛 = 𝑖0 . The path has finite capacity if each edge in the path has a non-zero capacity. For a sufficiently
small holding 𝑤𝑖0 of currency 𝑖0 (because of the capacity constraints), a closed path with 𝑖0 = 𝑖𝑛 represents an arbitrage
opportunity if
𝑛−1
∏ 𝑎𝑖𝑘 →𝑖𝑘+1 > 1.(4.1)
𝑘=0

4.4. Cryptocurrency arbitrage search 211


Hands-On Mathematical Optimization with AMPL in Python

If all we care about is simply finding an arbitrage cycle, regardless of the volume traded, we can use one of the many
shortest path algorithms from the networkx library. To convert the problem of finding a path meeting the above
condition into a sum-of-terms to be minimized, we can take the negative logarithm of both sides to obtain the condition:
𝑛−1 𝑛−1
− log( ∏ 𝑎𝑖𝑘 →𝑖𝑘+1 ) = − ∑ log(𝑎𝑖𝑘 →𝑖𝑘+1 ) < 0,
𝑘=0 𝑘=0

In other words, if we assign the negative logarithm as the weight of arcs in a graph, then our problem just became translated
into the problem of searching for a cycle with a total sum of weights along it to be negative.

4.4.7 Find order books that demonstrate arbitrage opportunities

A simple cycle is a closed path where no node appears twice. Simple cycles are distinct if they are not cyclic permutations
(essentially, rewriting the same path but with a different start=end point) of each other. One could check for arbitrage
opportunities by checking if there are any negative simple cycles in the graph.
However, looking for a negative-weight cycle through searching for an arbitrage opportunity can be a daunting task -
a brute-force search over all simple cycles has complexity (𝑛 + 𝑒)(𝑐 + 1) which is impractical for large-scale applica-
tions. A more efficient search based on the Bellman-Ford algorithm is embedded in the NetworkX function nega-
tive_edge_cycle that returns a logical True if a negative cycle exists in a directed graph.

order_book_dg = order_book_to_dg(order_book)
nx.negative_edge_cycle(order_book_dg, weight="weight", heuristic=True)

True

The function negative_edge_cycle is fast, but it only indicates if there is a negative cycle or not, and we don’t
even know what kind of a cycle is it so it would be hard to use that information to perform an arbitrage.
Luckily, the networkx library includes the function find_negative_cycle that locates a single negative edge
cycle if one exists. We can use this to demonstrate the existence of an arbitrage opportunity and to highlight that op-
portunity on the directed graph of all possible trades. The following cell reports the cycle found and the trading return
measured in basis points (1 bp = 0.01%), and marks it with thicker arcs in the graph.

# compute the sum of weights given a list of nodes


def sum_weights(cycle):
return sum(
[
order_book_dg.edges[edge]["weight"]
for edge in zip(cycle, cycle[1:] + cycle[:1])
]
)

order_book_dg = order_book_to_dg(order_book)
arb = nx.find_negative_cycle(order_book_dg, weight="weight", source="USD")[:-1]
bp = 10000 * (np.exp(-sum_weights(arb)) - 1)

for src, dst in zip(arb, arb[1:] + arb[:1]):


order_book_dg[src][dst]["width"] = 5

(continues on next page)

4.4. Cryptocurrency arbitrage search 212


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


ax = draw_dg(order_book_dg, 0.05)
ax.set_title(
f"Trading cycle with {len(list(arb))} trades: {' -> '.join(list(arb))}\n\n Return␣
↪= {bp:6.3f} basis points "

)
plt.show()

Note this may or may not be the trading cycle with maximum return. There may be other cycles with higher or lower
returns, and that allow higher or lower trading volumes.

4.4. Cryptocurrency arbitrage search 213


Hands-On Mathematical Optimization with AMPL in Python

4.4.8 Brute force search arbitrage with simple cycles

Not all arbitrage cycles are the same - some yield a higher relative return (per dollar invested) than the others, and some
yield a higher absolute return (maximum amount of money to be made risk-free) than others. This is because the amount
of money that flows through a negative cycle is upper bounded by the size of the smallest order in that cycle. Thus, if one
is looking for the best possible arbitrage sequence of trades, finding just ‘a cycle’ might not be enough.
A crude way to search for a good arbitrage opportunity would be to enumerate all possible simple cycles in a graph and
pick the one that’s best according to whatever criterion we pick. A brute force search over for all simple cycles has order
(𝑁𝑛𝑜𝑑𝑒𝑠 + 𝑁𝑒𝑑𝑔𝑒𝑠 )(𝑁𝑐𝑦𝑐𝑙𝑒𝑠 + 1) complexity, which is prohibitive for large order books. Nevertheless, we explore this
option here to better understand the problem of finding and valuing arbitrage opportunities.
In the following cell, we compute the loss function for all simple cycles that can be constructed within a directed graph
using the function simple_cycles from the networkx library to construct a dictionary of all distinct simple cycles
in the order book. Each cycle is represented by an ordered list of nodes. For each cycle, the financial return is computed,
and a histogram is constructed to show the distribution of potential returns. Several paths with the highest return are then
overlaid on the graph of the order book.
Again, note that no account is taken of the trading capacity available on each path.

# This cell iterates over all simple cycles in a directed graph. This
# can a long time for a large, well connected graph.

# convert order book to a directed graph


order_book_dg = order_book_to_dg(order_book)

# compute the sum of weights given a list of nodes


def sum_weights(cycle):
return sum(
[
order_book_dg.edges[edge]["weight"]
for edge in zip(cycle, cycle[1:] + cycle[:1])
]
)

# create a dictionary of all simple cycles and sum of weights


cycles = {
tuple(cycle): 10000 * (np.exp(-sum_weights(cycle)) - 1)
for cycle in nx.simple_cycles(order_book_dg)
}

print(
f"There are {len(cycles)} distinct simple cycles in the order book, {len([cycle␣
↪for cycle in cycles if cycles[cycle] > 0])} of which have positive return."

# create histogram
plt.hist(cycles.values(), bins=int(np.sqrt(len(cycles))))
ax = plt.gca()
ax.set_ylabel("count")
ax.set_xlabel("Basis Points")
ax.set_title("Histogram of Returns for all Simple Cycles")
ax.grid(True)
ax.axvline(0, color="r")
plt.show()

4.4. Cryptocurrency arbitrage search 214


Hands-On Mathematical Optimization with AMPL in Python

There are 203147 distinct simple cycles in the order book, 974 of which have positive␣
↪return.

Next, we sort out the negative cycles from this list and present them along with their basis-points (1% is 100 basis points)
return.

arbitrage = [
cycle for cycle in sorted(cycles, key=cycles.get, reverse=True) if cycles[cycle] >
↪ 0

n_cycles_to_list = 5

print(f"Top {n_cycles_to_list}\n")
print(f"Basis Points Arbitrage Cycle")
for cycle in arbitrage[0 : min(n_cycles_to_list, len(arbitrage))]:
t = list(cycle)
t.append(cycle[0])
print(f"{cycles[cycle]:6.3f} {len(t)} trades: {' -> '.join(t)}")

Top 5

Basis Points Arbitrage Cycle


14.774 8 trades: ETH -> USD -> BUSD -> USDC -> USDT -> ADA -> BTC -> ETH
14.747 8 trades: ETH -> USD -> BUSD -> USDC -> USDT -> TRX -> BTC -> ETH
14.699 7 trades: ADA -> BTC -> USD -> BUSD -> USDC -> USDT -> ADA
(continues on next page)

4.4. Cryptocurrency arbitrage search 215


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


14.673 7 trades: USDC -> USDT -> TRX -> BTC -> USD -> BUSD -> USDC
13.772 7 trades: ETH -> USD -> USDC -> USDT -> ADA -> BTC -> ETH

In the end, we draw an example arbitrage cycle on our graph to illustrate the route that the money must travel.

n_cycles_to_show = 1

for cycle in arbitrage[0 : min(n_cycles_to_show, len(arbitrage))]:


# get fresh graph to color nodes
order_book_dg = order_book_to_dg(order_book)

# color nodes red


for node in cycle:
order_book_dg.nodes[node]["color"] = "red"

# makes lines wide


for edge in zip(cycle, cycle[1:] + cycle[:1]):
order_book_dg.edges[edge]["width"] = 4

ax = draw_dg(order_book_dg, rad=0.05)

t = list(cycle)
t.append(cycle[0])
ax.set_title(
f"Trading cycle with {len(t)} trades: {' -> '.join(t)}\n\n Return =
↪{cycles[cycle]:6.3f} basis points "

)
plt.savefig("crypto3.pdf", bbox_inches="tight")
plt.show()

4.4. Cryptocurrency arbitrage search 216


Hands-On Mathematical Optimization with AMPL in Python

4.4.9 AMPL Model for Arbitrage with Capacity Constraints

The preceding analysis demonstrates some of the practical limitations of relying on generic implementations of network
algorithms:
• First of all, more than one negative cycle may exist, so more than one arbitrage opportunity may exist, i.e. an
optimal strategy consists of a combination of cycles.
• Secondly, simply searching for a negative cycle using shortest path algorithms does not account for capacity con-
straints, i.e., the maximum size of each of the exchanges. For that reason, one may end up with a cycle on which
a good `rate’ of arbitrage is available, but where the absolute gain need not be large due to the maximum amounts

4.4. Cryptocurrency arbitrage search 217


Hands-On Mathematical Optimization with AMPL in Python

that can be traded.


Instead, we can formulate the problem of searching for a maximum-gain arbitrage via linear optimization. We assume
we are given a directed graph where each edge 𝑖 → 𝑗 is labeled with a ‘multiplier’ 𝑎𝑖→𝑗 indicating how many units of
currency 𝑗 will be received for one unit of currency 𝑖, and a ‘capacity’ 𝑐𝑖→𝑗 indicating how many units of currency 𝑖 can
be converted to currency 𝑗.
We will break the trading process down into steps indexed by 𝑡 = 1, 2, … , 𝑇 , where currencies are exchanged between
two adjacent nodes within a single step. We shall denote by 𝑥𝑖→𝑗 (𝑡) the currency amount traded from node 𝑖 to 𝑗 in step 𝑡.
In this way, we start with the amount 𝑤𝑈𝑆𝐷 (0) at time 0 and aim to maximize the amount 𝑤𝑈𝑆𝐷 (𝑇 ) at time 𝑇 . Denote
by 𝑂𝑗 the set of nodes to which outgoing arcs from 𝑗 lead, and by 𝐼𝑗 the set of nodes from which incoming arcs lead.
A single transaction converts 𝑥𝑖→𝑗 (𝑡) units of currency 𝑖 to currency 𝑗. Following the all transactions at event 𝑡, the trader
will hold 𝑣𝑗 (𝑡) units of currency 𝑗 where

𝑣𝑗 (𝑡) = 𝑣𝑗 (𝑡 − 1) + ∑ 𝑎𝑖→𝑗 𝑥𝑖→𝑗 (𝑡) − ∑ 𝑥𝑗→𝑘 (𝑡)


𝑖∈𝐼𝑗 𝑘∈𝑂𝑗

For every edge 𝑖 → 𝑗, the sum of all transactions must satisfy


𝑇
∑ 𝑥𝑗→𝑘 (𝑡) ≤ 𝑐𝑗→𝑘
𝑡=1

The objective of the optimization model is to find a sequence of currency transactions that increase the holdings of a
reference currency. The solution is constrained by assuming the trader cannot short sell any currency. The resulting
model is
max 𝑣𝑈𝑆𝐷 (𝑇 )

s.t. 𝑣𝑈𝑆𝐷 (0) = 𝑣0

𝑣𝑗 (𝑡) = 𝑣𝑗 (𝑡 − 1) + ∑ 𝑎𝑖→𝑗 𝑥𝑖→𝑗 (𝑡) − ∑ 𝑥𝑗→𝑘 (𝑡) ∀𝑗 ∈ 𝑁 , 𝑡 = 1, 2, … , 𝑇


𝑖∈𝐼𝑗 𝑘∈𝑂𝑗

𝑣𝑗 (𝑡 − 1) ≥ ∑ 𝑥𝑗→𝑘 (𝑡) ∀𝑗 ∈ 𝑁 , 𝑡 = 1, 2, … , 𝑇
𝑘∈𝑂𝑗
𝑇
∑ 𝑥𝑗→𝑘 (𝑡) ≤ 𝑐𝑗→𝑘 ∀(𝑗, 𝑘) ∈ 𝐸, 𝑡 = 1, 2, … , 𝑇
𝑡=1
𝑣𝑗 (𝑡), 𝑥𝑖→𝑗 (𝑡) ≥ 0 ∀𝑡

where the subsequent constraints are the:


• initial amount condition,
• balance equations linking the state of the given node in the previous and subsequent time periods,
• constraint that we cannot trade at time step 𝑡 more of a given currency than we had in this currency from time step
𝑡 − 1. This constraint ‘enforces’ the time order of trades, i.e., we cannot trade in time period 𝑡 units which have
been received in the same time period.
• the capacity constraints related to the maximum allowed trade volumes,
• non-negativity constraints.
The following Python code illustrates this formulation.

4.4. Cryptocurrency arbitrage search 218


Hands-On Mathematical Optimization with AMPL in Python

%%writefile crypto_model.mod

param T;

# length of the trading chain


set T0 := 0..T;
set T1 := 1..T;

# currency nodes and trading edges


set NODES;
set EDGES within NODES cross NODES;

# currency on hand at each node


var v{NODES, T0} >= 0;

# amount traded on each edge at each trade


var x{EDGES, T1} >= 0;

# total amount traded on each edge over all trades


var z{EDGES} >= 0;

# "multiplier" on each trading edge


param a{EDGES};

param c{EDGES};

param v0;

maximize wealth: v["USD", T];

s.t. total_traded {(src, dst) in EDGES}:


z[src, dst] == sum{t in T1} x[src, dst, t];

s.t. edge_capacity {(src, dst) in EDGES}:


z[src, dst] <= c[src, dst];

# initial assignment of V0 units on a selected currency


s.t. initial {node in NODES}:
v[node, 0] == (if node == "USD" then v0 else 0);

s.t. no_shorting {node in NODES, t in T1}:


v[node, t - 1] >= sum{(src, dst) in EDGES: src == node} x[node, dst, t];

s.t. balances {node in NODES, t in T1}:


v[node, t] == v[node, t - 1] +
sum{(src, dst) in EDGES: dst == node} a[src, node] * x[src, node, t] -
sum{(src, dst) in EDGES: src == node} x[node, dst, t]
;

Overwriting crypto_model.mod

def crypto_model(order_book_dg, T=10, v0=100.0):


m = AMPL()
m.read("crypto_model.mod")

m.set["NODES"] = order_book_dg.nodes
m.set["EDGES"] = order_book_dg.edges
(continues on next page)

4.4. Cryptocurrency arbitrage search 219


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

m.param["T"] = T
m.param["a"] = [
order_book_dg.edges[(src, dst)]["a"] for (src, dst) in order_book_dg.edges
]
m.param["c"] = [
order_book_dg.edges[(src, dst)]["capacity"]
for (src, dst) in order_book_dg.edges
]

m.param["v0"] = v0

m.option["solver"] = SOLVER
m.solve()

return m

Using this function, we are able to compute the optimal (absolute) return from an order book while respecting the order
capacities and optimally using all the arbitrage opportunities inside it.

order_book_dg = order_book_to_dg(order_book)

v0 = 10000.0
T = 8
m = crypto_model(order_book_dg, T=T, v0=v0)
vT = m.obj["wealth"].value()

print(f"Starting wealth = {v0:0.2f} USD")


print(f"Weath after {T:2d} transactions = {vT:0.2f} USD")
print(f"Return = {10000 * (vT - v0)/v0:0.3f} basis points")

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 10009.00655


0 simplex iterations

"option abs_boundtol 3.1086244689504383e-15;"


or "option rel_boundtol 7.771561172376096e-16;"
will change deduced dual values.

Starting wealth = 10000.00 USD


Weath after 8 transactions = 10009.01 USD
Return = 9.007 basis points

To track the evolution of the trades throughout time, the script in the following cell illustrates, for each currency (rows)
the amount of money held in that currency at each of the time steps 𝑡 = 0, … , 8. It is visible from this scheme that the
sequence of trades is not a simple cycle, but rather a more sophisticated sequence of trades which we would not have
discovered with simple-cycle exploration alone, especially not when considering also the arc capacities.

NODES = m.set["NODES"].to_list()
T0 = m.set["T0"].to_list()
v = m.var["v"].to_dict()

for node in NODES:


print(f"{node:5s}", end="")
for t in T0:
print(f" {v[node, t]:11.5f}", end="")
print()

4.4. Cryptocurrency arbitrage search 220


Hands-On Mathematical Optimization with AMPL in Python

ETH 0.00000 0.00000 0.00000 0.00000 0.00000 -0.00000 0.


↪00000 0.00000 0.00000
BTC 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 0.
↪00000 0.00004 0.00000
BNB 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 0.
↪00000 0.00000 0.00000
ADA 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 2.
↪00000 0.00000 0.00000
SOL 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 0.
↪00000 0.00000 0.00000
MATIC 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 0.
↪00000 -0.00000 -0.00000
MANA 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 0.
↪00000 0.00000 0.00000
TRX 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 4.
↪00000 0.00000 0.00000
USDT 0.00000 9998.02596 0.97433 0.00000 10003.02698 0.97482 0.
↪00000 10008.03049 0.00000
BUSD 0.00000 0.00000 10002.02677 0.97472 0.00000 10007.02979 0.
↪00000 0.00000 0.00000
USDC 0.00000 0.97424 0.00000 10002.02677 0.97472 0.00000 10007.
↪02979 0.00000 0.00000
DAI 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 -0.
↪00000 -0.00000 0.00000
USD 10000.00000 0.00000 0.00000 0.00000 0.00000 0.00000 0.
↪00000 0.00000 10009.00655

To be even more specific, the following cell lists the sequence of transcations executed.

T1 = m.set["T1"].to_list()
EDGES = m.set["EDGES"].to_list()
x = m.var["x"].to_dict()
a = m.param["a"].to_dict()

print("\nTransaction Events")
for t in T1:
print(f"t = {t}")
for src, dst in EDGES:
if x[src, dst, t] > 1e-6:
print(
f" {src:8s} -> {dst:8s}: {x[src, dst, t]:14.6f} {a[src, dst] * x[src,␣
↪dst, t]:14.6f}"

)
print()

Transaction Events
t = 1
USD -> USDT : 9999.025765 9998.025962
USD -> USDC : 0.974235 0.974235

t = 2
USDT -> BUSD : 9998.025962 10002.026773
USDC -> USDT : 0.974235 0.974333

t = 3
USDT -> BUSD : 0.974333 0.974723
BUSD -> USDC : 10002.026773 10002.026773
(continues on next page)

4.4. Cryptocurrency arbitrage search 221


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

t = 4
BUSD -> USDC : 0.974723 0.974723
USDC -> USDT : 10002.026773 10003.026976

t = 5
USDT -> BUSD : 10003.026976 10007.029787
USDC -> USDT : 0.974723 0.974820

t = 6
USDT -> ADA : 0.697500 2.000000
USDT -> TRX : 0.277320 4.000000
BUSD -> USDC : 10007.029787 10007.029787

t = 7
ADA -> BTC : 2.000000 0.000030
TRX -> BTC : 4.000000 0.000012
USDC -> USDT : 10007.029787 10008.030490

t = 8
BTC -> USD : 0.000042 0.976057
USDT -> USD : 10008.030490 10008.030490

We next illustrate the arbitrage strategy in the graph by marking all the corresponding arcs thicker.

# add comment in the text to remind the reader about bids and asks
# for each currency we took only one ask and one bid, this is why we are unique␣
↪between each pair of nodes

z = m.var["z"].to_dict()

# report what orders to issue


for src, dst in EDGES:
if z[src, dst] > 0.0000002:
order_book_dg.nodes[src]["color"] = "red"
order_book_dg.nodes[dst]["color"] = "red"
order_book_dg[src][dst]["width"] = 4

draw_dg(order_book_dg, 0.05)

<Axes: >

4.4. Cryptocurrency arbitrage search 222


Hands-On Mathematical Optimization with AMPL in Python

If we want to be even more precise about the execution of the trading strategy, we can formulate a printout of the list of
orders that we, as the counter party to the orders stated in the order book, should issue for our strategy to take place.

# report what orders to issue


c = m.param["c"].to_dict()

print("Trading Summary for the Order Book")


print(f" Order Book Type Capacity Traded")
for src, dst in EDGES:
if z[src, dst] > 0.0000002:
kind = order_book_dg.edges[(src, dst)]["kind"]
s = f"{src:>5s} -> {dst:<5s} {kind} {c[src, dst]:12.5f} {z[src, dst]:14.5f}"
s += " >>> "
if kind == "ask":
(continues on next page)

4.4. Cryptocurrency arbitrage search 223


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


base = dst
quote = src
symbol = base + "/" + quote
price = 1.0 / order_book_dg.edges[(src, dst)]["a"]
volume = z[src, dst] / price
s += f"sell {volume:15.6f} {symbol:11s} at {price:12.6f}"

if kind == "bid":
base = src
quote = dst
symbol = base + "/" + quote
price = order_book_dg.edges[(src, dst)]["a"]
volume = z[src, dst]
s += f"buy {volume:16.6f} {symbol:11s} at {price:12.6f}"
print(s)

Trading Summary for the Order Book


Order Book Type Capacity Traded
BTC -> USD bid 0.00746 0.00004 >>> buy 0.000042 BTC/USD ␣
↪at 23373.010000

ADA -> BTC bid 2.00000 2.00000 >>> buy 2.000000 ADA/BTC ␣
↪at 0.000015
TRX -> BTC bid 4.00000 4.00000 >>> buy 4.000000 TRX/BTC ␣
↪at 0.000003
USDT -> ADA ask 178.28100 0.69750 >>> sell 2.000000 ADA/USDT ␣
↪at 0.348750
USDT -> BUSD ask 317048.85971 20002.02727 >>> sell 20010.031283 BUSD/USDT ␣
↪at 0.999600
USDT -> TRX ask 750.05354 0.27732 >>> sell 4.000000 TRX/USDT ␣
↪at 0.069330
USDT -> USD bid 10407.87000 10008.03049 >>> buy 10008.030490 USDT/USD ␣
↪at 1.000000
BUSD -> USDC ask 279879.62000 20010.03128 >>> sell 20010.031283 USDC/BUSD ␣
↪at 1.000000
USDC -> USDT bid 307657.00000 20011.00552 >>> buy 20011.005518 USDC/USDT ␣
↪at 1.000100
USD -> USDT ask 954935.16397 9999.02576 >>> sell 9998.025962 USDT/USD ␣
↪at 1.000100
USD -> USDC ask 517682.17000 0.97424 >>> sell 0.974235 USDC/USD ␣
↪at 1.000000

In the end, we can illustrate the time-journey of our balances in different currencies using time-indexed bar charts.

# display currency balances


balances = pd.DataFrame()
for node in order_book_dg.nodes:
if sum(v[node, t] for t in T0) > 0.0000002:
for t in T0:
balances.loc[t, node] = v[node, t]

balances.plot(
kind="bar",
subplots=True,
figsize=(8, 10),
xlabel="Transaction",
ylabel="Currency Units",
(continues on next page)

4.4. Cryptocurrency arbitrage search 224


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


)
plt.gcf().tight_layout()
plt.show()

4.4. Cryptocurrency arbitrage search 225


Hands-On Mathematical Optimization with AMPL in Python

4.4.10 Questions to the user

The previous notebook cells made certain assumptions that we need to consider. The first assumption was that there was
at most one bid and one ask order between any pair of currencies in an exchange. This assumption was based on the
number of orders we downloaded from the online database, but in reality, there may be more orders. How would the
presence of multiple orders per pair affect our graph formulation? How can we adjust the MILO formulation to account
for this?
Another assumption was that we only traded currencies within one exchange. However, in reality, we can trade across
multiple exchanges. How can we modify the graph-based problem formulation to accommodate this scenario?
Further, we have assigned no cost to the number of trades required to implement the strategy produced by optimization.
How can the modeled be modified to either minimize the number of trades, or to explicitly include trading costs?
Finally, a tool like this needs to operate in real time. How can this model be incorporated into, say, a streamlit application
that could be used to monitor for arbitrage opportunities in real time?

4.4.11 Real Time Downloads of Order Books from an Exchange

The goal of this notebook was to show how network algorithms and optimization can be utilized to detect arbitrage
opportunities within an order book that has been obtained from a cryptocurrency exchange.
The subsequent cell in the notebook utilizes ccxt.exchange.fetch_order_book to obtain the highest bid and
lowest ask orders from an exchange for market symbols that meet the criteria of having a minimum in-degree for their
base currencies.

import pandas as pd

def get_order_book(exchange, exchange_dg):


def get_orders(base, quote, limit=1):
"""
Return order book data for a specified symbol.
"""
result = exchange.fetch_order_book("/".join([base, quote]), limit)
if not result["asks"] or not result["bids"]:
result = None
else:
result["base"], result["quote"] = base, quote
result["timestamp"] = exchange.milliseconds()
result["bid_price"], result["bid_volume"] = result["bids"][0]
result["ask_price"], result["ask_volume"] = result["asks"][0]
return result

# fetch order book data and store in a dictionary


order_book = filter(
lambda r: r is not None,
[get_orders(base, quote) for quote, base in exchange_dg.edges()],
)

# convert to pandas dataframe


order_book = pd.DataFrame(order_book)
order_book["timestamp"] = pd.to_datetime(order_book["timestamp"], unit="ms")

return order_book[
[
"symbol",
(continues on next page)

4.4. Cryptocurrency arbitrage search 226


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


"timestamp",
"base",
"quote",
"bid_price",
"bid_volume",
"ask_price",
"ask_volume",
]
]

minimum_in_degree = 5

# get graph of market symbols with mininum_in_degree for base currencies


exchange_dg = get_exchange_dg(exchange, minimum_in_degree)

# get order book for all markets in the graph


order_book = get_order_book(exchange, exchange_dg)
order_book_dg = order_book_to_dg(order_book)

# find trades
v0 = 10000.0
m = crypto_model(order_book_dg, T=12, v0=v0)
vT = m.obj["wealth"].value()

print(f"Potential Return = {10000*(vT - v0)/v0:0.3f} basis points")


display(order_book)

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 10000.00128


0 simplex iterations
Potential Return = 0.001 basis points

symbol timestamp base quote bid_price bid_volume \


0 ETH/BTC 2023-07-21 19:45:31.588 ETH BTC 0.063325 0.02360
1 BNB/BTC 2023-07-21 19:45:31.693 BNB BTC 0.008148 6.18300
2 LTC/BTC 2023-07-21 19:45:31.738 LTC BTC 0.003155 0.11700
3 ADA/BTC 2023-07-21 19:45:31.888 ADA BTC 0.000010 176.00000
4 LINK/BTC 2023-07-21 19:45:31.939 LINK BTC 0.000272 5.50000
.. ... ... ... ... ... ...
171 SOL/USDC 2023-07-21 19:45:42.132 SOL USDC 25.720000 19.40000
172 ADA/USDC 2023-07-21 19:45:42.180 ADA USDC 0.313200 5193.80000
173 BTC/DAI 2023-07-21 19:45:42.229 BTC DAI 30066.840000 0.01042
174 ETH/DAI 2023-07-21 19:45:42.278 ETH DAI 1775.010000 0.00780
175 USDT/USD 2023-07-21 19:45:42.583 USDT USD 0.998700 5996.00000

ask_price ask_volume
0 0.063512 0.01890
1 0.008156 3.84100
2 0.003156 4.00000
3 0.000011 40.10000
4 0.000274 13.96000
.. ... ...
171 25.790000 3.07000
172 0.316200 1135.70000
173 30091.810000 0.00172
174 1974.530000 0.01890
(continues on next page)

4.4. Cryptocurrency arbitrage search 227


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


175 0.998800 13173.00000

[176 rows x 8 columns]

The following cell can be used to download additional order book data sets for testing.

from datetime import datetime


import time
import glob

# get graph of market symbols with mininum_in_degree for base currencies


minimum_in_degree = 5
exchange_dg = get_exchange_dg(exchange, minimum_in_degree)

# search time
search_time = 20
timeout = time.time() + search_time

# threshold in basis points


arb_threshold = 1.0

# wait for arbitrage opportunity


print(f"Search for {search_time} seconds.")
while time.time() <= timeout:
# print("bp = ", end="")
order_book = get_order_book(exchange, exchange_dg)
order_book_dg = order_book_to_dg(order_book)

v0 = 10000.0
m = crypto_model(order_book_dg, T=12, v0=10000)
vT = m.obj["wealth"].value()
bp = 10000 * (vT - v0) / vT
print(f"bp = {bp:0.3f}")

if bp >= arb_threshold:
print("arbitrage found!")
fname = f"{exchange} orderbook {datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.
↪csv".replace(

" ", "_"


)
order_book.to_csv(fname)
print(f"order book saved to: {fname}")

print("Search complete.")

Search for 20 seconds.


cbc 2.10.7cbc 2.10.7: optimal solution; objective 10000.00128
0 simplex iterations
bp = 0.001
Search complete.

4.4. Cryptocurrency arbitrage search 228


Hands-On Mathematical Optimization with AMPL in Python

4.5 Extra material: Energy dispatch problem

To meet the energy demand, power plants run day and night across the country to produce electricity from a variety of
sources such as fossil fuels and renewable energy. On the short-time scale, the best operating levels for electric power
plants are derived every 15 minutes by solving the so-called Optimal Power Flow (OPF) model. The OPF model is an
optimization problem with the objective of minimizing the total energy dispatching cost, while ensuring that the generation
meets the total energy demand. Furthermore, the model takes into account many constraints, among which operational
and physical constraints.

# install dependencies and select solver


%pip install -q amplpy matplotlib networkx numpy pandas

SOLVER = "highs"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import networkx as nx

4.5.1 Background: Power networks and power flow physics

We model the nation-wide transmission power network as a directed graph 𝐺 = (𝑉 , 𝐸), where 𝑉 represents the set of
nodes (e.g., cities, industrial districts, power plants) and 𝐸 denotes the set of directed edges (e.g., physical transmission
lines).
Each node 𝑖 ∈ 𝑉 has a power injection 𝑝𝑖 and demand 𝑑𝑖 . The set of nodes are separated into generator and load nodes.
The set of generators 𝒢 ⊆ 𝑉 corresponds to the nodes 𝑖 ∈ 𝑉 for which 𝑝𝑖 ≥ 0 and 𝑑𝑖 = 0. Each generator 𝑖 ∈ 𝒢 has
a minimum 𝑝𝑖min and maximum 𝑝𝑖max production capacity. The set of load nodes 𝒟 ⊆ 𝑉 corresponds to the nodes for
which 𝑝𝑖 = 0 and 𝑑𝑖 ≥ 0. The load nodes thus correspond to the places where electricity is being consumed, e.g., cities
and industrial districts. We say that supply and demand is matched if ∑𝑖∈𝑉 𝑝𝑖 − 𝑑𝑖 = 0. Since we cannot store electricity
in large volumes, supply must meet demand at all times, hence adjusting to it every 15 minutes by solving the OPF.
max
Each edge (𝑖, 𝑗) ∈ 𝐸 carries a power flow 𝑓𝑖𝑗 ∈ 𝑅 and has a capacity 𝑓𝑖𝑗 ≥ 0, i.e., the maximum power flow that it
may carry. Note that our choice to model a directed graph is to make the modeling of the network easier. In particular,
a directed edge (𝑖, 𝑗) may carry a ‘negative’ flow 𝑓𝑖𝑗 < 0, which implies that there is flow going from 𝑗 to 𝑖 where
𝑓𝑗𝑖 = −𝑓𝑖𝑗 . The capacity does not depend on the direction of the flow, implying that the flow capacity constraints are
max
given by |𝑓𝑖𝑗 | = |𝑓𝑗𝑖 | ≤ 𝑓𝑖𝑗 .
One crucial difference of power flow problems compared to typical network flow problems is that the power flows cannot
be controlled directly. Instead, as you might recall from high-school physics, the power flows are determined by the laws
of electricity, which we will now present as the power flow equations. Ignore for a moment the flow capacity constraints.
Let 𝜃𝑖 ∈ ℝ denote the phase angle of node 𝑖. For each edge (𝑖, 𝑗), let 𝑏𝑖𝑗 > 0 denote the line susceptance. Assuming that

4.5. Extra material: Energy dispatch problem 229


Hands-On Mathematical Optimization with AMPL in Python

𝑛
supply and demand is matched, i.e., ∑𝑖=1 𝑝𝑖 − 𝑑𝑖 = 0, the power flows f ∈ ℝ𝑚 and phase angles 2/7 ∈ ℝ𝑛 are obtained
by solving the following linear system of equations:

𝑝𝑖 − 𝑑𝑖 = ∑ 𝑓𝑖𝑗 − ∑ 𝑓𝑗𝑖 , ∀𝑖 ∈ 𝑉, (4.2)


𝑗∶(𝑖,𝑗)∈𝐸 𝑗∶(𝑗,𝑖)∈𝐸

𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸.


(4.3)

The first set of constraints ensures flow conservation and the second set of constrations captures the flow dependency on
susceptances and angle differences. The DC power flow equations admit a unique power flow solution f given matched
power injections p and demand d.
For a given matched supply and demand vector p and d, we can compute the power flows on the network by solving
the linear equations as described above. There are exactly |𝑉 | and |𝐸| equations for the 𝜃𝑖 variables and 𝑓𝑖𝑗 variables,
meaning that this system of equations admit a solution.

4.5.2 Optimal Power Flow

We assumed above that the power injections p were given. However, in practice, the power injections need to be deter-
mined for each generator in the power network, where some types of generators may be cheaper than others. Moreover,
we need to take into account operational constraints, such as the maximum flow and generator limits.
On the short-time scale, the power injections are calculated for each generator by solving the so-called Optimal Power
Flow (OPF) problem. The goal of the OPF problem is to determine a solution (p, f, 2/7) with minimal costs such that:
• Supply meets demand
• Line capacity constraints are met
• Generator capacity constraints are met
Let 𝑐𝑖 > 0 be the cost associated with the production of a unit energy by generator 𝑖. Then, the OPF problem can be
formulated as
min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 = 𝑝𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
max
|𝑓𝑖𝑗 | ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
(4.4)
𝑝𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max
min
∀𝑖 ∈ 𝑉 ,
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
𝑓𝑖𝑗 ∈ ℝ ∀(𝑖, 𝑗) ∈ 𝐸

For simplicity, you may assume that all load nodes do not produce energy, i.e., 𝑝𝑖 = 𝑝𝑖min = 𝑝𝑖max = 0 for all 𝑖 ∈ 𝒟. You
may therefore model 𝑝𝑖 as decision variables for all nodes (both generator and load nodes). Similarly, you may assume
that all generator nodes have no demand, i.e., 𝑑𝑖 = 0 for all 𝑖 ∈ 𝒢.
To summarize, the decision variables in the OPF problem are:
• 𝑝𝑖 power injections
• 𝜃𝑖 phase angles
• 𝑓𝑖𝑗 power flows
All the other quantities are instance dependent parameters.

4.5. Extra material: Energy dispatch problem 230


Hands-On Mathematical Optimization with AMPL in Python

4.5.3 Data

You will solve the OPF problem on the IEEE-118 power network, which is a standard test network consisting of 118
nodes and 179 edges. In the following, we will load the data into the notebook and provide a description of the data.

Data import

# Download the data


base_url = (
"https://raw.githubusercontent.com/ampl/mo-book.ampl.com/dev/notebooks/04/"
)
nodes_df = pd.read_csv(base_url + "nodes.csv").set_index(["node_id", "instance"])
edges_df = pd.read_csv(base_url + "edges.csv").set_index(["node_id1", "node_id2"])

# Replace 'na' by an empty string


nodes_df.fillna("", inplace=True)

# Split the initial nodes data frame by instance


nodes_by_instance = [
y.reset_index(level=1).drop("instance", axis=1)
for x, y in nodes_df.groupby("instance")
]
I = [{"nodes": n, "edges": edges_df} for n in nodes_by_instance]

# Initialize a network for demonstration purposes


network = I[0]

Network data

def visualize_network(network, edge_flows=None, ax=None):


"""Visualize a network instance, highlighting the generators in orange and the␣
↪load buses in green."""

plt.figure(figsize=[12, 10])
g = nx.DiGraph(list(network["edges"].index))
pos = nx.layout.kamada_kawai_layout(g, weight=None)

color_mapping = {
"solar": "#ffcb36",
"wind": "white",
"hydro": "#a5efff",
"coal": "#686868",
"gas": "#00ab4e",
"": "#b6b6b6",
}

vertex2color = {
k: color_mapping[v] for k, v in network["nodes"]["energy_type"].items()
}
v2c_list = [vertex2color[i] for i in g.nodes] # Order based on networkx

nodes = nx.draw_networkx_nodes(
g,
pos,
node_size=250,
(continues on next page)

4.5. Extra material: Energy dispatch problem 231


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


node_color=v2c_list,
linewidths=2,
)
edges = nx.draw_networkx_edges(
g,
pos,
width=2,
edge_color="#595959",
)

# Gives node colors


ax = plt.gca()
ax.collections[0].set_edgecolor("#595959")
ax.set_axis_off()

visualize_network(network)

The IEEE-118 network has 18 generators, of which:


• 2 hydro plants (cyan)

4.5. Extra material: Energy dispatch problem 232


Hands-On Mathematical Optimization with AMPL in Python

• 4 coal plants (dark gray)


• 4 solarfarms (yellow)
• 2 windmills (white)
• 6 gas plants (green)
Moreover, the network has 100 load nodes (gray). Each node has the following parameters:
• node_id
• d: demand
• p_min: minimum production capacity
• p_max: maximum production capacity
• c_var: variable production costs
• is_generator: boolean value to indicate whether the node is a generator or not
• energy_type: the type of energy used for electricity production
All generator nodes can filtered by the is_generator parameter. All generators have a zero demand 𝑑𝑖 = 0. For
the renewable energy sources (i.e., hydro, solar and wind) there are no variable costs c_var. For solar and wind, the
production is fixed, i.e., p_min = p_max, meaning that all available solar and wind energy must be produced.

example_nodes = network["nodes"]
example_nodes[example_nodes.is_generator]

d p_min p_max c_var is_generator energy_type


node_id
9 0.0 0.000000 400.000000 0.000000 True hydro
11 0.0 0.000000 200.000000 0.000000 True hydro
24 0.0 0.000000 397.800000 28.948321 True coal
25 0.0 0.000000 873.000000 22.220980 True coal
30 0.0 0.000000 612.000000 25.993982 True coal
45 0.0 0.000000 720.000000 24.202306 True coal
48 0.0 0.000000 0.000000 0.000000 True solar
53 0.0 0.000000 0.000000 0.000000 True solar
58 0.0 0.000000 0.000000 0.000000 True solar
60 0.0 0.000000 0.000000 0.000000 True solar
64 0.0 38.484861 38.484861 0.000000 True wind
65 0.0 180.602933 180.602933 0.000000 True wind
79 0.0 0.000000 916.200000 50.330000 True gas
86 0.0 0.000000 360.000000 50.330000 True gas
88 0.0 0.000000 1146.600000 50.330000 True gas
99 0.0 0.000000 1175.400000 50.330000 True gas
102 0.0 0.000000 194.400000 50.330000 True gas
110 0.0 0.000000 142.200000 50.330000 True gas

For the load nodes, the only important parameter is the demand 𝑑𝑖 ≥ 0. All other parameters are either zero, False, or
an empty string ''.

example_nodes[~example_nodes.is_generator]

d p_min p_max c_var is_generator energy_type


node_id
0 28.186145 0.0 0.0 0.0 False
1 10.921628 0.0 0.0 0.0 False
(continues on next page)

4.5. Extra material: Energy dispatch problem 233


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


2 22.884795 0.0 0.0 0.0 False
3 21.961682 0.0 0.0 0.0 False
4 0.000000 0.0 0.0 0.0 False
... ... ... ... ... ... ...
113 4.396000 0.0 0.0 0.0 False
114 11.864042 0.0 0.0 0.0 False
115 108.311328 0.0 0.0 0.0 False
116 10.703998 0.0 0.0 0.0 False
117 18.242759 0.0 0.0 0.0 False

[100 rows x 6 columns]

Edges

The IEEE-118 network has 179 edges. Each edge has the following parameters:
• node_id1: first node of a given edge
• node_id2: second node of a given edge
• b: the line susceptance, and
• f_max: the maximum edge capacity.

edges_df

b f_max
node_id1 node_id2
0 1 10.0100 271
2 23.5849 271
3 4 125.3133 316
2 4 9.2593 315
4 5 18.5185 316
... ... ...
64 65 28.9059 1427
67 68 28.9059 1427
80 79 28.9059 1427
86 85 4.8216 253
67 115 246.9136 12992

[179 rows x 2 columns]

Instances

Since the OPF problem is solved every 15 minutes in practice, you will be given 24 * 4 = 96 network instances
that need to be solved, hence solving an entire day’s worth of OPF problems. The first instance thus corresponds to the
power generation within time window [00:00, 00:15], the second instance corresponds to [00:15, 00:30],
and so on. The data takes into account a realistic demand and (renewable energy) generation pattern. We assume that the
decisions in each time window are independent of the previous and subsequent time windows, so every 15 minutes a new
OPF instance is solved independently.
The network instances are stored in the variable I as a list and can be accessed using indexing, i.e., I[0] gives the first
network instance and I[95] gives the 96th network instance.

4.5. Extra material: Energy dispatch problem 234


Hands-On Mathematical Optimization with AMPL in Python

4.6 Solving OPF

Observe that the stated OPF problem contains absolute decision values. We first rewrite the OPF problem into a linear
optimization problem.

min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
+ − + −
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 − 𝑓𝑗𝑖 = 𝑝 𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
+ −
𝑓𝑖𝑗 − 𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
+ − max
𝑓𝑖𝑗 + 𝑓𝑖𝑗 ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
(4.5)
min
𝑝𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max ∀𝑖 ∈ 𝑉 ,
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
+ −
𝑓𝑖𝑗 , 𝑓𝑖𝑗 ∈ℝ ∀(𝑖, 𝑗) ∈ 𝐸

We then implement the model using AMPL and solve it for all instances I[0] to I[95], reporting the average objective
value across all the 96 instances.
%%writefile opf1.mod

# Indexing sets
set NODES;
set EDGES within {NODES,NODES};

# Parameters
param d{NODES};
param p_min{NODES};
param p_max{NODES};
param c_var{NODES};
param is_generator{NODES};
param energy_type{NODES} symbolic;

param b{EDGES};
param f_max{EDGES};

# Declare decision variables


var p{i in NODES} >= p_min[i], <= p_max[i];
var theta{NODES};
var fabs{(i,j) in EDGES} >= 0, <= f_max[i, j];
var fp{EDGES} >= 0;
var fm{EDGES} >= 0;

# Declare objective value


minimize energy_cost: sum{i in NODES: is_generator[i] == 1} c_var[i] * p[i];

# Auxiliary variables for incoming and outgoing flows


var incoming_flow{i in NODES} = sum{j in NODES: (j,i) in EDGES} (fp[j, i] - fm[j, i]);
var outgoing_flow{i in NODES} = sum{j in NODES: (i,j) in EDGES} (fp[i, j] - fm[i, j]);

# Constraints
s.t. flow_conservation{i in NODES}:
outgoing_flow[i] - incoming_flow[i] == p[i] - d[i];

s.t. susceptance{(i,j) in EDGES}:


fp[i, j] - fm[i, j] == b[i, j] * (theta[i] - theta[j]);

s.t. abs_flow{(i,j) in EDGES}:


fabs[i, j] == fp[i, j] + fm[i, j];

4.6. Solving OPF 235


Hands-On Mathematical Optimization with AMPL in Python

Overwriting opf1.mod

def OPF1(network):
"""
Input:
- network: a dictionary containing:
- a dictionary of nodes with a dictionary of attributes
- a dictionary of edges with a dictionary of attributes

Output:
- total energy dispatching cost
"""

nodes = network["nodes"]
edges = network["edges"]

m = AMPL()
m.read("opf1.mod")

m.set_data(nodes, "NODES")
m.set_data(edges, "EDGES")

m.option["solver"] = SOLVER
m.solve()

return m.obj["energy_cost"].value()

OPF1_results = [OPF1(instance) for instance in I]


print(f"The average objective value over all instances is: {np.mean(OPF1_results):.2f}
↪")

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25556.38586


527 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25291.14543


524 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25014.46578


517 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 24033.92447


547 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 24607.51391


526 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23894.80106


523 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23539.85465


(continues on next page)

4.6. Solving OPF 236


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


522 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23344.3297


532 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 22863.0752


516 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21810.52787


521 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21900.80263


532 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21547.3871


526 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20462.85553


530 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20203.29637


525 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19631.87938


528 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19053.61942


514 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19052.65246


529 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19361.24172


530 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19838.31763


527 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20490.77796


527 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20961.18848


525 simplex iterations
(continues on next page)

4.6. Solving OPF 237


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21810.71275


538 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23204.42688


520 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23961.68452


560 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25710.80611


523 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 28528.05224


519 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 30606.15924


529 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 32931.97675


535 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 35933.96987


524 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 40878.89847


543 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 41272.13149


553 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 41625.78447


548 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 43657.80972


524 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 44523.08766


539 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46653.79048


528 simplex iterations
0 barrier iterations
(continues on next page)

4.6. Solving OPF 238


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 48123.99084


542 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 49986.24726


526 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51594.08932


539 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 48767.34223


536 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46412.44208


555 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 45234.51683


563 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47192.70567


533 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 44287.62719


536 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 44509.58397


566 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 42934.44805


523 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 42869.82759


535 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 39555.8754


540 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 38670.71153


538 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 37154.52425


531 simplex iterations
0 barrier iterations

(continues on next page)

4.6. Solving OPF 239


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36860.74842
537 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 38740.0701


543 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36354.1365


525 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36912.55781


551 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 37668.23898


545 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 37040.66645


546 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 34794.47952


527 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36325.10891


532 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36452.65775


537 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 35163.06581


536 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 35116.35087


537 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36112.04806


535 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 34697.75853


529 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 35180.60733


529 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 35021.77992


(continues on next page)

4.6. Solving OPF 240


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


522 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36530.09028


531 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 37554.629


525 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 41679.08392


542 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46836.06918


543 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54206.38573


541 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 57125.77445


522 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 59781.78181


554 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62049.83588


546 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 61861.88082


547 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62773.3659


569 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 63568.70959


548 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 63537.75177


544 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 65871.0868


554 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 64744.56999


562 simplex iterations
(continues on next page)

4.6. Solving OPF 241


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 64044.66726


549 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62795.39073


552 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 60679.41848


543 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 60587.38654


578 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 56395.11375


541 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54159.88066


545 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51612.43852


553 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47266.24936


544 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 43900.49913


540 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 40067.91939


535 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36097.07305


551 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 34979.65852


519 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 34133.27621


516 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 33746.40968


511 simplex iterations
0 barrier iterations
(continues on next page)

4.6. Solving OPF 242


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 32602.68668


514 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 31745.40199


520 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 30839.27015


528 simplex iterations
0 barrier iterations

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 29427.14541


529 simplex iterations
0 barrier iterations

The average objective value over all instances is: 38507.23

4.7 Strict fossil fuel policy pt.1

Gas and coal plants emit CO2, while renewable energy sources are carbon neutral. For this reason, the Dutch government
has decided to constrain the number of active fossil-fuel-based power plants for the generation of electricity. More
specifically, a maximum of 2 gas plants and 1 coal plant may be active during a single OPF instance. Any plant that is set
inactive for a specific instance cannot produce any electricity.
We first write down the new model. To this end, we introduce new decision variables 𝑥𝑖 , 𝑖 ∈ 𝑉 to the model, which
indicate whether a generator 𝑖 is active or inactive.

min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
+ − + −
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 − 𝑓𝑗𝑖 = 𝑝 𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
+ −
𝑓𝑖𝑗 − 𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 + −
𝑓𝑖𝑗 = 𝑓𝑖𝑗 + 𝑓𝑖𝑗 , ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 max
𝑓𝑖𝑗 ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
𝑝𝑖min 𝑥𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max 𝑥𝑖 ∀𝑖 ∈ 𝑉 ,
(4.6)
∑𝑖∈𝒢 𝑥𝑖 ≤ 2
gas
∑𝑖∈𝒢 𝑥𝑖 ≤ 1
coal
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
𝑓𝑖𝑗 ∈ ℝ ∀(𝑖, 𝑗) ∈ 𝐸
𝑥𝑖 ∈ {0, 1} ∀𝑖 ∈ 𝑉

We then implement the new model using AMPL and solve it for all instances I[0] to I[95], reporting the average
objective value across the instances.

%%writefile opf2.mod

# Indexing sets
set NODES;
set EDGES within {NODES,NODES};

# Parameters
(continues on next page)

4.7. Strict fossil fuel policy pt.1 243


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


param d{NODES};
param p_min{NODES};
param p_max{NODES};
param c_var{NODES};
param is_generator{NODES};
param energy_type{NODES} symbolic;

param b{EDGES};
param f_max{EDGES};

param max_gas_plants;
param max_coal_plants;

# Declare decision variables


var p{i in NODES} >= 0;
var theta{NODES};
var x{NODES} binary;
var fabs{(i,j) in EDGES} >= 0, <= f_max[i, j];
var fp{EDGES} >= 0;
var fm{EDGES} >= 0;

# Declare objective value


minimize energy_cost: sum{i in NODES: is_generator[i] == 1} c_var[i] * p[i];

# Auxiliary variables for incoming and outgoing flows


var incoming_flow{i in NODES} = sum{j in NODES: (j,i) in EDGES} (fp[j, i] - fm[j, i]);
var outgoing_flow{i in NODES} = sum{j in NODES: (i,j) in EDGES} (fp[i, j] - fm[i, j]);

# Constraints
s.t. flow_conservation{i in NODES}:
outgoing_flow[i] - incoming_flow[i] == p[i] - d[i];

s.t. susceptance{(i,j) in EDGES}:


fp[i, j] - fm[i, j] == b[i, j] * (theta[i] - theta[j]);

s.t. abs_flow{(i,j) in EDGES}:


fabs[i, j] == fp[i, j] + fm[i, j];

s.t. generation_upper_bound{i in NODES}: p[i] <= p_max[i] * x[i];


s.t. generation_lower_bound{i in NODES}: p[i] >= p_min[i] * x[i];

s.t. gas_plants_limit: sum{i in NODES: energy_type[i] == 'gas'} x[i] <= max_gas_


↪plants;

s.t. coal_plants_limit: sum{i in NODES: energy_type[i] == 'coal'} x[i] <= max_coal_


↪plants;

Overwriting opf2.mod

def OPF2(network, max_gas_plants, max_coal_plants):


"""
Input:
- network: a dictionary containing:
- a dictionary of nodes with a dictionary of attributes
- a dictionary of edges with a dictionary of attributes

Output:
(continues on next page)

4.7. Strict fossil fuel policy pt.1 244


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


- total energy dispatching cost
"""

nodes = network["nodes"]
edges = network["edges"]

m = AMPL()
m.read("opf2.mod")

m.set_data(nodes, "NODES")
m.set_data(edges, "EDGES")

m.param["max_gas_plants"] = max_gas_plants
m.param["max_coal_plants"] = max_coal_plants

m.option["solver"] = SOLVER
m.solve()

return m.obj["energy_cost"].value()

max_gas_plants = 2
max_coal_plants = 1
OPF2_results = [OPF2(instance, max_gas_plants, max_coal_plants) for instance in I]
print(f"The average objective value over all instances is: {np.mean(OPF2_results):.2f}
↪")

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 32014.85844


578 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 31513.92689


576 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 30885.43208


569 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 28881.74194


558 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 30037.14784


553 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 28659.49442


730 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 28098.31029


706 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 27643.13369


737 simplex iterations
(continues on next page)

4.7. Strict fossil fuel policy pt.1 245


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 26906.38333


666 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25000.0886


788 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25172.05815


774 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 24597.59628


773 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 22704.40086


810 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 22272.96622


802 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21282.46505


794 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20149.63104


768 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20299.75691


747 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20796.63521


766 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21606.25852


820 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 22744.74502


831 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23499.3076


835 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25010.50854


770 simplex iterations
3 branching nodes
(continues on next page)

4.7. Strict fossil fuel policy pt.1 246


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 27655.6242


725 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 28739.85416


674 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 32306.05117


570 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 38259.59372


592 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 42596.56579


572 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47474.46445


587 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52182.27728


549 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 57946.94606


549 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 58983.93317


556 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 59611.09337


562 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62318.4829


610 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 63377.649


553 simplex iterations
1 branching nodes
absmipgap=7.27596e-12, relmipgap=1.14803e-16
HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 66355.25327
648 simplex iterations
1 branching nodes
absmipgap=2.91038e-11, relmipgap=4.38606e-16
HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 67718.3282
559 simplex iterations
1 branching nodes

(continues on next page)

4.7. Strict fossil fuel policy pt.1 247


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 70199.13353
601 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 72074.24181


574 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 69357.83301


587 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 66541.23238


554 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 65359.14645


580 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 67603.72994


551 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 64424.45835


543 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 64578.61294


550 simplex iterations
1 branching nodes
absmipgap=8.73115e-11, relmipgap=1.35202e-15
HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62683.24322
544 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62712.94063


537 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 58810.9962


539 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 57847.89312


533 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54468.61779


534 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54196.02103


534 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 56244.40675


(continues on next page)

4.7. Strict fossil fuel policy pt.1 248


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


538 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52907.81025


515 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 53769.82573


558 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 55263.93141


549 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54378.86264


545 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 50680.88639


535 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 53087.4615


541 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52929.63938


539 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51552.75812


524 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51058.5326


534 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52840.59651


545 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 50645.90154


522 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51995.97075


554 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51721.08012


537 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54156.67505


542 simplex iterations
(continues on next page)

4.7. Strict fossil fuel policy pt.1 249


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 55970.40312


544 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 61124.14254


537 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 67438.51907


557 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 75846.37597


606 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 79266.53736


570 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 82505.30565


580 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 85139.05154


577 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 84584.98267


590 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 85269.31644


592 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 86348.42938


573 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 86331.61954


568 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 88598.84186


630 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 87298.4441


595 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 86180.15407


560 simplex iterations
1 branching nodes
(continues on next page)

4.7. Strict fossil fuel policy pt.1 250


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 84762.76321


569 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 82124.08662


549 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 82071.14498


575 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 77315.23968


564 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 74464.39753


582 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 71600.93405


609 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 66396.59052


621 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62377.56686


564 simplex iterations
1 branching nodes
absmipgap=1.16415e-10, relmipgap=1.8663e-15
HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 57665.26521
562 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 53248.29462


553 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51707.5513


547 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 50039.81283


581 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 49235.30303


584 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46856.8694


595 simplex iterations
1 branching nodes

(continues on next page)

4.7. Strict fossil fuel policy pt.1 251


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 45074.09967
569 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 43164.18792


577 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 40195.99971


560 simplex iterations
1 branching nodes

The average objective value over all instances is: 53120.81

4.8 Strict fossil fuel policy pt.2

The restriction on the number of gas and coal plants may pose a threat to the availability of electricity production when
renewable energy sources fail to deliver. For this reason, the grid operators have decided to slightly change the constraint
that was introduced for OPF2. If the total production of energy from renewable energy sources (i.e., solar, wind and hydro)
is above 1000, then the number of gas and coal plants is restricted to 2 and 1, respectively. Otherwise, the restriction on
the number of gas and coal plants is lifted. These constraints can be modeled as either-or constraints.
We first write down the new model, using big-𝑀 constraints to model the either-or constraint.
min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
+ − + −
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 − 𝑓𝑗𝑖 = 𝑝 𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
+ −
𝑓𝑖𝑗 − 𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 + −
𝑓𝑖𝑗 = 𝑓𝑖𝑗 + 𝑓𝑖𝑗 , ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 max
𝑓𝑖𝑗 ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
𝑝𝑖min 𝑥𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max 𝑥𝑖 ∀𝑖 ∈ 𝑉 ,
∑𝑖∈𝒢 𝑥𝑖 ≤ 2 + (1 − 𝑦)𝑀0
gas (4.7)
∑𝑖∈𝒢 𝑥𝑖 ≤ 1 + (1 − 𝑦)𝑀1
coal
∑𝑖∈𝒢 𝑝𝑖 + ∑𝑖∈𝒢 𝑝𝑖 + ∑𝑖∈𝒢 𝑝𝑖 ≤ 1000 + 𝑦𝑀2
solar wind hydro
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
𝑓𝑖𝑗 ∈ ℝ ∀(𝑖, 𝑗) ∈ 𝐸
𝑥𝑖 ∈ {0, 1} ∀𝑖 ∈ 𝑉
𝑦 ∈ {0, 1}
We now implement the new model using AMPL and solve it for all instances I[0] to I[95], reporting the average
objective value across the instances.
%%writefile opf3.mod

# Indexing sets
set NODES;
set EDGES within {NODES,NODES};

# Parameters
param d{NODES};
param p_min{NODES};
param p_max{NODES};
(continues on next page)

4.8. Strict fossil fuel policy pt.2 252


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


param c_var{NODES};
param is_generator{NODES};
param energy_type{NODES} symbolic;

param b{EDGES};
param f_max{EDGES};

param max_gas_plants;
param max_coal_plants;

# Big-Ms
param M0;
param M1;
param M2;

# Declare decision variables


var p{i in NODES} >= 0;
var theta{NODES};
var x{NODES} binary;
var fabs{(i,j) in EDGES} >= 0, <= f_max[i, j];
var fp{EDGES} >= 0;
var fm{EDGES} >= 0;
var y binary;

# Declare objective value


minimize energy_cost: sum{i in NODES: is_generator[i] == 1} c_var[i] * p[i];

# Auxiliary variables for incoming and outgoing flows


var incoming_flow{i in NODES} = sum{j in NODES: (j,i) in EDGES} (fp[j, i] - fm[j, i]);
var outgoing_flow{i in NODES} = sum{j in NODES: (i,j) in EDGES} (fp[i, j] - fm[i, j]);

# Constraints
s.t. flow_conservation{i in NODES}:
outgoing_flow[i] - incoming_flow[i] == p[i] - d[i];

s.t. susceptance{(i,j) in EDGES}:


fp[i, j] - fm[i, j] == b[i, j] * (theta[i] - theta[j]);

s.t. abs_flow{(i,j) in EDGES}:


fabs[i, j] == fp[i, j] + fm[i, j];

s.t. generation_upper_bound{i in NODES}: p[i] <= p_max[i] * x[i];


s.t. generation_lower_bound{i in NODES}: p[i] >= p_min[i] * x[i];

s.t. gas_plants_limit: sum{i in NODES: energy_type[i] == 'gas'} x[i] <= max_gas_


↪plants + (1 - y) * M0;

s.t. coal_plants_limit: sum{i in NODES: energy_type[i] == 'coal'} x[i] <= max_coal_


↪plants + (1 - y) * M1;

s.t. renewable_energy_production:
sum{i in NODES: energy_type[i] in {'solar', 'wind', 'hydro'}} p[i] <= 1000 + y *␣
↪M2;

Overwriting opf3.mod

4.8. Strict fossil fuel policy pt.2 253


Hands-On Mathematical Optimization with AMPL in Python

def OPF3(network, max_gas_plants, max_coal_plants):


"""
Input:
- network: a dictionary containing:
- a dictionary of nodes with a dictionary of attributes
- a dictionary of edges with a dictionary of attributes

Output:
- total energy dispatching cost
"""

nodes = network["nodes"]
edges = network["edges"]

m = AMPL()
m.read("opf3.mod")

m.set_data(nodes, "NODES")
m.set_data(edges, "EDGES")

m.param["max_gas_plants"] = max_gas_plants
m.param["max_coal_plants"] = max_coal_plants

m.param["M0"] = 4
m.param["M1"] = 3
m.param["M2"] = nodes["p_max"].sum()

m.option["solver"] = SOLVER
m.solve()

return m.obj["energy_cost"].value()

OPF3_results = [OPF3(instance, max_gas_plants, max_coal_plants) for instance in I]


print(f"The average objective value over all instances is: {np.mean(OPF3_results):.2f}
↪")

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25556.38586


542 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25291.14543


541 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25014.46578


532 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 24033.92447


532 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 24607.51391


527 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23894.80106


(continues on next page)

4.8. Strict fossil fuel policy pt.2 254


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


548 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23539.85465


531 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23344.3297


555 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 22863.0752


538 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21810.52787


535 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21900.80263


531 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21547.3871


524 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20462.85553


543 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20203.29637


534 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19631.87938


534 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19053.61942


535 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19052.65246


539 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19361.24172


535 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 19838.31763


539 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20490.77796


531 simplex iterations
(continues on next page)

4.8. Strict fossil fuel policy pt.2 255


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 20961.18848


531 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 21810.71275


536 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23204.42688


535 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 23961.68452


533 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 25710.80611


521 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 28528.05224


533 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 30606.15924


540 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 32931.97675


518 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 35933.96987


563 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 40878.89847


540 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 41272.13149


551 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 41625.78447


530 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 43657.80972


556 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 44523.08766


522 simplex iterations
1 branching nodes
(continues on next page)

4.8. Strict fossil fuel policy pt.2 256


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46653.79048


531 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 48517.02462


610 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51469.76795


793 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 53735.57811


667 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52478.34218


792 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51082.21976


789 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 50820.70479


650 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52284.09621


607 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51770.03428


722 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52556.32959


1132 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 52063.08667


934 simplex iterations
9 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 50973.40711


968 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 49101.46232


1292 simplex iterations
7 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47859.57387


1175 simplex iterations
3 branching nodes

(continues on next page)

4.8. Strict fossil fuel policy pt.2 257


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47812.22458
1084 simplex iterations
10 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47735.03354


1065 simplex iterations
7 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 49249.56069


1073 simplex iterations
10 branching nodes
absmipgap=2.11363, relmipgap=4.29168e-05
HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47079.99596
1159 simplex iterations
11 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47106.46001


1103 simplex iterations
11 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47698.24825


1418 simplex iterations
8 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47740.93595


1170 simplex iterations
10 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 45854.74172


1287 simplex iterations
14 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46738.37993


1057 simplex iterations
11 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46817.47832


1160 simplex iterations
14 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 45851.54466


1257 simplex iterations
14 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46189.99144


1222 simplex iterations
14 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46339.28074


1165 simplex iterations
14 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 45723.75827


1106 simplex iterations
14 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 44508.52721


(continues on next page)

4.8. Strict fossil fuel policy pt.2 258


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


1083 simplex iterations
12 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 44574.67924


903 simplex iterations
12 branching nodes
absmipgap=0.500375, relmipgap=1.12255e-05
HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46015.12654
1007 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 46672.0612


793 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 49744.16264


1167 simplex iterations
7 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54261.30821


631 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 59898.76421


605 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62696.04055


587 simplex iterations
1 branching nodes
absmipgap=1.00408e-09, relmipgap=1.60151e-14
HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 63259.92706
747 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 65614.15585


689 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 64099.97548


730 simplex iterations
3 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 63826.39248


584 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 63624.76669


580 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 63537.75177


570 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 65871.0868


554 simplex iterations
(continues on next page)

4.8. Strict fossil fuel policy pt.2 259


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 64744.56999


564 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 64044.66726


549 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 62795.39073


551 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 60679.41848


543 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 60587.38654


535 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 56395.11375


546 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 54159.88066


567 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 51612.43852


552 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 47266.24936


536 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 43900.49913


535 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 40067.91939


529 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 36097.07305


527 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 34979.65852


522 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 34133.27621


524 simplex iterations
1 branching nodes
(continues on next page)

4.8. Strict fossil fuel policy pt.2 260


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 33746.40968


544 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 32602.68668


521 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 31745.40199


547 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 30839.27015


533 simplex iterations
1 branching nodes

HiGHS 1.5.3: HiGHS 1.5.3: optimal solution; objective 29427.14541


535 simplex iterations
1 branching nodes

The average objective value over all instances is: 41608.73

4.9 Comparing the three models

For all three implemented models, we plot the objective values for all instances and explain the differences between the
objective values in view of the different feasible regions.

objs = [OPF1_results, OPF2_results, OPF3_results]

plt.plot(objs[0], color="blue", label="OPF1")


plt.plot(objs[1], color="green", label="OPF2")
plt.plot(objs[2], color="orange", label="OPF3")
plt.title("Optimal objective values over all instances and models")
plt.xlabel("Instance number")
plt.ylabel("Optimal objective value")
plt.legend()
plt.show()

4.9. Comparing the three models 261


Hands-On Mathematical Optimization with AMPL in Python

The goal of the OPF problem is to minimize the total costs of dispatching energy. OPF1 is the original OPF formulation,
whereas OPF2 and OPF3 are restricted versions of OPF1. This means that the feasible region of OPF1 is at least as large
as OPF2 and OPF3, where we may assume that 𝑥𝑖 = 1 for all the generators 𝑖.
If we let 𝐹1 , 𝐹2 , 𝐹3 denote the feasible region of OPF1, OPF2 and OPF3, then we observe that which explains that the
optimal objective value of OPF1 always remains below the optimal objectives values of OPF2 and OPF3.
The most important observation to make is that based on the problem descriptions, we would expect OPF2 to take on the
objective values of OPF1 or OPF3, depending on which of the either-or-constraints are activated.
• OPF3 uses expensive generators, and possibly not all the renewable energy
• OPF2 does only uses 1000 renewable energy power, because then it may keep using all the gas and coal generators.
• OPF1 uses all renewable energy at all times, because it has the flexibility to use all generators in order to mitigate
operational restrictions due to line flows.

4.10 Forex Arbitrage

This notebook presents an example of linear optimization on a network model for financial transactions. The goal is to
identify whether or not an arbitrage opportunity exists given a matrix of cross-currency exchange rates. Other treatments
of this problem and application are available, including the following links.
• Crypto Arbitrage Framework
• Crypto Trading and Arbitrage Identification Strategies

4.10. Forex Arbitrage 262


Hands-On Mathematical Optimization with AMPL in Python

# install dependencies and select solver


%pip install -q amplpy networkx numpy pandas

SOLVER = "cbc"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

import io
import networkx as nx
import numpy as np
import pandas as pd

4.10.1 Problem

Exchanging one currency for another is among the most common of all banking transactions. Currencies are normally
priced relative to each other.
At this moment of this writing, for example, the Japanese yen (symbol JPY) is priced at 0.00761 relative to the euro
(symbol EUR). At this price 100 euros would purchase 100/0.00761 = 13,140.6 yen. Conversely, EUR is priced at
131.585 yen. The ‘round-trip’ of 100 euros from EUR to JPY and back to EUR results in
1 JPY 1 EUR
100 EUR × ⟶ 12, 140.6 JPY × ⟶ 99.9954 EUR
0.00761 EUR 131.585 JPY
The small loss in this round-trip transaction is the fee collected by the brokers and banking system to provide these
services.
Needless to say, if a simple round-trip transaction like this reliably produced a net gain then there would many eager
traders ready to take advantage of the situation. Trading situations offering a net gain with no risk are called arbitrage,
and are the subject of intense interest by traders in the foreign exchange (forex) and crypto-currency markets around the
globe.
As one might expect, arbitrage opportunities involving a simple round-trip between a pair of currencies are almost non-
existent in real-world markets. When the do appear, they are easily detected and rapid and automated trading quickly
exploit the situation. More complex arbitrage opportunities, however, can arise when working with three more currencies
and a table of cross-currency exchange rates.

4.10.2 Demonstration of Triangular Arbitrage

Consider the following cross-currency matrix.

i <- J USD EUR JPY


USD 1.0 2.0 0.01
EUR 0.5 1.0 0.0075
JPY 100.0 133 1/3 1.0

Entry 𝑎𝑚,𝑛 is the number units of currency 𝑚 received in exchange for one unit of currency 𝑛. We use the notation

𝑎𝑚,𝑛 = 𝑎𝑚←𝑛

4.10. Forex Arbitrage 263


Hands-On Mathematical Optimization with AMPL in Python

as reminder of what the entries denote. For this data there are no two way arbitrage opportunities. We can check this by
explicitly computing all two-way currency exchanges

𝐼 →𝐽 →𝐼

by computing

𝑎𝑖←𝑗 × 𝑎𝑗←𝑖

This data set shows no net cost and no arbitrage for conversion from one currency to another and back again.
df = pd.DataFrame(
[[1.0, 0.5, 100], [2.0, 1.0, 1 / 0.0075], [0.01, 0.0075, 1.0]],
columns=["USD", "EUR", "JPY"],
index=["USD", "EUR", "JPY"],
).T

display(df)

# USD -> EUR -> USD


print(df.loc["USD", "EUR"] * df.loc["EUR", "USD"])

# USD -> JPY -> USD


print(df.loc["USD", "JPY"] * df.loc["JPY", "USD"])

# EUR -> JPY -> EUR


print(df.loc["EUR", "JPY"] * df.loc["JPY", "EUR"])

USD EUR JPY


USD 1.0 2.000000 0.0100
EUR 0.5 1.000000 0.0075
JPY 100.0 133.333333 1.0000

1.0
1.0
1.0

Now consider a currency exchange comprised of three trades that returns back to the same currency.

𝐼 →𝐽 →𝐾→𝐼

The net exchange rate can be computed as

𝑎𝑖←𝑘 × 𝑎𝑘←𝑗 × 𝑎𝑗←𝑖

By direct calculation we see there is a three-way triangular arbitrage opportunity for this data set that returns a 50%
increase in wealth.
I = "USD"
J = "JPY"
K = "EUR"

print(df.loc[I, K] * df.loc[K, J] * df.loc[J, I])

1.5

Our challenge is create a model that can identify complex arbitrage opportunities that may exist in cross-currency forex
markets.

4.10. Forex Arbitrage 264


Hands-On Mathematical Optimization with AMPL in Python

4.10.3 Modeling

The cross-currency table 𝐴 provides exchange rates among currencies. Entry 𝑎𝑖,𝑗 in row 𝑖, column 𝑗 tells us how many
units of currency 𝑖 are received in exchange for one unit of currency 𝑗. We’ll use the notation 𝑎𝑖,𝑗 = 𝑎𝑖←𝑗 to remind
ourselves of this relationship.
We start with 𝑤𝑗 (0) units of currency 𝑗 ∈ 𝑁 , where 𝑁 is the set of all currencies in the data set. We consider a sequence
of trades 𝑡 = 1, 2, … , 𝑇 where 𝑤𝑗 (𝑡) is the amount of currency 𝑗 on hand after completing trade 𝑡.
Each trade is executed in two phases. In the first phase an amount 𝑥𝑖←𝑗 (𝑡) of currency 𝑗 is committed for exchange
to currency 𝑖. This allows a trade to include multiple currency transactions. After the commitment the unencumbered
balance for currency 𝑗 must satisfy trading constraints. Each trade consists of simultaneous transactions in one or more
currencies.
𝑤𝑗 (𝑡 − 1) − ∑ 𝑥𝑖←𝑗 (𝑡) ≥ 0
𝑖≠𝑗

Here a lower bound has been placed to prohibit short-selling of currency 𝑗. This constraint could be modified if leveraging
is allowed on the exchange.
The second phase of the trade is complete when the exchange credits all of the currency accounts according to

𝑤𝑗 (𝑡) = 𝑤𝑗 (𝑡 − 1) − ∑ 𝑥𝑖←𝑗 (𝑡) + ∑ 𝑎𝑗←𝑖 𝑥𝑗←𝑖 (𝑡)


𝑖≠𝑗
⏟⏟⏟⏟⏟ ⏟⏟ 𝑖≠𝑗 ⏟⏟⏟⏟⏟
outgoing incoming

We assume all trading fees and costs are represented in the bid/ask spreads represented by 𝑎𝑗←𝑖
The goal of this calculation is to find a set of transactions 𝑥𝑖←𝑗 (𝑡) ≥ 0 to maximize the value of portfolio after a specified
number of trades 𝑇 .
%%writefile arbitrage.mod

set T0;
set T1;

# currency *nodes*
set NODES;

# paths between currency nodes i -> j


set ARCS within {NODES,NODES};

param T;
param R symbolic;
param a{NODES, NODES};

# w[i, t] amount of currency i on hand after transaction t


var w{NODES, T0} >= 0;

# x[m, n, t] amount of currency m converted to currency n in transaction t t


var x{ARCS, T1} >= 0;

# start with assignment of 100 units of a selected reserve currency


s.t. initial_condition{i in NODES}:
w[i, 0] == (if i == R then 100 else 0);

# no shorting constraint
s.t. max_trade {j in NODES, t in T1}:
w[j, t-1] >= sum{i in NODES: i != j} x[i, j, t];
(continues on next page)

4.10. Forex Arbitrage 265


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)

# one round of transactions


s.t. balances {j in NODES, t in T1}:
w[j, t] ==
w[j, t-1] -
sum{i in NODES: i != j} x[i, j, t] +
sum{i in NODES: i != j} a[j, i] * x[j, i, t];

maximize wealth: w[R, T];

Overwriting arbitrage.mod

def arbitrage(T, df, R="EUR"):


m = AMPL()
m.read("arbitrage.mod")

T0 = list(range(0, T + 1))
T1 = list(range(1, T + 1))
NODES = df.index
ARCS = [(i, j) for i in NODES for j in NODES if i != j]

m.set["T0"] = T0
m.set["T1"] = T1
m.set["NODES"] = NODES
m.set["ARCS"] = ARCS
m.param["T"] = T
m.param["R"] = R
m.param["a"] = df

m.option["solver"] = SOLVER
m.solve()

x = m.var["x"].to_dict()
w = m.var["w"].to_dict()

for t in T0:
print(f"\nt = {t}\n")
if t >= 1:
for i, j in ARCS:
if x[i, j, t] > 0:
print(
f"{j} -> {i} Convert {x[i, j, t]} {j} to {df.loc[i,j]*x[i,j,
↪t]} {i}"

)
print()

for i in NODES:
print(f"w[{i},{t}] = {w[i, t]:9.2f} ")

return m

m = arbitrage(3, df, "EUR")


w = m.var["w"].to_dict()
print(w["EUR", 0])
print(w["EUR", 3])

4.10. Forex Arbitrage 266


Hands-On Mathematical Optimization with AMPL in Python

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 150


0 simplex iterations

t = 0

w[USD,0] = 0.00
w[EUR,0] = 100.00
w[JPY,0] = 0.00

t = 1

EUR -> USD Convert 100 EUR to 200.0 USD

w[USD,1] = 200.00
w[EUR,1] = -0.00
w[JPY,1] = 0.00

t = 2

USD -> JPY Convert 200 USD to 20000.0 JPY

w[USD,2] = 0.00
w[EUR,2] = 0.00
w[JPY,2] = 20000.00

t = 3

JPY -> EUR Convert 20000.00000000001 JPY to 150.00000000000009 EUR

w[USD,3] = 0.00
w[EUR,3] = 150.00
w[JPY,3] = 0.00
100
150.0000000000001

4.10.4 Display graph

def display_graph(m):
m_w = m.var["w"].to_dict()
m_x = m.var["x"].to_dict()

T0 = m.set["T0"].to_list()
T1 = m.set["T1"].to_list()
NODES = m.set["NODES"].to_list()
ARCS = m.set["ARCS"].to_list()

path = []

for t in T0:
for i in NODES:
if m_w[i, t] >= 1e-6:
path.append(f"{m_w[i, t]} {i}")
path = " -> ".join(path)
print("\n", path)

(continues on next page)

4.10. Forex Arbitrage 267


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


G = nx.DiGraph()
for i in NODES:
G.add_node(i)
nodelist = set()
edge_labels = dict()

for t in T1:
for i, j in ARCS:
if m_x[i, j, t] > 0.1:
nodelist.add(i)
nodelist.add(j)
y = m_w[j, t - 1]
x = m_w[j, t]
G.add_edge(j, i)
edge_labels[(j, i)] = df.loc[i, j]

nodelist = list(nodelist)
pos = nx.spring_layout(G)
nx.draw(
G,
pos,
with_labels=True,
node_size=2000,
nodelist=nodelist,
node_color="lightblue",
node_shape="s",
arrowsize=20,
label=path,
)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels)

display_graph(m)

100 EUR -> 200 USD -> 20000 JPY -> 150.0000000000001 EUR

4.10. Forex Arbitrage 268


Hands-On Mathematical Optimization with AMPL in Python

4.10.5 FOREX data

https://www.bloomberg.com/markets/currencies/cross-rates
https://www.tradingview.com/markets/currencies/cross-rates-overview-prices/
# data extracted 2022-03-17

bloomberg = """
USD EUR JPY GBP CHF CAD AUD HKD
USD - 1.1096 0.0084 1.3148 1.0677 0.
↪7915 0.7376 0.1279
EUR 0.9012 - 0.0076 1.1849 0.9622 0.
↪7133 0.6647 0.1153
JPY 118.6100 131.6097 - 155.9484 126.
↪6389 93.8816 87.4867 15.1724
GBP 0.7606 0.8439 0.0064 - 0.8121 0.
↪6020 0.5610 0.0973
CHF 0.9366 1.0393 0.0079 1.2314 - 0.
↪7413 0.6908 0.1198
CAD 1.2634 1.4019 0.0107 1.6611 1.3489 -
↪ 0.9319 0.1616
AUD 1.3557 1.5043 0.0114 1.7825 1.4475 1.
↪0731 - 0.1734
(continues on next page)

4.10. Forex Arbitrage 269


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


HKD 7.8175 8.6743 0.0659 10.2784 8.3467 6.
↪1877 5.7662 -
"""

df = pd.read_csv(io.StringIO(bloomberg.replace("-", "1.0")), sep="\t", index_col=0)


display(df)

USD EUR JPY GBP CHF CAD AUD HKD


USD 1.0000 1.1096 0.0084 1.3148 1.0677 0.7915 0.7376 0.1279
EUR 0.9012 1.0000 0.0076 1.1849 0.9622 0.7133 0.6647 0.1153
JPY 118.6100 131.6097 1.0000 155.9484 126.6389 93.8816 87.4867 15.1724
GBP 0.7606 0.8439 0.0064 1.0000 0.8121 0.6020 0.5610 0.0973
CHF 0.9366 1.0393 0.0079 1.2314 1.0000 0.7413 0.6908 0.1198
CAD 1.2634 1.4019 0.0107 1.6611 1.3489 1.0000 0.9319 0.1616
AUD 1.3557 1.5043 0.0114 1.7825 1.4475 1.0731 1.0000 0.1734
HKD 7.8175 8.6743 0.0659 10.2784 8.3467 6.1877 5.7662 1.0000

m = arbitrage(3, df, "USD")

cbc 2.10.7: cbc 2.10.7: optimal solution; objective 100.451402


0 simplex iterations

t = 0

w[USD,0] = 100.00
w[EUR,0] = 0.00
w[JPY,0] = 0.00
w[GBP,0] = 0.00
w[CHF,0] = 0.00
w[CAD,0] = 0.00
w[AUD,0] = 0.00
w[HKD,0] = 0.00

t = 1

USD -> JPY Convert 100 USD to 11861.0 JPY

w[USD,1] = 0.00
w[EUR,1] = 0.00
w[JPY,1] = 11861.00
w[GBP,1] = 0.00
w[CHF,1] = 0.00
w[CAD,1] = 0.00
w[AUD,1] = 0.00
w[HKD,1] = 0.00

t = 2

GBP -> JPY Convert 2.399951029481301e-13 GBP to 3.742685231259617e-11 JPY


AUD -> GBP Convert 2.851001950900435e-13 AUD to 1.599412094455144e-13 GBP
JPY -> CAD Convert 11861 JPY to 126.91269999999999 CAD
CAD -> AUD Convert 2.65817100640753e-13 CAD to 2.85248330697592e-13 AUD
AUD -> HKD Convert 1.426981268386952e-13 AUD to 8.228259389772843e-13 HKD

(continues on next page)

4.10. Forex Arbitrage 270


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


w[USD,2] = 0.00
w[EUR,2] = 0.00
w[JPY,2] = -0.00
w[GBP,2] = 0.00
w[CHF,2] = 0.00
w[CAD,2] = 126.91
w[AUD,2] = 0.00
w[HKD,2] = 0.00

t = 3

CAD -> USD Convert 126.9127 CAD to 100.45140205 USD


GBP -> EUR Convert 2.391852144845997e-14 GBP to 2.834105606428022e-14 EUR
EUR -> CHF Convert 2.834105606428022e-14 EUR to 2.945485956760643e-14 CHF
JPY -> CAD Convert 2.832313318459672e-12 JPY to 3.0305752507518485e-14 CAD

w[USD,3] = 100.45
w[EUR,3] = 0.00
w[JPY,3] = 0.00
w[GBP,3] = 0.00
w[CHF,3] = 0.00
w[CAD,3] = 0.00
w[AUD,3] = 0.00
w[HKD,3] = 0.00

display_graph(m)

100 USD -> 11861 JPY -> 126.9127 CAD -> 100.45140205 USD

4.10. Forex Arbitrage 271


Hands-On Mathematical Optimization with AMPL in Python

4.10. Forex Arbitrage 272


CHAPTER

FIVE

5. CONVEX OPTIMIZATION

Our aim so far has been to formulate a real-life decision problem as a (mixed-integer) linear optimization problem. The
reason was clear: linear functions are simple, so problems formulated with them should also be simple to solve. However,
the world of linear problems is sometimes not flexible enough to model well all real-life problems. In fact, numerous
optimization problems come naturally in nonlinear form and that this might lead to situations in which determining the
optimal solution is difficult.
Luckily, it will turn out that the real separation between simple and difficult problems is not whether they are linear vs.
nonlinear, but instead, whether they are convex or not. Convexity is a very desirable property of an optimization problem
that makes the optimization problem solvable to optimality in a reasonable time. For that reason, it plays a central role in
mathematical optimization.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of convex optimization:
• Milk pooling and blending
• Ordinary Least Squares (OLS) regression
• Markowitz portfolio optimization problem
• Support Vector Machines for binary classification
• Extra material: Refinery production
• Extra material: Cutting stock
Go to the next chapter about conic optimization.

5.1 Milk pooling and blending

Pooling and blending operations involve the “pooling” of various streams to create intermediate mixtures that are subse-
quently blended with other streams to meet final product specifications. These operations are common to the chemical
processing and petroleum sectors where limited tankage may be available, or when it is necessary to transport materials by
train, truck, or pipeline to remote blending terminals. Similar applications arise in agriculture, food, mining, wastewater
treatment, and other industries.
This notebook considers a simple example of a wholesale milk distributor to show how non-convexity arises in the
optimization of pooling and blending operations. Non-convexity is due to presence of bilinear terms that are the product
of two decision variables where one is a scale-dependent extensive quantity measuring the amount or flow of a product,
and the other is scale-independent intensive quantity such as product composition. The notebook then shows how to
develop and solve a convex approximation of the problem, and finally demonstrates solution the use of Couenne, a solver
specifically designed to find global solutions to mixed integer nonlinear optimization (MINLO) problems.

273
Hands-On Mathematical Optimization with AMPL in Python

# install dependencies and select solver


%pip install -q amplpy pandas matplotlib numpy scipy

SOLVER_LO = "cbc"
SOLVER_GNLO = "couenne"

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
modules=["coin"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics

Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from itertools import product

5.1.1 Problem: Pooling milk for wholesale blending and distribution

A bulk distributor supplies custom blends of milk to several customers. Each customer has specified a minimum fat
content, a maximum price, and a maximum amount for the milk they wish to buy. The distributor sources raw milk from
local farms. Each farm produces a milk with a known fat content and cost.
The distributor has recently identified more affordable sources raw milk from several distant farms. These distant farms
produce milk grades that can be blend with milk from the local farms. However, the distributor only has one truck with
a single tank available for transporting milk from the distant farms. As a result, milk from the distant farms must be
combined in the tank before being transported to the blending station. This creates a “pool” of uniform composition
which to be blended with local milk to meet customer requirements.
The process is shown in the following diagram. The fat content and cost of raw milk is given for each farm. For each
customer, data is given for the required milk fat content, price, and the maximum demand. The arrows indicate pooling
and blending of raw milk supplies. Each arrow is labeled with the an amount of raw milk.

5.1. Milk pooling and blending 274


Hands-On Mathematical Optimization with AMPL in Python

What should the distributor do?


• Option 1. Do nothing. Continue operating the business as usual with local suppliers.
• Option 2. Buy a second truck to transport raw milk from the remote farms to the blending facility without pooling.
• Option 3. Pool raw milk from the remote farms into a single truck for transport to the blending facility.
customers = pd.DataFrame(
{
"Customer 1": {"min_fat": 0.045, "price": 52.0, "demand": 6000.0},
"Customer 2": {"min_fat": 0.030, "price": 48.0, "demand": 2500.0},
"Customer 3": {"min_fat": 0.040, "price": 50.0, "demand": 4000.0},
}
).T

suppliers = pd.DataFrame(
{
"Farm A": {"fat": 0.045, "cost": 45.0, "location": "local"},
"Farm B": {"fat": 0.030, "cost": 42.0, "location": "local"},
"Farm C": {"fat": 0.033, "cost": 37.0, "location": "remote"},
"Farm D": {"fat": 0.050, "cost": 45.0, "location": "remote"},
},
).T

local_suppliers = suppliers[suppliers["location"] == "local"]


remote_suppliers = suppliers[suppliers["location"] == "remote"]

print("\nCustomers")
display(customers)

print("\nSuppliers")
display(suppliers)

Customers

min_fat price demand


Customer 1 0.045 52.0 6000.0
(continues on next page)

5.1. Milk pooling and blending 275


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Customer 2 0.030 48.0 2500.0
Customer 3 0.040 50.0 4000.0

Suppliers

fat cost location


Farm A 0.045 45.0 local
Farm B 0.03 42.0 local
Farm C 0.033 37.0 remote
Farm D 0.05 45.0 remote

5.1.2 Option 1. Business as usual

The normal business of the milk distributor is to blend supplies from local farms to meet customer requirements. Let 𝐿
designate the set of local suppliers, and let 𝐶 designate the set of customers. Decision variable 𝑧𝑙,𝑐 is the amount of milk
from local supplier 𝑙 ∈ 𝐿 that is mixed into the blend sold to customer 𝑐 ∈ 𝐶.
The distributor’s objectives is to maximize profit

profit = ∑ (price𝑐 − cost𝑙 )𝑧𝑙,𝑐


(𝑙,𝑐) ∈ 𝐿×𝐶

where (𝑙, 𝑐) ∈ 𝐿 × 𝐶 indicates a summation over the cross-product of two sets. Each term, (price𝑐 − cost𝑙 ), is the net
profit of including one unit of raw milk from supplier 𝑙 ∈ 𝐿 in the blend delivered to customer 𝑐 ∈ 𝐶.
The amount of milk delivered to each customer 𝑐 ∈ 𝐶 can not exceed the customer demand.
∑ 𝑧𝑙,𝑐 ≤ demand𝑐 ∀𝑐 ∈ 𝐶
𝑙∈𝐿

Let fat𝑙 denote the fat content of the raw milke produced by farm 𝑙, and let

model = AMPL()
model.eval(
"""
# define sources and customers
set L;
set C;

param price{C};
param demand{C};
param min_fat{C};
param cost{L};
param fat{L};

# define local -> customer flowrates


var z{L cross C} >= 0;

maximize profit: sum{l in L, c in C} z[l, c]*(price[c] - cost[l]);

subject to demand_req{c in C}:


sum{l in L} z[l, c] <= demand[c];

subject to fat_content{c in C}:


sum{l in L} z[l, c] * fat[l] >= sum{l in L} z[l, c] * min_fat[c];
(continues on next page)

5.1. Milk pooling and blending 276


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


"""
)

model.set_data(customers, "C")
model.set_data(local_suppliers.drop(columns=["location"]), "L")

model.option["solver"] = SOLVER_LO
model.get_output("solve;")

# report results
print(f"\nProfit = {model.obj['profit'].value():0.2f}\n")

# create dataframe of results


z = model.var["z"]
L = model.set["L"]
C = model.set["C"]

print("Blending Plan")
Z = pd.DataFrame(
[[l, c, round(z[l, c].value(), 1)] for l in L.members() for c in C.members()],
columns=["supplier", "customer", ""],
)
Z = Z.pivot_table(index="customer", columns="supplier")
Z["Total"] = Z.sum(axis=1)
Z["fat content"] = [
sum(z[l, c].value() * suppliers.loc[l, "fat"] for l in L.members())
/ sum(z[l, c].value() for l in L.members())
for c in C.members()
]
Z["min_fat"] = customers["min_fat"]

display(Z)

Profit = 81000.00

Blending Plan

Total fat content min_fat


supplier Farm A Farm B
customer
Customer 1 6000.0 0.0 6000.0 0.045 0.045
Customer 2 0.0 2500.0 2500.0 0.030 0.030
Customer 3 2666.7 1333.3 4000.0 0.040 0.040

5.1. Milk pooling and blending 277


Hands-On Mathematical Optimization with AMPL in Python

5.1.3 Option 2. Buy an additional truck

The distributor can earn a profit of 81,000 using only local suppliers. Is is possible earn a higher profit by also sourcing
raw milk from the remote suppliers?
Before considering pooling,the distributor may wish to know the maximum profit possible if raw milk from the remote
suppliers could be blended just like local suppliers. This would require acquiring and operating a separate transport truck
for each remote supplier,and is worth knowing if the additional profit would justify the additional expense.
The linear optimization model presented Option 1 extends the to include both local and remote suppliers.

model = AMPL()
model.eval(
"""
# define sources and customers
set S;
set C;

param price{C};
param demand{C};
param min_fat{C};
param cost{S};
param fat{S};

# define local -> customer flowrates


var z{S cross C} >= 0;

maximize profit: sum{s in S, c in C} z[s, c]*(price[c] - cost[s]);

subject to demand_req{c in C}:


sum{s in S} z[s, c] <= demand[c];

subject to quality{c in C}:


sum{s in S} z[s, c] * fat[s] >= sum{s in S} z[s, c] * min_fat[c];
"""
)

model.set_data(customers, "C")
model.set_data(suppliers.drop(columns=["location"]), "S")

model.option["solver"] = SOLVER_LO
model.get_output("solve;")

# report results
print(f"\nProfit = {model.obj['profit'].value():0.2f}\n")

# create dataframe of results


z = model.var["z"]
S = model.set["S"]
C = model.set["C"]

print("Blending Plan")
Z = pd.DataFrame(
[[s, c, round(z[s, c].value(), 1)] for s in S.members() for c in C.members()],
columns=["supplier", "customer", ""],
)
Z = Z.pivot_table(index="customer", columns="supplier")
Z["Total"] = Z.sum(axis=1)
(continues on next page)

5.1. Milk pooling and blending 278


Hands-On Mathematical Optimization with AMPL in Python

(continued from previous page)


Z["fat content"] = [
sum(z[s, c].value() * suppliers.loc[s, "fat"] for s in S.members())
/ sum(z[s, c].value() for s in S.members())
for c in C.members()
]
Z["min_fat"] = customers["min_fat"]

display(Z)

Profit = 122441.18

Blending Plan

Total fat content min_fat


supplier Farm A Farm B Farm C Farm D
customer
Customer 1 0.0 0.0 1764.7 4235.3 6000.0 0.045 0.045
Customer 2 0.0 0.0 2500.0 0.0 2500.0 0.033 0.030
Customer 3 0.0 0.0 2352.9 1647.1 4000.0 0.040 0.040

Sourcing raw milk from the remote farms significantly increases profits. This blending,however,requires at least two trucks
to keep the sources of milk from the remote suppliers separated until they reach the blending facility. Note that the local
suppliers are completely replaced by the lower cost remote suppliers,even to the extent of providing “product giveaway”
by surpassing the minimum requirements of Customer 2.

5.1.4 Option 3. Pool delivery from remote suppliers

Comparing Option 1 with Option 2 shows there is significantly more profit to be earned by purchasing raw milk from the
remote suppliers. But that option requires an additional truck to keep the supplies separated during transport.
Because only one truck with a single tank is available for transport from the remote farms,the pool and blending problem
is to combine purchases from the remote suppliers into a single pool of uniform composition,transport that pool to the
distribution facility,then blend with raw milk from local suppliers to meet individual customer requirements. Compared
to option 2,the profit potential may be reduced due to pooling,but without the need to acquire an additional truck.

Pooling problem

There are a several mathematical formulations of pooling problem in the academic literature. The formulation used here
is called the “p-parameterization” where the pool composition represents a new decision variable 𝑝. The other additional
decision variables are 𝑥𝑟 referring to the amount of raw milk purchased from remote supplier 𝑟 ∈ 𝑅,and 𝑦𝑐 which is the
amount of the pooled milk included in the blend delivered to customer 𝑐 ∈ 𝐶.
The profit objective is the difference between the income received for selling blended products and the cost of purchasing
raw milk from local and remote suppliers.

Profit = ∑ (price𝑐 − cost𝑙 ) 𝑧𝑙,𝑐 + ∑ price𝑐 𝑦𝑐 − ∑ cost𝑟 𝑥𝑟


(𝑙,𝑐) ∈ 𝐿×𝐶 𝑐∈𝐶 𝑟∈𝑅

5.1. Milk pooling and blending 279

You might also like