quicopt.structured¶
structured ¶
quicopt.structured — orders, groups, minimum loads and fees, written in Pyomo.
Some decisions are an order: the order in which a courier visits its stops, the
order of jobs on a machine, which department moves into which office. Each is a
permutation: n things, called items, placed in n numbered places,
called slots, one item per slot. For a route the items are the stops and the
slots are the visits, so slot 0 holds the first stop visited.
permutation declares one on a Pyomo model.
Its item_at is the item in a slot and
its slot_of the slot of an item; both
are expressions whose values the service chooses, like decision variables. Items
and slots count from 0, like the positions of a Python list.
Data that depends on the arrangement, such as the distance between consecutive
stops, is read from a lookup_table: indexing
it with expressions gives the entry at the positions the service chooses.
Both are declared on a Pyomo model. PuLP and OR-Tools MathOpt models hold only linear and quadratic terms, which cannot express either.
Example
Five stops along a road. Find the shortest route through all of them that visits stop 3 before stop 0:
import pyomo.environ as pyo
from quicopt import Client
from quicopt.structured import permutation, lookup_table
position = [0, 3, 1, 4, 2] # km along the road, stops 0 to 4
m = pyo.ConcreteModel()
dist = lookup_table(m, "dist", [[abs(a - b) for b in position] for a in position])
route = permutation(m, "route", 5)
route.precede(3, 0) # stop 3 is visited before stop 0
m.length = pyo.Objective(
expr=sum(dist[route.item_at(k), route.item_at(k + 1)] for k in range(4)),
sense=pyo.minimize)
result = Client().solve(m)
print(result.objective) # 4.0
print(result.structures["route"].item_at) # [3, 1, 4, 2, 0]
The route drives from one end of the road to the other. Starting at the far end is what visits stop 3 before stop 0.
A model with a permutation or a lookup table is solved by a search, so its
result has the status heuristic. Permutations combine with random
variables (quicopt.stochastic), for example a route whose travel times are
uncertain, minimized in expectation.
Minimum loads, fees and blocks:
A delivery is often either nothing or at least a minimum load: a truck does
not go out for two units. semiinteger
marks an integer variable so that it is 0 or a whole number within its
bounds, and nothing in between;
semicontinuous does the same for a
continuous one. active is 1 when such a
variable is not 0, so 5 * active(m.ship[w, c]) charges 5 for every
delivery made.
A fee that depends on a total, such as a warehouse's opening fee due as soon
as it delivers anything, is Pyomo's own ``Expr_if`` over a comparison:
``pyo.Expr_if(IF=total >= 1, THEN=60, ELSE=0)``. Any of ``<=``, ``<``,
``>=``, ``>`` and ``==`` works; [`minimum`][quicopt.structured.minimum] and
[`maximum`][quicopt.structured.maximum] cap or floor an expression in the
same spirit.
A [`block`][quicopt.structured.block] names the variables of one subproblem,
such as one customer's deliveries. Every solution the service returns
satisfies the constraints that involve only one block's variables, such as
that customer's demand.
Three warehouses supply three customers. Each delivery is none or 10 to 25
units and costs 5 plus a price per unit; a warehouse that delivers anything
pays its opening fee:
```python
import pyomo.environ as pyo
from quicopt import Client
from quicopt.structured import semiinteger, active, block
demand = [40, 30, 20] # units, customers 0 to 2
price = [[1, 3, 4], [3, 1, 3], [4, 2, 1]] # per unit, price[warehouse][customer]
opening = [60, 50, 40] # fee of warehouses 0 to 2
m = pyo.ConcreteModel()
m.ship = pyo.Var(range(3), range(3), within=pyo.NonNegativeIntegers, bounds=(10, 25))
semiinteger(m.ship) # every delivery: none, or 10 to 25 units
for c in range(3):
block(m, f"customer_{c}", [m.ship[w, c] for w in range(3)])
m.served = pyo.Constraint(range(3), rule=lambda m, c:
sum(m.ship[w, c] for w in range(3)) >= demand[c])
m.cost = pyo.Objective(expr=
sum(price[w][c] * m.ship[w, c] for w in range(3) for c in range(3))
+ sum(5 * active(m.ship[w, c]) for w in range(3) for c in range(3))
+ sum(pyo.Expr_if(IF=sum(m.ship[w, c] for c in range(3)) >= 1,
THEN=opening[w], ELSE=0) for w in range(3)))
result = Client().solve(m)
print(result.objective) # 300.0
# the deliveries ship[0, 0], ship[0, 1], …, ship[2, 2] are x1 to x9 in the solution
print([result.solution[f"x{k}"] for k in range(1, 10)])
# [25.0, 10.0, 0.0, 0.0, 0.0, 0.0, 15.0, 20.0, 20.0]
```
Warehouse 1 stays closed. Warehouse 0 sends its full 25 units to customer 0
and the minimum of 10 to customer 1; warehouse 2 covers the rest. Without
the minimum load, the cheapest plan would cost 295 and send customer 1 a
delivery of only 5 units from warehouse 0.
Splitting items into groups
partition declares a split of items into
parts as a decision: people into teams, jobs onto machines. Its
part_of is the part an item is in
and its size_of how many items a
part holds. Items and parts count from 0.
Six players of strengths 7, 5, 4, 3, 2 and 1 form a team of four and a team of two, as evenly matched as possible:
import pyomo.environ as pyo
from quicopt import Client
from quicopt.structured import partition, maximum
strength = [7, 5, 4, 3, 2, 1] # of players 0 to 5
m = pyo.ConcreteModel()
teams = partition(m, "teams", 6, 2) # 6 players into teams 0 and 1
total = [sum(pyo.Expr_if(IF=teams.part_of(p) == t, THEN=strength[p], ELSE=0)
for p in range(6)) for t in range(2)]
m.four = pyo.Constraint(expr=teams.size_of(0) == 4)
m.stronger = pyo.Objective(expr=maximum(total[0], total[1]))
result = Client().solve(m)
print(result.objective) # 11.0
print(result.structures["teams"].part_of) # [1, 0, 1, 0, 0, 0]
Players 0 and 2 (7 + 4) make up the team of two, and the other four also add up to 11.
Permutation ¶
A permutation declared by :func:permutation: n items in n slots,
both counted from 0.
Wrap the declaration record of the permutation name; use
:func:permutation to declare one.
Source code in src/quicopt/structured.py
item_at ¶
The item in slot slot, an expression whose value the service chooses.
For a route whose items are stops, route.item_at(0) is the first stop
visited. Index a :class:LookupTable with it to read data about that item.
slot is a whole number from 0 to n - 1.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Source code in src/quicopt/structured.py
slot_of ¶
The slot item item is placed in, an expression whose value the
service chooses.
For a route whose items are stops, route.slot_of(2) is when stop 2 is
visited.
item is a whole number from 0 to n - 1.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Source code in src/quicopt/structured.py
precede ¶
Require item before to be in an earlier slot than item after.
A pickup before its delivery, or a job before the job that needs its output. The service only considers arrangements that meet every such requirement, so every solution meets it. Returns the permutation, so calls can be chained.
Raises:
| Type | Description |
|---|---|
TypeError
|
If an item is not a whole number. |
ValueError
|
If an item is outside |
Source code in src/quicopt/structured.py
Partition ¶
A partition declared by :func:partition: n items in parts parts,
both counted from 0.
Wrap the declaration record of the partition name; use
:func:partition to declare one.
Source code in src/quicopt/structured.py
part_of ¶
The part item item is in, an expression whose value the service
chooses, from 0 to parts - 1.
For people split into teams, teams.part_of(2) is the team of person 2.
Index a :class:LookupTable with it to read data about that part.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Source code in src/quicopt/structured.py
size_of ¶
How many items part part holds, an expression whose value the
service chooses, from 0 to n.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Source code in src/quicopt/structured.py
LookupTable ¶
A table of data declared by :func:lookup_table, to be indexed with
expressions: table[i] for a table of one dimension, table[i, j] for
two, positions counted from 0.
Wrap the stored entries values of the table name; use
:func:lookup_table to declare one.
Source code in src/quicopt/structured.py
maximum ¶
The largest of two or more expressions.
This is how recourse is priced: maximum(demand - stock, 0) is the
shortfall, and zero whenever stock covers demand. Python's built-in max
cannot do this — it would have to compare the arguments, and their values are
not known yet.
Raises:
| Type | Description |
|---|---|
TypeError
|
If an argument is neither a Pyomo expression nor a number. |
ValueError
|
If given fewer than two arguments. |
Source code in src/quicopt/stochastic.py
minimum ¶
The smallest of two or more expressions.
The counterpart of :func:maximum — minimum(demand, stock) is what you
actually sell.
Raises:
| Type | Description |
|---|---|
TypeError
|
If an argument is neither a Pyomo expression nor a number. |
ValueError
|
If given fewer than two arguments. |
Source code in src/quicopt/stochastic.py
permutation ¶
Declare an order as a decision: a permutation of n items over n
slots on the Pyomo model model.
name identifies the permutation in the result, as
result.structures[name]. n is at least 2.
start is the arrangement the service's search starts from: start[i] is
the slot of item i, so it holds each of 0..n - 1 once. Left out, item
i starts in slot i.
Returns the :class:Permutation, to read with item_at and slot_of and
to constrain with precede.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Source code in src/quicopt/structured.py
partition ¶
Declare a split of n items into parts parts as a decision, on the
Pyomo model model: every item goes into exactly one part, and a part may
stay empty.
People into teams, jobs onto machines, products into warehouses: in each, the
model reads the part an item is in through :meth:Partition.part_of and how
many items a part holds through :meth:Partition.size_of. Items and parts
count from 0.
name identifies the partition in the result, as
result.structures[name], a :class:~quicopt.client.Grouping. n is at
least 1 and parts at least 2. start is the split the service's search
starts from: start[i] is the part of item i. Left out, the service
deals the items out over the parts in turn.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Source code in src/quicopt/structured.py
lookup_table ¶
Declare a table of data that can be indexed by decisions.
A model sometimes needs a number from a table at a position that is not known yet: the distance between the first and the second stop of a route whose order the service is still choosing, or the price of an option an integer variable picks. A Python list cannot be indexed this way; the table returned here can.
values is a sequence of numbers (a table of one dimension) or a sequence of
equally long sequences of numbers (two dimensions), such as a list of lists or
a NumPy array. name must not already be declared on the model.
The whole table is sent with the model, so a very large table makes the request large.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If it is empty, ragged or holds a NaN, or |
Source code in src/quicopt/structured.py
semicontinuous ¶
Let a continuous Pyomo variable be 0 as well as any value within its bounds, and nothing in between: a delivery of nothing, or of at least a minimum load.
var is a variable (m.x) or an indexed one (m.ship, every element).
Its bounds are the range it takes when it is not 0; both must be finite, and
when 0 already lies within them nothing changes. Use :func:semiinteger for an
integer variable, and :func:active to charge a fee whenever the variable is
not 0.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
Source code in src/quicopt/structured.py
semiinteger ¶
Let an integer Pyomo variable be 0 as well as any whole number within its bounds, and nothing in between: a batch of none, or of 10 to 25 units.
var is a variable (m.x) or an indexed one (m.ship, every element),
declared with within=pyo.Integers (or NonNegativeIntegers, …). Its
bounds are the range it takes when it is not 0; both must be finite. See
:func:semicontinuous for a continuous variable.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
Source code in src/quicopt/structured.py
active ¶
Whether the variable var is in use: an expression that is 1 when
var is not 0, and 0 when it is.
A fee due whenever a delivery is made is fee * active(m.ship[w, c]). On a
variable that is 0 or within its bounds (:func:semicontinuous,
:func:semiinteger), such a fee keeps a linear model linear, so the service
solves it exactly.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
Source code in src/quicopt/structured.py
block ¶
Declare a block: the variables of one subproblem of the model, such as one customer's supply or one project's staffing.
The constraints over a block's own variables alone, such as that customer's demand, hold in every solution the service returns. A block changes nothing else about the model; it tells the service which variables belong together.
vars is a list of variables, indexed ones included (every element).
name must be new among the model's blocks. A variable belongs to at most
one block, and a block's variables need finite bounds.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If the block is empty, its name is taken, a variable is listed twice or is in another block, or a variable belongs to another model. |