diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index dcef8da32..5ce3cfd77 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -80,7 +80,7 @@ jobs: run: | conda install mpi4py pandas setuptools pip install pyomo sphinx sphinx_rtd_theme sphinx-copybutton dill gridx-egret cplex pybind11 - pip install xpress coverage + pip install gurobipy xpress coverage - name: Build pyomo extensions run: | @@ -165,6 +165,14 @@ jobs: run: | coverage run $COV_ARGS -m pytest mpisppy/tests/test_prox_approx_e2e.py -v + - name: Test timed mipgap + run: | + coverage run $COV_ARGS -m pytest mpisppy/tests/test_timed_mipgap.py -v + + - name: Test termination callback + run: | + coverage run $COV_ARGS -m pytest mpisppy/utils/callbacks/termination/tests/test_termination.py -v + - name: Test docs run: | cd ./doc/ diff --git a/doc/src/extensions.rst b/doc/src/extensions.rst index 960de37c0..13eb14444 100644 --- a/doc/src/extensions.rst +++ b/doc/src/extensions.rst @@ -28,7 +28,11 @@ command-line flags: (see :ref:`w_oscillation`) - ``--interrupt-W-oscillations `` -- activates W-oscillation interruption (slamming; implies detection; see :ref:`w_oscillation`) -- ``--mipgaps-json `` -- activates the mipgapper extension +- ``--mipgaps-json `` -- activates the legacy mipgapper schedule mode +- ``--starting-mipgap `` (required; ``--mipgap-ratio`` defaults to + ``0.1``) -- activates the mipgapper's auto-gap mode for cylinders +- ``--timed-mipgap `` -- activates the timed MIP gap extension and sets + the timed MIP gap curve as ``gap:time`` pairs (e.g., ``"0.02:100 0.05:200"``) - ``--user-defined-extensions `` -- loads a custom extension - ``--grad-rho`` -- activates gradient-based rho (see :ref:`rho_setting`) - ``--use-norm-rho-updater`` -- activates the norm rho updater @@ -82,22 +86,51 @@ now describe a few of the extensions in the release. mipgapper.py ^^^^^^^^^^^^ -This is a good extension to look at as a first example. It takes a -dictionary with iteration numbers and mipgaps as input and changes the -mipgap at the corresponding iterations. The dictionary is provided in -the options dictionary in ``["gapperoptions"]["mipgapdict"]``. There -is an example of its use in ``examples.sizes.sizes_demo.py``. +This is a good extension to look at as a first example. It can either take a +dictionary with iteration numbers and mipgaps as input, or it can run in +auto-gap mode when used from ``generic_cylinders``. -Instead of an options dictionary, when run with cylinders the options -``["gapperoptions"]["starting_mipgap"]`` and ``["gapperoptions"]["mipgap_ratio"]`` -can be set. The ``starting_mipgap`` will be the initial value used, -and as the cylinders close the relative optimality gap the extension will set the subproblem -mipgaps as the ``min(starting_mipgap, mipgap_ratio * problem_ratio)``, where -the ``problem_ratio`` is the relative optimality gap on the overall problem -as computed by the cylinders. +The dictionary form is provided in the options dictionary in +``["gapperoptions"]["mipgapdict"]``. There is an example of its use in +``examples.sizes.sizes_demo.py``. + +When run with cylinders, the options ``["gapperoptions"]["starting_mipgap"]`` +and ``["gapperoptions"]["mipgap_ratio"]`` can be set instead. The +``starting_mipgap`` is the initial value used, and as the cylinders close the +relative optimality gap the extension sets the subproblem mipgaps as +``min(starting_mipgap, mipgap_ratio * problem_ratio)``, where +``problem_ratio`` is the relative optimality gap on the overall problem as +computed by the cylinders. This extension can also be used with the Lagrangian and subgradient spokes. +timed_mipgap.py +^^^^^^^^^^^^^^^^ + +This extension installs a solver termination callback that stops a persistent +MIP solve once a user-specified run time has been reached *and* the current +relative gap is already below a target threshold. The option is given as a +string of ordered ``gap:time`` pairs in +``options["timed_mipgap"]["timecurve"]``. For example, +``"0.02:100 0.05:200"`` means: after 100 seconds, stop if the relative gap is +below 2%; after 200 seconds, stop if it is below 5%. + +This is a soft, time-dependent stopping rule: it does not force termination at +the specified times unless the incumbent and bound are already close enough. +It is useful when early PH iterations do not need tight subproblem solves, but +later iterations may still benefit from stronger solves when the solver is +making progress. + +When using ``generic_cylinders.py``, enable it with: + +- ``--timed-mipgap "0.02:100 0.05:200"`` + +The extension currently requires a persistent solver with supported termination +callbacks; at present this includes CPLEX, Gurobi, and Xpress persistent +interfaces. The ``gap:time`` pairs are validated in the order provided and +must be strictly increasing in both gap and time; duplicate gap entries are +rejected. + fixer.py ^^^^^^^^ diff --git a/doc/src/generic_cylinders.rst b/doc/src/generic_cylinders.rst index d830f9a7c..b8c366c73 100644 --- a/doc/src/generic_cylinders.rst +++ b/doc/src/generic_cylinders.rst @@ -230,6 +230,10 @@ Some extensions can be activated directly from the command line: - ``--fixer`` -- Fix variables that have converged - ``--mipgaps-json `` -- MIP gap schedule from a JSON file +- ``--starting-mipgap `` (required; ``--mipgap-ratio`` defaults to + ``0.1``) -- auto MIP gap mode for cylinders +- ``--timed-mipgap `` -- Time-dependent MIP gap termination + curve as ``gap:time`` pairs - ``--user-defined-extensions `` -- Load a custom extension module - ``--wtracker`` -- Track W (Lagrange-multiplier) values per iteration and write a convergence report at the end of the run @@ -390,7 +394,9 @@ any ``mipgap`` set elsewhere. For iteration-aware mipgap, use ``--iter0-mipgap`` and ``--iterk-mipgap`` (plus their per-spoke variants), or -``--mipgaps-json `` for a mipgap-only schedule. +``--mipgaps-json `` for a mipgap-only schedule. For +auto-tuning mipgap during decomposition, use ``--starting-mipgap``; +``--mipgap-ratio`` defaults to ``0.1``. ``--max-solver-threads`` sets a system-level thread cap that wins over any inline ``threads`` value; use it on shared HPC nodes. diff --git a/mpisppy/cylinders/hub.py b/mpisppy/cylinders/hub.py index bfe731807..7528718ba 100644 --- a/mpisppy/cylinders/hub.py +++ b/mpisppy/cylinders/hub.py @@ -8,6 +8,7 @@ ############################################################################### import abc +import math import logging import mpisppy.log @@ -86,6 +87,22 @@ def get_update_string(self): return ' ' + self.latest_ib_char return self.latest_ob_char+' '+self.latest_ib_char + @staticmethod + def _format_trace_value(value, width, decimals): + """Format a trace value in fixed notation unless it under/overflows it.""" + fixed = f"{value:{width}.{decimals}f}" + if not math.isfinite(value): + return fixed + + if len(fixed) > width: + return f"{value:{width}.{decimals}e}" + + fixed_core = fixed.strip().lstrip("-") + if value != 0 and fixed_core == f"0.{('0' * decimals)}": + return f"{value:{width}.{decimals}e}" + + return fixed + def screen_trace(self): current_iteration = self.current_iteration() abs_gap, rel_gap = self.compute_gaps() @@ -96,7 +113,11 @@ def screen_trace(self): row = f'{"Iter.":>5s} {" "} {"Best Bound":>14s} {"Best Incumbent":>14s} {"Rel. Gap":>12s} {"Abs. Gap":>14s}' global_toc(row, True) self.print_init = False - row = f"{current_iteration:5d} {update_source} {best_bound:14.4f} {best_solution:14.4f} {rel_gap*100:12.3f}% {abs_gap:14.4f}" + best_bound_s = self._format_trace_value(best_bound, 14, 4) + best_solution_s = self._format_trace_value(best_solution, 14, 4) + rel_gap_s = self._format_trace_value(rel_gap * 100, 12, 3) + abs_gap_s = self._format_trace_value(abs_gap, 14, 4) + row = f"{current_iteration:5d} {update_source} {best_bound_s} {best_solution_s} {rel_gap_s}% {abs_gap_s}" global_toc(row, True) self.clear_latest_chars() diff --git a/mpisppy/extensions/timed_mipgap.py b/mpisppy/extensions/timed_mipgap.py index 2f5efba5b..15f480a2f 100644 --- a/mpisppy/extensions/timed_mipgap.py +++ b/mpisppy/extensions/timed_mipgap.py @@ -7,22 +7,25 @@ # full copyright and license information. ############################################################################### ''' -This class is implemented as an extension to be used in mpi-sppy to add a callback to a persistent -solver to implement a time-dependent target MIP gap. -For now, the only solver supported is GurobiPersistent. +This class is implemented as an extension to be used in mpi-sppy to add a termination callback to a +persistent solver and implement a time-dependent target MIP gap. ''' +import math + import mpisppy.extensions.extension import mpisppy.utils.sputils as sputils -from pyomo.solvers.plugins.solvers.gurobi_persistent import GurobiPersistent - -from gurobipy import GRB +from mpisppy.utils.callbacks.termination import ( + set_termination_callback, + supports_termination_callback, +) class TimedMIPGapCB(mpisppy.extensions.extension.Extension): ''' - This extension adds a solver callback function that implements a time-dependent target MIP gap. - The curve is defined by a sequence of (time,gap) pairs, monotonically increasing in both dimensions. - For each (t,g) pair, when the solver reaches time t, it relaxes the target MIP gap to g. + This extension adds a solver termination callback that implements a time-dependent target MIP gap. + The curve is defined by an ordered mapping of gap:time pairs, monotonically increasing in both + dimensions. For each (t,g) pair, when the solver reaches time t, it terminates if the relative gap + has already improved below g. This class requires the following options: 'timed_mipgap': @@ -30,19 +33,12 @@ class TimedMIPGapCB(mpisppy.extensions.extension.Extension): Attributes ---------- - timecurve: dict of {gap:time} with sequence of (time,gap) pairs. - - Reference - --------- - Based on post found at: - https://support.gurobi.com/hc/en-us/articles/360047717291-How-do-I-use-callbacks-to-terminate-the-solver- - (Oct 2023) + timecurve: ordered dict of {gap:time} pairs. ''' def __init__(self, ph): - + super().__init__(ph) self.ph = ph - self._set_options() def _set_options(self): @@ -50,53 +46,81 @@ def _set_options(self): if 'timed_mipgap' not in ph.options: raise RuntimeError('Did not find "timed_mipgap" options') timecurve_str = ph.options['timed_mipgap']['timecurve'] - self.timecurve = {float(ent.split(':')[0]):float(ent.split(':')[1]) for ent in timecurve_str.split(' ')} - - def iter0_post_solver_creation(self): - ph = self.ph - for sname, s in ph.local_scenarios.items(): - if not hasattr(s, '_solver_plugin'): - raise RuntimeError('Solver must be created before calling callback extension') - if not (sputils.is_persistent(s._solver_plugin)): - raise RuntimeError('Solvers must be persistent for callback definition') - if not s._solver_plugin.has_instance(): - raise RuntimeError('Solver must be instantiated before calling callback extension') - if not isinstance(s._solver_plugin,GurobiPersistent): - raise RuntimeError('Currently, only GurobiPersistent solver is supported.') - - def cb_fun(cb_m,cb_opt,cb_wh): - ''' - callback function - ''' - return self._timecurve_cb(cb_m,cb_opt,cb_wh,self.timecurve) - s._solver_plugin.set_callback(cb_fun) + self.timecurve = dict() + prev_gap = None + prev_time = None + for entry in timecurve_str.split(): + try: + gap_str, time_str = entry.split(':', 1) + gap = float(gap_str) + solve_time = float(time_str) + except ValueError as exc: + raise RuntimeError( + 'Timed MIP gap option "timecurve" entries must have format "gap:time"' + ) from exc + + if not math.isfinite(gap) or not math.isfinite(solve_time): + raise RuntimeError( + 'Timed MIP gap option "timecurve" entries must use finite gap and time values' + ) + if gap in self.timecurve: + raise RuntimeError( + f'Timed MIP gap option "timecurve" has duplicate gap entry {gap}' + ) + if prev_gap is not None and (gap <= prev_gap or solve_time <= prev_time): + raise RuntimeError( + 'Timed MIP gap option "timecurve" must be strictly increasing in both gap and time' + ) + + self.timecurve[gap] = solve_time + prev_gap = gap + prev_time = solve_time + + if not self.timecurve: + raise RuntimeError('Timed MIP gap option "timecurve" must not be empty') @staticmethod - def _timecurve_cb(cb_model, cb_opt, cb_where, - timecurve_dict): - ''' - Inputs - ------ - cb_model: Pyomo ConcreteModel - cb_opt: SolverFactory model of gurobi_persistent type - cb_where: argument that indicates where in the algorith the callback is being called from - - timecurve_dict: dict of {gap:time} with: - time: time in sec - gap: target MIP gap (in p.u.) - Note that (time,gap) only define a meaningful timecurve if there is monotonicity, so monotonicity - is assumed. However, it is not verified. - - ''' - - if cb_where == GRB.Callback.MIP: - grb_m = cb_opt._solver_model # gurobipy model - runtime = grb_m.cbGet(GRB.Callback.RUNTIME) - objbst = grb_m.cbGet(GRB.Callback.MIP_OBJBST) - objbnd = grb_m.cbGet(GRB.Callback.MIP_OBJBND) - gap = abs((objbst - objbnd) / objbst) - - for tc_gap,tc_t in timecurve_dict.items(): - if runtime > tc_t and gap < tc_gap: - grb_m.terminate() - + def _compute_relative_gap(best_obj, best_bound): + '''Return relative gap or None if a bound is unavailable.''' + if best_obj is None or best_bound is None: + return None + if not math.isfinite(best_obj) or not math.isfinite(best_bound): + return None + + return abs(best_obj - best_bound) / max( + 1e-10, + abs(best_obj), + abs(best_bound), + ) + + def _should_terminate(self, runtime, best_obj, best_bound): + '''Return True when the timecurve says to terminate.''' + if runtime is None or not math.isfinite(runtime): + return False + + gap = self._compute_relative_gap(best_obj, best_bound) + if gap is None: + return False + + for tc_gap, tc_t in self.timecurve.items(): + if runtime > tc_t and gap < tc_gap: + return True + + return False + + def pre_solve(self, s): + if not hasattr(s, '_solver_plugin'): + raise RuntimeError('Solver must be created before calling callback extension') + if not (sputils.is_persistent(s._solver_plugin)): + raise RuntimeError('Solvers must be persistent for callback definition') + if not s._solver_plugin.has_instance(): + raise RuntimeError('Solver must be instantiated before calling callback extension') + if not supports_termination_callback(s._solver_plugin): + raise RuntimeError( + 'Timed MIP gap requires a persistent solver with supported termination callbacks' + ) + + def cb_fun(runtime, best_obj, best_bound): + return self._should_terminate(runtime, best_obj, best_bound) + + set_termination_callback(s._solver_plugin, cb_fun) diff --git a/mpisppy/generic/parsing.py b/mpisppy/generic/parsing.py index cb0ad3f1e..70f02a16a 100644 --- a/mpisppy/generic/parsing.py +++ b/mpisppy/generic/parsing.py @@ -145,6 +145,7 @@ def add_decomp_args(cfg): cfg.coeff_rho_args() cfg.sensi_rho_args() cfg.reduced_costs_rho_args() + cfg.timed_mipgap_args() cfg.add_to_config("user_defined_extensions", description="Space-delimited module names for user extensions", diff --git a/mpisppy/tests/test_cylinder_hub_formatting.py b/mpisppy/tests/test_cylinder_hub_formatting.py new file mode 100644 index 000000000..478203e78 --- /dev/null +++ b/mpisppy/tests/test_cylinder_hub_formatting.py @@ -0,0 +1,29 @@ +############################################################################### +# mpi-sppy: MPI-based Stochastic Programming in PYthon +# +# Copyright (c) 2024, Lawrence Livermore National Security, LLC, Alliance for +# Sustainable Energy, LLC, The Regents of the University of California, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md for +# full copyright and license information. +############################################################################### +"""Tests for adaptive trace formatting in cylinders.hub.""" + +import unittest + +from mpisppy.cylinders.hub import Hub + + +class TestTraceFormatting(unittest.TestCase): + def test_fixed_format_is_preserved(self): + self.assertEqual(Hub._format_trace_value(123.4567, 14, 4), " 123.4567") + + def test_small_values_switch_to_scientific(self): + self.assertIn("e", Hub._format_trace_value(1.2e-8, 14, 4)) + self.assertIn("e", Hub._format_trace_value(-4.9e-5, 14, 4)) + + def test_large_values_switch_to_scientific(self): + self.assertIn("e", Hub._format_trace_value(1234567890.0, 14, 4)) + + +if __name__ == "__main__": + unittest.main() diff --git a/mpisppy/tests/test_timed_mipgap.py b/mpisppy/tests/test_timed_mipgap.py new file mode 100644 index 000000000..3eedb137a --- /dev/null +++ b/mpisppy/tests/test_timed_mipgap.py @@ -0,0 +1,208 @@ +############################################################################### +# mpi-sppy: MPI-based Stochastic Programming in PYthon +# +# Copyright (c) 2026, Lawrence Livermore National Security, LLC, Alliance for +# Sustainable Energy, LLC, The Regents of the University of California, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md for +# full copyright and license information. +############################################################################### + +import time + +import pytest +from pyomo.environ import SolverFactory + +import mpisppy.extensions.timed_mipgap as timed_mipgap_module +from mpisppy.extensions.timed_mipgap import TimedMIPGapCB +from mpisppy.utils.callbacks.termination.tests.markshare2 import model as markshare2_model + + +class _MockSolver: + def __init__(self, has_instance=True): + self._has_instance = has_instance + + def has_instance(self): + return self._has_instance + + +class _MockScenario: + def __init__(self, solver_plugin): + self._solver_plugin = solver_plugin + + +class _MockPH: + def __init__(self, timecurve, solver_plugin): + self.options = {"timed_mipgap": {"timecurve": timecurve}} + self.local_scenarios = {"Scenario1": _MockScenario(solver_plugin)} + + +def _make_ph(timecurve="0.02:100 0.05:200", solver_plugin=None): + if solver_plugin is None: + solver_plugin = _MockSolver() + return _MockPH(timecurve, solver_plugin) + + +class _TimedMIPGapIntegration: + _solver_name = None + _time_limit_option = None + + def __init__(self): + self.model = markshare2_model.clone() + self._solver = SolverFactory(self._solver_name) + self._solver.set_instance(self.model) + self._ph = _make_ph(timecurve="1e20:2", solver_plugin=self._solver) + self._ext = TimedMIPGapCB(self._ph) + + def _set_time_limit(self): + self._solver.options[self._time_limit_option] = 20 + + def solve(self): + self._ext.pre_solve(self._ph.local_scenarios["Scenario1"]) + self._set_time_limit() + return self._solver.solve(tee=False, load_solutions=False) + + +class _TimedMIPGapCPLEX(_TimedMIPGapIntegration): + _solver_name = "cplex_persistent" + _time_limit_option = "timelimit" + + +class _TimedMIPGapGurobi(_TimedMIPGapIntegration): + _solver_name = "gurobi_persistent" + _time_limit_option = "timelimit" + + +class _TimedMIPGapXpress(_TimedMIPGapIntegration): + _solver_name = "xpress_persistent" + _time_limit_option = "maxtime" + + +def test_timecurve_parsing_tolerates_whitespace(): + ext = TimedMIPGapCB(_make_ph(timecurve="0.02:100 0.05:200")) + + assert ext.timecurve == {0.02: 100.0, 0.05: 200.0} + + +def test_timecurve_parsing_rejects_bad_entries(): + with pytest.raises(RuntimeError, match='format "gap:time"'): + TimedMIPGapCB(_make_ph(timecurve="0.02:100 badentry")) + + +def test_timecurve_parsing_rejects_duplicate_gaps(): + with pytest.raises(RuntimeError, match="duplicate gap entry"): + TimedMIPGapCB(_make_ph(timecurve="0.02:100 0.02:200")) + + +def test_timecurve_parsing_rejects_nonmonotone_curve(): + with pytest.raises(RuntimeError, match="strictly increasing"): + TimedMIPGapCB(_make_ph(timecurve="0.02:100 0.01:200")) + + with pytest.raises(RuntimeError, match="strictly increasing"): + TimedMIPGapCB(_make_ph(timecurve="0.02:100 0.05:100")) + + +def test_timecurve_parsing_rejects_empty_curve(): + with pytest.raises(RuntimeError, match="must not be empty"): + TimedMIPGapCB(_make_ph(timecurve=" ")) + + +def test_timecurve_parsing_rejects_nonfinite_entries(): + with pytest.raises(RuntimeError, match="finite gap and time values"): + TimedMIPGapCB(_make_ph(timecurve="inf:2")) + + with pytest.raises(RuntimeError, match="finite gap and time values"): + TimedMIPGapCB(_make_ph(timecurve="0.1:nan")) + + +def test_compute_relative_gap_guards_missing_and_zero_values(): + assert TimedMIPGapCB._compute_relative_gap(None, 1.0) is None + assert TimedMIPGapCB._compute_relative_gap(1.0, None) is None + assert TimedMIPGapCB._compute_relative_gap(float("inf"), 1.0) is None + assert TimedMIPGapCB._compute_relative_gap(1.0, float("nan")) is None + assert TimedMIPGapCB._compute_relative_gap(0.0, 0.0) == pytest.approx(0.0) + assert TimedMIPGapCB._compute_relative_gap(0.0, 2.0) == pytest.approx(1.0) + + +def test_should_terminate_uses_runtime_and_relative_gap(): + ext = TimedMIPGapCB(_make_ph(timecurve="0.02:100 0.05:200")) + + assert not ext._should_terminate(50.0, 100.0, 99.0) + assert ext._should_terminate(101.0, 100.0, 99.0) + assert not ext._should_terminate(101.0, 100.0, 97.0) + assert ext._should_terminate(201.0, 100.0, 96.0) + assert not ext._should_terminate(101.0, None, 99.0) + assert not ext._should_terminate(float("inf"), 100.0, 99.0) + assert not ext._should_terminate(101.0, float("inf"), 99.0) + assert ext._should_terminate(101.0, 0.0, 0.0) + + +def test_pre_solve_registers_generic_callback(monkeypatch): + calls = [] + solver_plugin = _MockSolver() + ext = TimedMIPGapCB(_make_ph(solver_plugin=solver_plugin)) + scenario = _MockScenario(solver_plugin) + + monkeypatch.setattr(timed_mipgap_module.sputils, "is_persistent", lambda solver: True) + monkeypatch.setattr(timed_mipgap_module, "supports_termination_callback", lambda solver: True) + monkeypatch.setattr( + timed_mipgap_module, + "set_termination_callback", + lambda solver, cb: calls.append((solver, cb)), + ) + + ext.pre_solve(scenario) + + assert len(calls) == 1 + assert calls[0][0] is solver_plugin + callback = calls[0][1] + assert callback(101.0, 100.0, 99.0) + assert not callback(101.0, 100.0, 97.0) + assert not callback(101.0, None, 99.0) + + +@pytest.mark.skipif( + not SolverFactory("cplex_persistent").available(exception_flag=False), + reason="cplex_persistent not available", +) +def test_timed_mipgap_cplex_integration(): + st = time.time() + test = _TimedMIPGapCPLEX() + results = test.solve() + end = time.time() + + assert end - st < 10 + assert str(results.solver.status) == "aborted" + assert str(results.solver.termination_condition) == "userInterrupt" + assert str(results.solution[0].status) == "stoppedByLimit" + + +@pytest.mark.skipif( + not SolverFactory("gurobi_persistent").available(exception_flag=False), + reason="gurobi_persistent not available", +) +def test_timed_mipgap_gurobi_integration(): + st = time.time() + test = _TimedMIPGapGurobi() + results = test.solve() + end = time.time() + + assert end - st < 10 + assert str(results.solver.status) == "aborted" + assert str(results.solver.termination_condition) == "userInterrupt" + assert str(results.solution[0].status) == "stoppedByLimit" + + +@pytest.mark.skipif( + not SolverFactory("xpress_persistent").available(exception_flag=False), + reason="xpress_persistent not available", +) +def test_timed_mipgap_xpress_integration(): + st = time.time() + test = _TimedMIPGapXpress() + results = test.solve() + end = time.time() + + assert end - st < 10 + assert str(results.solver.status) == "warning" + assert str(results.solver.termination_condition) == "other" + assert str(results.solution[0].status) == "feasible" diff --git a/mpisppy/utils/callbacks/termination/solver_callbacks.py b/mpisppy/utils/callbacks/termination/solver_callbacks.py index 360186220..102d3f72b 100644 --- a/mpisppy/utils/callbacks/termination/solver_callbacks.py +++ b/mpisppy/utils/callbacks/termination/solver_callbacks.py @@ -12,6 +12,8 @@ # are (mostly) defined in the context of MIPs. If we want to expand to LPs # and other non-branch-and-bound contexts, additional work is required. +from pyomo.opt.results import SolutionStatus, SolverStatus, TerminationCondition + def check_user_termination_callback_signature(user_termination_callback): import inspect @@ -25,7 +27,9 @@ def set_cplex_callback(solver, user_termination_callback): class Termination( cplex.callbacks.MIPInfoCallback, ): - _tc = user_termination_callback + # Store the user callback as a staticmethod so CPLEX callback instances + # do not bind ``self`` and accidentally pass a fourth positional arg. + _tc = staticmethod(user_termination_callback) def __call__(self): runtime = self.get_time() - self.get_start_time() @@ -37,32 +41,82 @@ def __call__(self): cplex_model.register_callback(Termination) + if not hasattr(solver, "_termination_callback_original_postsolve"): + solver._termination_callback_original_postsolve = solver._postsolve + + def _termination_callback_postsolve(): + results = solver._termination_callback_original_postsolve() + cplex_status = solver._solver_model.solution.get_status() + + # CPLEX reports callback-triggered aborts with status 113 ("aborted"). + # Pyomo's CPLEXDirect does not currently map that code, so normalize it + # here to an aborted solve with a feasible incumbent when available. + if cplex_status == 113 and results.solver.status == SolverStatus.error: + results.solver.status = SolverStatus.aborted + results.solver.termination_condition = TerminationCondition.userInterrupt + results.solver.message = ( + "CPLEX solve aborted by mpi-sppy termination callback." + ) + for solution in results.solution: + solution.status = SolutionStatus.stoppedByLimit + + return results + + solver._postsolve = _termination_callback_postsolve + def set_gurobi_callback(solver, user_termination_callback): - gurobi_model = solver._solver_model - gurobi_model._terminate_function = user_termination_callback - # TBD - best placement? For speeed... from gurobipy import GRB - def gurobi_callback(gurobi_model, where): - if where == GRB.Callback.MIP: - runtime = gurobi_model.cbGet(GRB.Callback.RUNTIME) - obj_best = gurobi_model.cbGet(GRB.Callback.MIP_OBJBST) - obj_bound = gurobi_model.cbGet(GRB.Callback.MIP_OBJBND) - if gurobi_model._terminate_function(runtime, obj_best, obj_bound): - gurobi_model.terminate() + class GurobiCallback: + + # Store the user callback as a staticmethod so Gurobi callback instances + # do not bind ``self`` and accidentally pass a fourth positional arg. + _tc = staticmethod(user_termination_callback) + + def __call__(self, gurobi_model, where): + if where == GRB.Callback.MIP: + runtime = gurobi_model.cbGet(GRB.Callback.RUNTIME) + obj_best = gurobi_model.cbGet(GRB.Callback.MIP_OBJBST) + obj_bound = gurobi_model.cbGet(GRB.Callback.MIP_OBJBND) + if self._tc(runtime, obj_best, obj_bound): + gurobi_model.terminate() + gurobi_callback = GurobiCallback() # This overwrites GurobiPersistent's # existing callback. gurobipy callbacks # are set by gurobi_model.solve, so we # need to let Pyomo do this. solver._callback = gurobi_callback + if not hasattr(solver, "_termination_callback_original_postsolve"): + solver._termination_callback_original_postsolve = solver._postsolve + + def _termination_callback_postsolve(): + results = solver._termination_callback_original_postsolve() + + from gurobipy import GRB + + if solver._solver_model.Status == GRB.INTERRUPTED: + results.solver.status = SolverStatus.aborted + results.solver.termination_condition = TerminationCondition.userInterrupt + results.solver.message = ( + "Gurobi solve interrupted by mpi-sppy termination callback." + ) + for solution in results.solution: + if solution.status == SolutionStatus.error: + solution.status = SolutionStatus.stoppedByLimit + + return results + + solver._postsolve = _termination_callback_postsolve + def set_xpress_callback(solver, user_termination_callback): - + import xpress as xp + xpress_problem = solver._solver_model def cbchecktime_callback(xpress_problem, termination_callback): @@ -70,10 +124,12 @@ def cbchecktime_callback(xpress_problem, termination_callback): obj_best = xpress_problem.attributes.mipbestobjval obj_bound = xpress_problem.attributes.bestbound if termination_callback(runtime, obj_best, obj_bound): - return 1 - return 0 - - # per the Xpress documentation, this callback is invoked every time the Optimizer - # checks if the time limit has been reached. So broader than what is presently - # needed for our present MIP-based use cases. - xpress_problem.addcbchecktime(cbchecktime_callback, user_termination_callback, 0) + xpress_problem.interrupt(xp.StopType.USER) + return None + + # Per the Xpress documentation, this callback is invoked every time the + # Optimizer checks if the time limit has been reached. This is broader than + # what is presently needed for our MIP-based use cases. + xpress_problem.addCheckTimeCallback( + cbchecktime_callback, user_termination_callback, 0 + ) diff --git a/mpisppy/utils/callbacks/termination/termination_callbacks.py b/mpisppy/utils/callbacks/termination/termination_callbacks.py index c736e7d57..c99359fc5 100644 --- a/mpisppy/utils/callbacks/termination/termination_callbacks.py +++ b/mpisppy/utils/callbacks/termination/termination_callbacks.py @@ -20,6 +20,13 @@ XpressPersistent: tc.set_xpress_callback, } + +def _get_termination_callback_setter(solver_instance): + for solver_class, setter in _termination_callback_solvers_to_setters.items(): + if isinstance(solver_instance, solver_class): + return setter + return None + def supports_termination_callback(solver_instance): """ Determines if this module supports a solver instance @@ -33,9 +40,7 @@ def supports_termination_callback(solver_instance): bool : True if this module can set a termination callback on the solver instance """ - return isinstance( - solver_instance, tuple(_termination_callback_solvers_to_setters.keys()) - ) + return _get_termination_callback_setter(solver_instance) is not None def set_termination_callback(solver_instance, termination_callback): @@ -59,12 +64,10 @@ def set_termination_callback(solver_instance, termination_callback): "Provided user termination callback did not match expected signature with 3 positional arguments" ) - try: - _termination_callback_solvers_to_setters[solver_instance.__class__]( - solver_instance, termination_callback - ) - except KeyError: + setter = _get_termination_callback_setter(solver_instance) + if setter is None: raise RuntimeError( - f"solver {solver_instance.__class__.__name___} termination callback " + f"solver {solver_instance.__class__.__name__} termination callback " "is not currently supported." ) + setter(solver_instance, termination_callback) diff --git a/mpisppy/utils/callbacks/termination/tests/test_termination.py b/mpisppy/utils/callbacks/termination/tests/test_termination.py index f5162b679..d5f4ac5d7 100644 --- a/mpisppy/utils/callbacks/termination/tests/test_termination.py +++ b/mpisppy/utils/callbacks/termination/tests/test_termination.py @@ -8,6 +8,7 @@ ############################################################################### import pytest +import mpisppy.utils.callbacks.termination.termination_callbacks as termination_callbacks_module from mpisppy.utils.callbacks.termination.termination_callbacks import ( set_termination_callback, supports_termination_callback, @@ -37,6 +38,12 @@ def solve(self): self._set_time_limit() self._solver.solve(tee=True) + def solve_without_loading(self): + assert supports_termination_callback(self._solver) + set_termination_callback(self._solver, self.solver_terminate) + self._set_time_limit() + return self._solver.solve(tee=False, load_solutions=False) + class CPLEXTermination(_TestTermination): @@ -69,10 +76,7 @@ def test_cplex_termination_callback(): st = time.time() cplextest = CPLEXTermination() - try: - cplextest.solve() - except ValueError: - pass + cplextest.solve() end = time.time() assert end - st < 5 @@ -89,6 +93,19 @@ def test_gurobi_termination_callback(): end = time.time() assert end - st < 5 + +@pytest.mark.skipif( + not SolverFactory("gurobi_persistent").available(exception_flag=False), + reason="gurobi_persistent not available", +) +def test_gurobi_termination_callback_status(): + + gurobitest = GurobiTermination() + results = gurobitest.solve_without_loading() + assert str(results.solver.status) == "aborted" + assert str(results.solver.termination_condition) == "userInterrupt" + assert str(results.solution[0].status) == "stoppedByLimit" + @pytest.mark.skipif( not SolverFactory("xpress_persistent").available(exception_flag=False), @@ -102,6 +119,19 @@ def test_xpress_termination_callback(): end = time.time() assert end - st < 5 + +@pytest.mark.skipif( + not SolverFactory("xpress_persistent").available(exception_flag=False), + reason="xpress_persistent not available", +) +def test_xpress_termination_callback_status(): + + xpresstest = XpressTermination() + results = xpresstest.solve_without_loading() + assert str(results.solver.status) == "warning" + assert str(results.solver.termination_condition) == "other" + assert str(results.solution[0].status) == "feasible" + def test_unsupported(): @@ -109,3 +139,50 @@ def test_unsupported(): assert not supports_termination_callback("xpress_persistent") assert not supports_termination_callback(cbc) + + +def test_subclass_dispatch(monkeypatch): + + calls = [] + + class BaseSolver: + pass + + class ChildSolver(BaseSolver): + pass + + monkeypatch.setattr( + termination_callbacks_module, + "_termination_callback_solvers_to_setters", + {BaseSolver: lambda solver, cb: calls.append((solver, cb))}, + ) + + solver = ChildSolver() + + def termination_callback(runtime, best_obj, best_bound): + return False + + assert supports_termination_callback(solver) + set_termination_callback(solver, termination_callback) + assert calls == [(solver, termination_callback)] + + +def test_unsupported_error_message(monkeypatch): + + class UnsupportedSolver: + pass + + monkeypatch.setattr( + termination_callbacks_module, + "_termination_callback_solvers_to_setters", + {}, + ) + + def termination_callback(runtime, best_obj, best_bound): + return False + + with pytest.raises( + RuntimeError, + match="solver UnsupportedSolver termination callback is not currently supported", + ): + set_termination_callback(UnsupportedSolver(), termination_callback) diff --git a/mpisppy/utils/cfg_vanilla.py b/mpisppy/utils/cfg_vanilla.py index 8d76e167a..eaee10293 100644 --- a/mpisppy/utils/cfg_vanilla.py +++ b/mpisppy/utils/cfg_vanilla.py @@ -588,6 +588,7 @@ def subgradient_hub(cfg, } add_wxbar_read_write(hub_dict, cfg) add_ph_tracking(hub_dict, cfg) + add_timed_mipgap(hub_dict, cfg) return hub_dict def fwph_hub(cfg, @@ -985,10 +986,10 @@ def add_ph_tracking(cylinder_dict, cfg, spoke=False): return cylinder_dict def add_timed_mipgap(cylinder_dict, cfg): - if getattr(cfg, "timed_mipgap", False): + if getattr(cfg, "timed_mipgap", None) is not None: from mpisppy.extensions.timed_mipgap import TimedMIPGapCB cylinder_dict = extension_adder(cylinder_dict, TimedMIPGapCB) - cylinder_dict['opt_kwargs']['options']['timed_mipgap']= {'timecurve':cfg.timed_mipgap_options} + cylinder_dict['opt_kwargs']['options']['timed_mipgap']= {'timecurve':cfg.timed_mipgap} return cylinder_dict diff --git a/mpisppy/utils/config.py b/mpisppy/utils/config.py index 0abab528c..1ed927adc 100644 --- a/mpisppy/utils/config.py +++ b/mpisppy/utils/config.py @@ -606,18 +606,13 @@ def two_sided_args(self): default=100) def timed_mipgap_args(self): - self.add_to_config('timed_mipgap', - description="use a time-dependent target mip gap", - domain=bool, - default=False) - - self.add_to_config("timed_mipgap_options", + self.add_to_config("timed_mipgap", description= "Should be a string with the following format: 'gap1:time1 gap2:time2 ... gapN:timeN'." "Each pair defines a soft solver time limit, i.e. time limit only applied to solver " - "if MIP gap is below specified threshold. Default: 0.05:600", + "if MIP gap is below specified threshold. Default: None (not enabled)", domain=str, - default="0.05:600") + default=None) def mip_options(self): self.add_mipgap_specs()