Skip to content

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

Permutation(name, record)

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
def __init__(self, name, record):
    """Wrap the declaration ``record`` of the permutation ``name``; use
    :func:`permutation` to declare one."""
    self._name = name
    self._record = record

name property

name

The name the permutation was declared under.

n property

n

How many items, and so how many slots.

item_at

item_at(slot)

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 slot is not a whole number.

ValueError

If slot is outside 0..n - 1.

Source code in src/quicopt/structured.py
def item_at(self, slot):
    """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:
        TypeError: If ``slot`` is not a whole number.
        ValueError: If ``slot`` is outside ``0..n - 1``.
    """
    return _Part("item_at", self._name, self._position(slot, "slot", "item_at"))

slot_of

slot_of(item)

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 item is not a whole number.

ValueError

If item is outside 0..n - 1.

Source code in src/quicopt/structured.py
def slot_of(self, item):
    """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:
        TypeError: If ``item`` is not a whole number.
        ValueError: If ``item`` is outside ``0..n - 1``.
    """
    return _Part("slot_of", self._name, self._position(item, "item", "slot_of"))

precede

precede(before, after)

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 0..n - 1, the two are the same item, or the requirement contradicts the ones already given.

Source code in src/quicopt/structured.py
def precede(self, before, after):
    """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:
        TypeError: If an item is not a whole number.
        ValueError: If an item is outside ``0..n - 1``, the two are the same
            item, or the requirement contradicts the ones already given.
    """
    a = self._position(before, "item", "precede")
    b = self._position(after, "item", "precede")
    if a == b:
        raise ValueError("an item cannot precede itself")
    edges = self._record.precede + [(a, b)]
    if _has_cycle(edges, self.n):
        raise ValueError(f"requiring {a} before {b} contradicts the requirements already "
                         f"given for '{self._name}'")
    self._record.precede.append((a, b))
    return self

Partition

Partition(name, record)

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
def __init__(self, name, record):
    """Wrap the declaration ``record`` of the partition ``name``; use
    :func:`partition` to declare one."""
    self._name = name
    self._record = record

name property

name

The name the partition was declared under.

n property

n

How many items.

parts property

parts

How many parts.

part_of

part_of(item)

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 item is not a whole number.

ValueError

If item is outside 0..n - 1.

Source code in src/quicopt/structured.py
def part_of(self, item):
    """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:
        TypeError: If ``item`` is not a whole number.
        ValueError: If ``item`` is outside ``0..n - 1``.
    """
    return _Part("part_of", self._name, _position(self, item, self.n, "item", "part_of"))

size_of

size_of(part)

How many items part part holds, an expression whose value the service chooses, from 0 to n.

Raises:

Type Description
TypeError

If part is not a whole number.

ValueError

If part is outside 0..parts - 1.

Source code in src/quicopt/structured.py
def size_of(self, part):
    """How many items part ``part`` holds, an expression whose value the
    service chooses, from 0 to ``n``.

    Raises:
        TypeError: If ``part`` is not a whole number.
        ValueError: If ``part`` is outside ``0..parts - 1``.
    """
    return _Part("size_of", self._name, _position(self, part, self.parts, "part", "size_of"))

LookupTable

LookupTable(name, values)

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
def __init__(self, name, values):
    """Wrap the stored entries ``values`` of the table ``name``; use
    :func:`lookup_table` to declare one."""
    self._name = name
    self._values = values

name property

name

The name the table was declared under.

shape property

shape

The table's extent: (a,) for one dimension, (a, b) for two.

maximum

maximum(*args)

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
def maximum(*args):
    """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:
        TypeError: If an argument is neither a Pyomo expression nor a number.
        ValueError: If given fewer than two arguments.
    """
    return _ne.MaxExpression([_operand("maximum", a) for a in _at_least_two("maximum", args)])

minimum

minimum(*args)

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
def minimum(*args):
    """The smallest of two or more expressions.

    The counterpart of :func:`maximum` — ``minimum(demand, stock)`` is what you
    actually sell.

    Raises:
        TypeError: If an argument is neither a Pyomo expression nor a number.
        ValueError: If given fewer than two arguments.
    """
    return _ne.MinExpression([_operand("minimum", a) for a in _at_least_two("minimum", args)])

permutation

permutation(model, name, n, start=None)

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 model is not a Pyomo model (a PuLP or MathOpt model, or a block inside a Pyomo model, is refused), name is not a string or n not a whole number.

ValueError

If n is below 2, name is already declared on the model, or start is not an arrangement of 0..n - 1.

Source code in src/quicopt/structured.py
def permutation(model, name, n, start=None):
    """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:
        TypeError: If ``model`` is not a Pyomo model (a PuLP or MathOpt model, or
            a block inside a Pyomo model, is refused), ``name`` is not a string or
            ``n`` not a whole number.
        ValueError: If ``n`` is below 2, ``name`` is already declared on the model,
            or ``start`` is not an arrangement of ``0..n - 1``.
    """
    if not _is_whole(n):
        raise TypeError(f"a permutation has a whole number of items, got {n!r}")
    if n < 2:
        raise ValueError(f"a permutation has at least 2 items, got {n}")
    n = int(n)
    start = [] if start is None else list(start)
    if start and not (all(_is_whole(s) for s in start) and sorted(start) == list(range(n))):
        raise ValueError(f"'{name}': start must hold each of 0 to {n - 1} once "
                         "(start[i] is the slot item i starts in)")
    record = _declare(model, name)
    perm = _PermutationRecord(n, [int(s) for s in start])
    record.permutations[name] = perm
    return Permutation(name, perm)

partition

partition(model, name, n, parts, start=None)

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 model is not a Pyomo model, name is not a string, or n or parts is not a whole number.

ValueError

If n is below 1 or parts below 2, name is already declared on the model, or start does not give each item a part.

Source code in src/quicopt/structured.py
def partition(model, name, n, parts, start=None):
    """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:
        TypeError: If ``model`` is not a Pyomo model, ``name`` is not a string, or
            ``n`` or ``parts`` is not a whole number.
        ValueError: If ``n`` is below 1 or ``parts`` below 2, ``name`` is already
            declared on the model, or ``start`` does not give each item a part.
    """
    for (what, k, least) in (("items", n, 1), ("parts", parts, 2)):
        if not _is_whole(k):
            raise TypeError(f"a partition has a whole number of {what}, got {k!r}")
        if k < least:
            raise ValueError(f"a partition has at least {least} {what[:-1] if least == 1 else what}, got {k}")
    n, parts = int(n), int(parts)
    start = [] if start is None else list(start)
    if start and not (len(start) == n and all(_is_whole(b) and 0 <= b < parts for b in start)):
        raise ValueError(f"'{name}': start gives each of the {n} items a part from 0 to "
                         f"{parts - 1} (start[i] is the part item i starts in)")
    record = _declare(model, name)
    decl = _PartitionRecord(n, parts, [int(b) for b in start])
    record.partitions[name] = decl
    return Partition(name, decl)

lookup_table

lookup_table(model, name, values)

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 model is not a Pyomo model (a PuLP or MathOpt model, or a block inside a Pyomo model, is refused), or values is not one of the shapes above.

ValueError

If it is empty, ragged or holds a NaN, or name is already declared.

Source code in src/quicopt/structured.py
def lookup_table(model, name, values):
    """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:
        TypeError: If ``model`` is not a Pyomo model (a PuLP or MathOpt model, or
            a block inside a Pyomo model, is refused), or ``values`` is not one of
            the shapes above.
        ValueError: If it is empty, ragged or holds a NaN, or ``name`` is already
            declared.
    """
    table = _as_table(name, values)
    record = _declare(model, name)
    record.tables[name] = table
    return LookupTable(name, table)

semicontinuous

semicontinuous(var)

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 var is not a Pyomo variable, or is integer.

Source code in src/quicopt/structured.py
def semicontinuous(var):
    """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:
        TypeError: If ``var`` is not a Pyomo variable, or is integer.
    """
    _mark_holes(var, integer=False, caller="semicontinuous")

semiinteger

semiinteger(var)

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 var is not a Pyomo variable, or is not integer, or is binary.

Source code in src/quicopt/structured.py
def semiinteger(var):
    """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:
        TypeError: If ``var`` is not a Pyomo variable, or is not integer, or is
            binary.
    """
    _mark_holes(var, integer=True, caller="semiinteger")

active

active(var)

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 var is not a single Pyomo variable.

Source code in src/quicopt/structured.py
def active(var):
    """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:
        TypeError: If ``var`` is not a single Pyomo variable.
    """
    if not _is_pyomo_var(var) or var.is_indexed():
        raise TypeError(f"active: expected a single Pyomo variable such as m.x or m.ship[1], "
                        f"got {var!r}")
    return _Active([var])

block

block(model, name, vars)

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 model is not a Pyomo model, name not a string, or a member not a Pyomo variable.

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.

Source code in src/quicopt/structured.py
def block(model, name, vars):
    """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:
        TypeError: If ``model`` is not a Pyomo model, ``name`` not a string, or a
            member not a Pyomo variable.
        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.
    """
    record = _record(model)
    _check_name(name)
    if name in record.blocks:
        raise ValueError(f"block '{name}' is already declared on this model")
    members = [v for x in vars for v in _members(x, "block")]
    if not members:
        raise ValueError(f"block '{name}' has no variables")
    owner = {id(v): other for (other, vs) in record.blocks.items() for v in vs}
    seen = set()
    for v in members:
        if v.model() is not model:
            raise ValueError(f"block '{name}': '{v.name}' belongs to another model")
        if id(v) in seen:
            raise ValueError(f"block '{name}' lists '{v.name}' twice")
        if id(v) in owner:
            raise ValueError(f"'{v.name}' is already in block '{owner[id(v)]}'; a variable "
                             "belongs to at most one block")
        seen.add(id(v))
    record.blocks[name] = members