Skip to content

Commit 0935515

Browse files
authored
Merge branch 'main' into fix-pyros-subsolver-time-limits
2 parents 2186651 + 43ff36c commit 0935515

File tree

9 files changed

+325
-49
lines changed

9 files changed

+325
-49
lines changed

pyomo/contrib/incidence_analysis/interface.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
scipy as sp,
2929
plotly,
3030
)
31-
from pyomo.common.deprecation import deprecated
31+
from pyomo.common.deprecation import deprecated, deprecation_warning
3232
from pyomo.contrib.incidence_analysis.config import get_config_from_kwds
3333
from pyomo.contrib.incidence_analysis.matching import maximum_matching
3434
from pyomo.contrib.incidence_analysis.connected import get_independent_submatrices
@@ -453,11 +453,29 @@ def _validate_input(self, variables, constraints):
453453
raise ValueError("Neither variables nor a model have been provided.")
454454
else:
455455
variables = self.variables
456+
elif self._incidence_graph is not None:
457+
# If variables were provided and an incidence graph is cached,
458+
# make sure the provided variables exist in the graph.
459+
for var in variables:
460+
if var not in self._var_index_map:
461+
raise KeyError(
462+
f"Variable {var} does not exist in the cached"
463+
" incidence graph."
464+
)
456465
if constraints is None:
457466
if self._incidence_graph is None:
458467
raise ValueError("Neither constraints nor a model have been provided.")
459468
else:
460469
constraints = self.constraints
470+
elif self._incidence_graph is not None:
471+
# If constraints were provided and an incidence graph is cached,
472+
# make sure the provided constraints exist in the graph.
473+
for con in constraints:
474+
if con not in self._con_index_map:
475+
raise KeyError(
476+
f"Constraint {con} does not exist in the cached"
477+
" incidence graph."
478+
)
461479

462480
_check_unindexed(variables + constraints)
463481
return variables, constraints
@@ -854,7 +872,7 @@ def dulmage_mendelsohn(self, variables=None, constraints=None):
854872
# Hopefully this does not get too confusing...
855873
return var_partition, con_partition
856874

857-
def remove_nodes(self, nodes, constraints=None):
875+
def remove_nodes(self, variables=None, constraints=None):
858876
"""Removes the specified variables and constraints (columns and
859877
rows) from the cached incidence matrix.
860878
@@ -866,35 +884,76 @@ def remove_nodes(self, nodes, constraints=None):
866884
867885
Parameters
868886
----------
869-
nodes: list
870-
VarData or ConData objects whose columns or rows will be
871-
removed from the incidence matrix.
887+
variables: list
888+
VarData objects whose nodes will be removed from the incidence graph
872889
constraints: list
873-
VarData or ConData objects whose columns or rows will be
874-
removed from the incidence matrix.
890+
ConData objects whose nodes will be removed from the incidence graph
891+
892+
.. note::
893+
894+
**Deprecation in Pyomo v6.7.2.dev0**
895+
896+
The pre-6.7.2.dev0 implementation of ``remove_nodes`` allowed variables and
897+
constraints to remove to be specified in a single list. This made
898+
error checking difficult, and indeed, if invalid components were
899+
provided, we carried on silently instead of throwing an error or
900+
warning. As part of a fix to raise an error if an invalid component
901+
(one that is not part of the incidence graph) is provided, we now require
902+
variables and constraints to be specified separately.
875903
876904
"""
877905
if constraints is None:
878906
constraints = []
907+
if variables is None:
908+
variables = []
879909
if self._incidence_graph is None:
880910
raise RuntimeError(
881911
"Attempting to remove variables and constraints from cached "
882912
"incidence matrix,\nbut no incidence matrix has been cached."
883913
)
884-
to_exclude = ComponentSet(nodes)
885-
to_exclude.update(constraints)
886-
vars_to_include = [v for v in self.variables if v not in to_exclude]
887-
cons_to_include = [c for c in self.constraints if c not in to_exclude]
914+
915+
vars_to_validate = []
916+
cons_to_validate = []
917+
depr_msg = (
918+
"In IncidenceGraphInterface.remove_nodes, passing variables and"
919+
" constraints in the same list is deprecated. Please separate your"
920+
" variables and constraints and pass them in the order variables,"
921+
" constraints."
922+
)
923+
if any(var in self._con_index_map for var in variables) or any(
924+
con in self._var_index_map for con in constraints
925+
):
926+
deprecation_warning(depr_msg, version="6.7.2.dev0")
927+
# If we received variables/constraints in the same list, sort them.
928+
# Any unrecognized objects will be caught by _validate_input.
929+
for var in variables:
930+
if var in self._con_index_map:
931+
cons_to_validate.append(var)
932+
else:
933+
vars_to_validate.append(var)
934+
for con in constraints:
935+
if con in self._var_index_map:
936+
vars_to_validate.append(con)
937+
else:
938+
cons_to_validate.append(con)
939+
940+
variables, constraints = self._validate_input(
941+
vars_to_validate, cons_to_validate
942+
)
943+
v_exclude = ComponentSet(variables)
944+
c_exclude = ComponentSet(constraints)
945+
vars_to_include = [v for v in self.variables if v not in v_exclude]
946+
cons_to_include = [c for c in self.constraints if c not in c_exclude]
888947
incidence_graph = self._extract_subgraph(vars_to_include, cons_to_include)
889948
# update attributes
890949
self._variables = vars_to_include
891950
self._constraints = cons_to_include
892951
self._incidence_graph = incidence_graph
893952
self._var_index_map = ComponentMap(
894-
(var, i) for i, var in enumerate(self.variables)
953+
(var, i) for i, var in enumerate(vars_to_include)
895954
)
896955
self._con_index_map = ComponentMap(
897-
(con, i) for i, con in enumerate(self._constraints)
956+
(con, i) for i, con in enumerate(cons_to_include)
898957
)
899958

900959
def plot(self, variables=None, constraints=None, title=None, show=True):

pyomo/contrib/incidence_analysis/tests/test_interface.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -634,17 +634,15 @@ def test_exception(self):
634634
nlp = PyomoNLP(model)
635635
igraph = IncidenceGraphInterface(nlp)
636636

637-
with self.assertRaises(RuntimeError) as exc:
637+
with self.assertRaisesRegex(KeyError, "does not exist"):
638638
variables = [model.P]
639639
constraints = [model.ideal_gas]
640640
igraph.maximum_matching(variables, constraints)
641-
self.assertIn("must be unindexed", str(exc.exception))
642641

643-
with self.assertRaises(RuntimeError) as exc:
642+
with self.assertRaisesRegex(KeyError, "does not exist"):
644643
variables = [model.P]
645644
constraints = [model.ideal_gas]
646645
igraph.block_triangularize(variables, constraints)
647-
self.assertIn("must be unindexed", str(exc.exception))
648646

649647

650648
@unittest.skipUnless(networkx_available, "networkx is not available.")
@@ -885,17 +883,15 @@ def test_exception(self):
885883
model = make_gas_expansion_model()
886884
igraph = IncidenceGraphInterface(model)
887885

888-
with self.assertRaises(RuntimeError) as exc:
886+
with self.assertRaisesRegex(KeyError, "does not exist"):
889887
variables = [model.P]
890888
constraints = [model.ideal_gas]
891889
igraph.maximum_matching(variables, constraints)
892-
self.assertIn("must be unindexed", str(exc.exception))
893890

894-
with self.assertRaises(RuntimeError) as exc:
891+
with self.assertRaisesRegex(KeyError, "does not exist"):
895892
variables = [model.P]
896893
constraints = [model.ideal_gas]
897894
igraph.block_triangularize(variables, constraints)
898-
self.assertIn("must be unindexed", str(exc.exception))
899895

900896
@unittest.skipUnless(scipy_available, "scipy is not available.")
901897
def test_remove(self):
@@ -923,7 +919,7 @@ def test_remove(self):
923919
# Say we know that these variables and constraints should
924920
# be matched...
925921
vars_to_remove = [model.F[0], model.F[2]]
926-
cons_to_remove = (model.mbal[1], model.mbal[2])
922+
cons_to_remove = [model.mbal[1], model.mbal[2]]
927923
igraph.remove_nodes(vars_to_remove, cons_to_remove)
928924
variable_set = ComponentSet(igraph.variables)
929925
self.assertNotIn(model.F[0], variable_set)
@@ -1309,7 +1305,7 @@ def test_remove(self):
13091305
# matrix.
13101306
vars_to_remove = [m.flow_comp[1]]
13111307
cons_to_remove = [m.flow_eqn[1]]
1312-
igraph.remove_nodes(vars_to_remove + cons_to_remove)
1308+
igraph.remove_nodes(vars_to_remove, cons_to_remove)
13131309
var_dmp, con_dmp = igraph.dulmage_mendelsohn()
13141310
var_con_set = ComponentSet(igraph.variables + igraph.constraints)
13151311
underconstrained_set = ComponentSet(
@@ -1460,6 +1456,42 @@ def test_remove_no_matrix(self):
14601456
with self.assertRaisesRegex(RuntimeError, "no incidence matrix"):
14611457
igraph.remove_nodes([m.v1])
14621458

1459+
def test_remove_bad_node(self):
1460+
m = pyo.ConcreteModel()
1461+
m.x = pyo.Var([1, 2, 3])
1462+
m.eq = pyo.Constraint(pyo.PositiveIntegers)
1463+
m.eq[1] = m.x[1] * m.x[2] == m.x[3]
1464+
m.eq[2] = m.x[1] + 2 * m.x[2] == 3 * m.x[3]
1465+
igraph = IncidenceGraphInterface(m)
1466+
with self.assertRaisesRegex(KeyError, "does not exist"):
1467+
# Suppose we think something like this should work. We should get
1468+
# an error, and not silently do nothing.
1469+
igraph.remove_nodes([m.x], [m.eq[1]])
1470+
1471+
with self.assertRaisesRegex(KeyError, "does not exist"):
1472+
igraph.remove_nodes(None, [m.eq])
1473+
1474+
with self.assertRaisesRegex(KeyError, "does not exist"):
1475+
igraph.remove_nodes([[m.x[1], m.x[2]], [m.eq[1]]])
1476+
1477+
def test_remove_varcon_samelist_deprecated(self):
1478+
m = pyo.ConcreteModel()
1479+
m.x = pyo.Var([1, 2, 3])
1480+
m.eq = pyo.Constraint(pyo.PositiveIntegers)
1481+
m.eq[1] = m.x[1] * m.x[2] == m.x[3]
1482+
m.eq[2] = m.x[1] + 2 * m.x[2] == 3 * m.x[3]
1483+
1484+
igraph = IncidenceGraphInterface(m)
1485+
# This raises a deprecation warning. When the deprecated functionality
1486+
# is removed, this will fail, and this test should be updated accordingly.
1487+
igraph.remove_nodes([m.eq[1], m.x[1]])
1488+
self.assertEqual(len(igraph.variables), 2)
1489+
self.assertEqual(len(igraph.constraints), 1)
1490+
1491+
igraph.remove_nodes([], [m.eq[2], m.x[2]])
1492+
self.assertEqual(len(igraph.variables), 1)
1493+
self.assertEqual(len(igraph.constraints), 0)
1494+
14631495

14641496
@unittest.skipUnless(networkx_available, "networkx is not available.")
14651497
@unittest.skipUnless(scipy_available, "scipy is not available.")
@@ -1840,7 +1872,7 @@ def test_var_elim(self):
18401872
for adj_con in igraph.get_adjacent_to(m.x[1]):
18411873
for adj_var in igraph.get_adjacent_to(m.eq4):
18421874
igraph.add_edge(adj_var, adj_con)
1843-
igraph.remove_nodes([m.x[1], m.eq4])
1875+
igraph.remove_nodes([m.x[1]], [m.eq4])
18441876

18451877
assert ComponentSet(igraph.variables) == ComponentSet([m.x[2], m.x[3], m.x[4]])
18461878
assert ComponentSet(igraph.constraints) == ComponentSet([m.eq1, m.eq2, m.eq3])

pyomo/contrib/pynumero/interfaces/pyomo_nlp.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import pyomo.core.base as pyo
2323
from pyomo.common.collections import ComponentMap
2424
from pyomo.common.env import CtypesEnviron
25+
from pyomo.solvers.amplfunc_merge import amplfunc_merge
2526
from ..sparse.block_matrix import BlockMatrix
2627
from pyomo.contrib.pynumero.interfaces.ampl_nlp import AslNLP
2728
from pyomo.contrib.pynumero.interfaces.nlp import NLP
@@ -92,15 +93,8 @@ def __init__(self, pyomo_model, nl_file_options=None):
9293
# The NL writer advertises the external function libraries
9394
# through the PYOMO_AMPLFUNC environment variable; merge it
9495
# with any preexisting AMPLFUNC definitions
95-
amplfunc = "\n".join(
96-
filter(
97-
None,
98-
(
99-
os.environ.get('AMPLFUNC', None),
100-
os.environ.get('PYOMO_AMPLFUNC', None),
101-
),
102-
)
103-
)
96+
amplfunc = amplfunc_merge(os.environ)
97+
10498
with CtypesEnviron(AMPLFUNC=amplfunc):
10599
super(PyomoNLP, self).__init__(nl_file)
106100

pyomo/solvers/amplfunc_merge.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# ___________________________________________________________________________
2+
#
3+
# Pyomo: Python Optimization Modeling Objects
4+
# Copyright (c) 2008-2024
5+
# National Technology and Engineering Solutions of Sandia, LLC
6+
# Under the terms of Contract DE-NA0003525 with National Technology and
7+
# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
8+
# rights in this software.
9+
# This software is distributed under the 3-clause BSD License.
10+
# ___________________________________________________________________________
11+
12+
13+
def amplfunc_string_merge(amplfunc, pyomo_amplfunc):
14+
"""Merge two AMPLFUNC variable strings eliminating duplicate lines"""
15+
# Assume that the strings amplfunc and pyomo_amplfunc don't contain duplicates
16+
# Assume that the path separator is correct for the OS so we don't need to
17+
# worry about comparing Unix and Windows paths.
18+
amplfunc_lines = amplfunc.split("\n")
19+
existing = set(amplfunc_lines)
20+
for line in pyomo_amplfunc.split("\n"):
21+
# Skip lines we already have
22+
if line not in existing:
23+
amplfunc_lines.append(line)
24+
# Remove empty lines which could happen if one or both of the strings is
25+
# empty or there are two new lines in a row for whatever reason.
26+
amplfunc_lines = [s for s in amplfunc_lines if s != ""]
27+
return "\n".join(amplfunc_lines)
28+
29+
30+
def amplfunc_merge(env):
31+
"""Merge AMPLFUNC and PYOMO_AMPLFUNC in an environment var dict"""
32+
return amplfunc_string_merge(env.get("AMPLFUNC", ""), env.get("PYOMO_AMPLFUNC", ""))

pyomo/solvers/plugins/solvers/ASL.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pyomo.opt.solver import SystemCallSolver
2424
from pyomo.core.kernel.block import IBlock
2525
from pyomo.solvers.mockmip import MockMIP
26+
from pyomo.solvers.amplfunc_merge import amplfunc_merge
2627
from pyomo.core import TransformationFactory
2728

2829
import logging
@@ -158,11 +159,9 @@ def create_command_line(self, executable, problem_files):
158159
# Pyomo/Pyomo) with any user-specified external function
159160
# libraries
160161
#
161-
if 'PYOMO_AMPLFUNC' in env:
162-
if 'AMPLFUNC' in env:
163-
env['AMPLFUNC'] += "\n" + env['PYOMO_AMPLFUNC']
164-
else:
165-
env['AMPLFUNC'] = env['PYOMO_AMPLFUNC']
162+
amplfunc = amplfunc_merge(env)
163+
if amplfunc:
164+
env['AMPLFUNC'] = amplfunc
166165

167166
cmd = [executable, problem_files[0], '-AMPL']
168167
if self._timer:

pyomo/solvers/plugins/solvers/IPOPT.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from pyomo.opt.results import SolverStatus, SolverResults, TerminationCondition
2222
from pyomo.opt.solver import SystemCallSolver
2323

24+
from pyomo.solvers.amplfunc_merge import amplfunc_merge
25+
2426
import logging
2527

2628
logger = logging.getLogger('pyomo.solvers')
@@ -119,11 +121,9 @@ def create_command_line(self, executable, problem_files):
119121
# Pyomo/Pyomo) with any user-specified external function
120122
# libraries
121123
#
122-
if 'PYOMO_AMPLFUNC' in env:
123-
if 'AMPLFUNC' in env:
124-
env['AMPLFUNC'] += "\n" + env['PYOMO_AMPLFUNC']
125-
else:
126-
env['AMPLFUNC'] = env['PYOMO_AMPLFUNC']
124+
amplfunc = amplfunc_merge(env)
125+
if amplfunc:
126+
env['AMPLFUNC'] = amplfunc
127127

128128
cmd = [executable, problem_files[0], '-AMPL']
129129
if self._timer:

pyomo/solvers/plugins/solvers/gurobi_direct.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,9 +493,8 @@ def _add_constraint(self, con):
493493
if not con.active:
494494
return None
495495

496-
if is_fixed(con.body):
497-
if self._skip_trivial_constraints:
498-
return None
496+
if self._skip_trivial_constraints and is_fixed(con.body):
497+
return None
499498

500499
conname = self._symbol_map.getSymbol(con, self._labeler)
501500

pyomo/solvers/plugins/solvers/xpress_direct.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -667,9 +667,8 @@ def _add_constraint(self, con):
667667
if not con.active:
668668
return None
669669

670-
if is_fixed(con.body):
671-
if self._skip_trivial_constraints:
672-
return None
670+
if self._skip_trivial_constraints and is_fixed(con.body):
671+
return None
673672

674673
conname = self._symbol_map.getSymbol(con, self._labeler)
675674

0 commit comments

Comments
 (0)