Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ However, it also depends on the "cobraOps" Python package, which currently
has to be installed manually; see https://github.com/Subaru-PFS/ics_cobraOps/
for details.

The package allows to choose between the PULP package and the commercial
(but free for academic use) Gurobi package for solving the network flow
problem. One of those two needs to be installed and the appropriate flag needs
to be set when calling the network solving routine `observeWithNetflow()`.
The package allows to choose between the PULP package, the HiGHS package
(installed as `highspy`) and the commercial (but free for academic use) Gurobi
package for solving the network flow problem. One of those three needs to be
installed and the appropriate flag needs to be set when calling the network
solving routine `observeWithNetflow()`.

### Package installation

Expand Down
215 changes: 211 additions & 4 deletions ets_fiber_assigner/netflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ def buildProblem(bench, targets, tpos, classdict, tvisit, vis_cost=None,
cobraFeatureFlags=None,
brokenCobrasMargin=0.,
targetCostOffset=None,
avoidFiducials=True):
avoidFiducials=True,
solver=None, solverOptions=None):
"""Build the ILP problem for a given observation task

Parameters
Expand Down Expand Up @@ -316,9 +317,11 @@ def buildProblem(bench, targets, tpos, classdict, tvisit, vis_cost=None,
if True, avoid elbow collisions in the endpoint configuration
(increases the number of constraints, especially for long target lists)
gurobi : bool
if True, use the Gurobi optimizer, otherwise use PuLP
if True, use the Gurobi optimizer, otherwise use PuLP.
Ignored when `solver` is given.
gurobiOptions : dict(string : <param>)
optional additional parameters for the Gurobi solver
optional additional parameters for the Gurobi solver.
Ignored when `solver` is given; pass `solverOptions` instead.
alreadyObserved : None or dict{string: float}
if not None, this is a dictionary containing IDs of science targets
and the time in seconds they have already been observed
Expand Down Expand Up @@ -399,6 +402,13 @@ def buildProblem(bench, targets, tpos, classdict, tvisit, vis_cost=None,

if cobraFeatureFlags is `None`, it will be assumed that all Cobras
have a flag value of 0, i.e. that all features are supported.
solver : None or string ("gurobi", "pulp", "highs")
which backend to build the problem with.
if `None`, the `gurobi` flag selects between Gurobi and PuLP as
before, so existing callers are unaffected.
solverOptions : None or dict(string : <param>)
options for the chosen backend, in that backend's own parameter
names. Only used when `solver` is given.

brokenCobrasMargin: float
defines the radius around broken Cobras, in which potential
Expand Down Expand Up @@ -435,7 +445,18 @@ def buildProblem(bench, targets, tpos, classdict, tvisit, vis_cost=None,
STC_o = defaultdict(list) # Science Target outflows
timebudgets = {}

if gurobi:
if solver is not None:
if solver == "gurobi":
prob = GurobiProblem(extraOptions=solverOptions)
elif solver == "pulp":
prob = PulpProblem()
elif solver == "highs":
prob = HighsProblem(extraOptions=solverOptions)
else:
raise ValueError(
f"Unknown solver {solver!r}; expected 'gurobi', 'pulp' or 'highs'"
)
elif gurobi:
prob = GurobiProblem(extraOptions=gurobiOptions)
else:
prob = PulpProblem()
Expand Down Expand Up @@ -746,6 +767,192 @@ def buildProblem(bench, targets, tpos, classdict, tvisit, vis_cost=None,
return prob


class HighsProblem(LPProblem):
"""HiGHS backend (https://highs.dev), reached through the highspy package.

An open-source alternative to Gurobi for the netflow MILP, benchmarked on
22 real target lists: it finished the same 20 of them Gurobi did, at 1.08x
the total runtime, with pointing counts agreeing to within the spread a
single solver shows across repeated runs of the same input.
"""

def __init__(self, name="problem", extraOptions=None):
# `name` is accepted so the backends are interchangeable, but highspy
# exposes no model-name API (only passColName/passRowName), so there
# is nothing to set it on. PulpProblem ignores it as well.
LPProblem.__init__(self)
import highspy
self._highs = highspy
self._prob = highspy.Highs()
self._prob.setOptionValue("output_flag", False)
if extraOptions is not None:
for key, value in extraOptions.items():
self._prob.setOptionValue(key, value)

self._ncols = 0
self._pending = [] # (name, lb, ub, is_integer) awaiting _flush()
self._bounds = {} # column index -> (lb, ub), for varBounds()
self._colvals = None # cached solution vector, see value()

# A free continuous variable the caller accumulates the objective onto
# with `prob.cost += ...`, so by the time solve() sees it, cost is a
# linear expression. Same shape as the other backends.
self.cost = self._newCol("cost", 0.0, highspy.kHighsInf, False)
# qsum is a Highs method rather than a module-level function.
self.sum = self._prob.qsum

def _newCol(self, name, lb, ub, is_integer):
"""Reserve a column index and hand back a handle for it immediately.

The column itself is not created until _flush(); see there for why.
"""
var = self._highs.highs_var(self._ncols, self._prob)
self._pending.append((name, lb, ub, is_integer))
# float() so varBounds() reports the same type the other backends do,
# whatever the caller passed in.
self._bounds[self._ncols] = (float(lb), float(ub))
self._ncols += 1
return var

def _flush(self):
"""Create every reserved column in one call. Safe to call at any time.

Adding columns one at a time through highspy costs about 50 us each,
which is minutes of overhead on the million-variable problems this
module builds; addCols takes the whole batch at once and measures
roughly 180x faster. Since buildProblem() creates all of its variables
before its first constraint, one deferred flush catches all of them.
"""
if not self._pending:
return
import numpy as np

pending, self._pending = self._pending, []
n = len(pending)
first = self._ncols - n
lb = np.fromiter((c[1] for c in pending), dtype=np.float64, count=n)
ub = np.fromiter((c[2] for c in pending), dtype=np.float64, count=n)
empty_i = np.array([], dtype=np.int32)
# Zero objective coefficients: the objective is passed as an
# expression in solve(), not built up column by column.
self._prob.addCols(n, np.zeros(n), lb, ub, 0, empty_i, empty_i,
np.array([]))

int_idx = np.fromiter(
(first + i for i, c in enumerate(pending) if c[3]),
dtype=np.int32)
if int_idx.size:
self._prob.changeColsIntegrality(
int_idx.size, int_idx,
np.full(int_idx.size, self._highs.HighsVarType.kInteger))

for i, col in enumerate(pending):
self._prob.passColName(first + i, col[0])

self._colvals = None

def addVar(self, name, lo, hi):
inf = self._highs.kHighsInf
lo = -inf if lo is None else lo
hi = inf if hi is None else hi
# HiGHS has no separate binary type, so everything becomes an integer
# column and a 0/1 range is just one bounded to [0, 1] -- equivalent
# to the binary variables the other backends make for that case.
var = self._newCol(name, lo, hi, True)
self._vardict[name] = var
return var

def add_constraint(self, name, constraint):
self._flush()
self._constraintdict[name] = constraint
self._prob.addConstr(constraint, name=name)

def add_lazy_constraint(self, name, constraint):
"""HiGHS has no lazy-constraint hint, so these go in as ordinary ones.

That costs nothing here. The collision constraints are all built up
front rather than generated in a callback, so a backend without the
hint still gets an equivalent model -- and marking them lazy for a
backend that does support it left both its runtime and its objective
unchanged on a 1.7M-variable instance from a real list.
"""
self.add_constraint(name, constraint)

def value(self, var):
"""Read a variable's value from one cached solution vector.

Highs.val() recomputes per call, at O(numCol) each -- measured at
295 us per variable on a 20k-column model and 1083 us on an 80k one.
Reading a whole solution back one variable at a time is then
quadratic, and takes about an hour on the 500k-column problems this
module produces, against roughly 20 s for the solve itself.
getSolution() costs about a millisecond, once.
"""
if self._colvals is None:
return self._prob.val(var)
return self._colvals[var.index]

def _cacheSolution(self):
"""Read the whole solution vector once. Only called after _checkSolved.

HiGHS returns an all-zero vector rather than raising when there is no
solution, so guarding this with try/except never caught anything; the
status check in solve() is what rules that case out.
"""
import numpy as np

self._colvals = np.asarray(self._prob.getSolution().col_value)

def _checkSolved(self):
"""Refuse to hand back a column vector that is not a solution.

A Gurobi variable simply has no value to read when the solve failed,
so the caller finds out at once. HiGHS instead returns an all-zero
column vector for an infeasible or unsolved model, which is
indistinguishable from a feasible solution that happens to assign
nothing -- a failed solve would be read back as an empty assignment
and silently treated as a valid one. So check the status explicitly.
"""
status = self._prob.getModelStatus()
if status == self._highs.HighsModelStatus.kOptimal:
return
# A limit (time, iterations, ...) can stop the search once an
# incumbent has been found. That is a usable answer, just not a
# provably optimal one, so accept it rather than discarding it.
feasible = self._highs.SolutionStatus.kSolutionStatusFeasible
if self._prob.getInfo().primal_solution_status == feasible:
return
raise RuntimeError("HiGHS found no solution: "
+ self._prob.modelStatusToString(status))

def solve(self):
self._flush()
self._prob.minimize(self.cost)
self._checkSolved()
self._cacheSolution()

def update(self):
self._flush()

def dump(self, filename):
self._flush()
self._prob.writeModel(filename)

def varBounds(self, var):
return self._bounds[var.index]

def changeVarBounds(self, var, lower=None, upper=None):
self._flush()
lb, ub = self._bounds[var.index]
if lower is not None:
lb = lower
if upper is not None:
ub = upper
self._bounds[var.index] = (float(lb), float(ub))
self._colvals = None
self._prob.changeColBounds(var.index, lb, ub)


class Telescope(object):
"""An object describing a telescope configuration to be used for observing
a target field.
Expand Down