Skip to content

Commit 5716566

Browse files
authored
Merge pull request #3212 from Robbybp/incidence-remove-error
Require variables and constraints to be specified separately in `IncidenceGraphInterface.remove_nodes`
2 parents 72c0d44 + 24d3baf commit 5716566

File tree

2 files changed

+115
-24
lines changed

2 files changed

+115
-24
lines changed

Diff for: pyomo/contrib/incidence_analysis/interface.py

+72-13
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):

Diff for: pyomo/contrib/incidence_analysis/tests/test_interface.py

+43-11
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])

0 commit comments

Comments
 (0)