Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pyomo Network #583

Merged
merged 74 commits into from
Jul 24, 2018
Merged

Pyomo Network #583

merged 74 commits into from
Jul 24, 2018

Conversation

gseastream
Copy link
Contributor

@gseastream gseastream commented Jun 20, 2018

Summary/Motivation:

This originated out of the desire to have IDAES Streams inherit off of a Pyomo component in order to be able to genericize the sequential modular simulator I'm working on. However, it's also a useful Pyomo component since it provides a simple API for equating everything in two Connectors, and expanding Connections is less expensive than the current ConnectorExpander since it is able to search the model for the specific Connection ctype.

Basically a Connection is a component on which you can define either a source/destination pair for a directed Connection or simply pass a list/tuple of two Connectors for an undirected Connection. After expanding, simple equality constraints are added onto a new block and the connection is deactivated.

Except it's all called ports and arcs now and it's in a new package and it doesn't have to be just an equality relationship.

Changes proposed in this PR:

  • Introduce Pyomo network package
    • Ports and Arcs

Legal Acknowledgement

By contributing to this software project, I agree to the following terms and conditions for my contribution:

  1. I agree my contributions are submitted under the BSD license.
  2. I represent I am authorized to make the contributions and grant the license. If my employer has rights to intellectual property that includes these contributions, I represent that I have received permission to make contributions and grant the required license on behalf of that employer.

@codecov-io
Copy link

codecov-io commented Jun 21, 2018

Codecov Report

Merging #583 into master will increase coverage by 0.27%.
The diff coverage is 91.69%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #583      +/-   ##
==========================================
+ Coverage   65.97%   66.24%   +0.27%     
==========================================
  Files         379      383       +4     
  Lines       60341    61011     +670     
==========================================
+ Hits        39809    40418     +609     
- Misses      20532    20593      +61
Impacted Files Coverage Δ
pyomo/core/base/indexed_component.py 89.03% <100%> (ø) ⬆️
pyomo/core/base/connector.py 88% <100%> (+0.08%) ⬆️
pyomo/network/arc.py 82.67% <82.67%> (ø)
pyomo/network/util.py 92.59% <92.59%> (ø)
pyomo/network/plugins/expand_arcs.py 93.22% <93.22%> (ø)
pyomo/network/port.py 96.11% <96.11%> (ø)
pyomo/core/plugins/transform/expand_connectors.py 93.66% <97.5%> (-1.08%) ⬇️
pyomo/pysp/ph.py 61.14% <0%> (-0.1%) ⬇️
... and 4 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update ae3c6d4...fe9556b. Read the comment docs.

@gseastream
Copy link
Contributor Author

In addition to the whole descend_into thing, I have another question: should the ConnectionExpander only expand active Connections? I noticed that the ConnectorExpander does not filter and will also expand inactive Constraints.

@qtothec
Copy link
Contributor

qtothec commented Jun 21, 2018

I personally think that the reclassification approach makes sense. You're going from the higher-level modeling space of having Connections to the lower-level space of groups of constraints. Is there still a need for the higher level information after transformation?

@gseastream
Copy link
Contributor Author

@qtothec you would be unable to search the model for the Connection ctype after transformation, which for me would take away the point of making a ctype that Streams can inherit off of, since my plan was to be able to search a flowsheet or any connected network for their Connections to build a graph. Do you think in all cases of connected network models from which I want to be able to build a graph we can assume that the building of the graph is done before the transformation is done, and that we will never want quick access to all Connection objects after transformation?

@gseastream
Copy link
Contributor Author

gseastream commented Jun 21, 2018

As I said, one of the options is to change the model walker to find everything that is a subclass of Block. An automatic way to do this would be with a helper function that finds all defined subclasses of a class, like so:

def all_subclasses(cls):
    all_subs = set()
    for sub in cls.__subclasses__():
        all_subs.add(sub)
        all_subs.update(all_subclasses(sub))
    return all_subs

For example, after importing pyomo.environ, for Block this gets: ComplementarityList, IndexedDisjunct, IndexedComplementarity, IndexedBlock, SimpleDisjunct, IndexedPiecewise, SimpleComplementarity, Disjunct, SimplePiecewise, Complementarity, IndexedConnection, SubModel, SimpleBlock, Piecewise, AbstractModel, ConcreteModel, Model, Connection, SimpleConnection.

Meanwhile, just Block.__subclasses__() gets the first level only: SimpleBlock, IndexedBlock, Connection, Piecewise, Disjunct, Complementarity.

I don't know if we want to make it look for every defined subclass or not. Alternatively, we can just explicitly add Connection and any other Blocks we know are safe to descend into. We could even make it a rule that Block subclasses have a transformed flag indicated if they have been transformed and are safe to descend into. Or we just reclassify the Connection and hope that I can count on getting a graph out of a model before it is reclassified, but thinking about IDAES this may not be the case which means it might now be the case for other users of the simulator if there are ever any.

@gseastream gseastream changed the title [WIP] New Component: Connection New Component: Connection Jun 22, 2018
@gseastream
Copy link
Contributor Author

This should now be fully fleshed out and ready for review. A major change from when I first opened this PR is that connections are no longer blocks, they are base level (active) components, and when they are expanded they create blocks on which the constraints are added.

One thing I'm still unsure of and would like some opinions on is the naming conventions for the expanded constraints. It currently works like this, for a connection named connect on model m and connector members named temperature and pressure (scalar vars), and flow (indexed var over (a, b, c)):

The block where everything goes is called "connect.exp". You need to access it with m.component("connect.exp").

On the block, there are scalar constraints for scalar connector vars. They are named like "temperature.equality", and you need to access them with m.component("connect.exp").component("temperature.equality").

For indexed connector vars, there are ConstraintLists on the block containing all the equality constraints, named the same as the constraints above but they are integer indexed.

At the bottom there's an example of what the model looks like after expanding. I'm not 100% sure this is the best naming convention, especially since you have to use the component method due to the use of periods in the names. However, I went with this as a combination of the way connector and streams name their constraints. Any opinions would be appreciated.

Also @qtothec and @jsiirola would you mind reviewing?

m = ConcreteModel()
m.flow = Var(['a', 'b', 'c'])
m.temperature = Var()
m.pressure = Var()
m.con = Connector()
m.con.add(m.flow)
m.con.add(m.temperature)
m.con.add(m.pressure)
m.econ = Connector()
m.connect = Connection(connectors=(m.con, m.econ))
ConnectionExpander().apply(instance=m)
for i in m.component_data_objects(): print(i)
flow_index
flow[c]
flow[b]
flow[a]
temperature
pressure
con
econ
connect
econ.auto.flow[c]
econ.auto.flow[b]
econ.auto.flow[a]
econ.auto.pressure
econ.auto.temperature
connect.exp
connect.exp.flow.equality_index
connect.exp.flow.equality[1]
connect.exp.flow.equality[2]
connect.exp.flow.equality[3]
connect.exp.pressure.equality
connect.exp.temperature.equality

@qtothec
Copy link
Contributor

qtothec commented Jun 22, 2018

You should be aware of this: #525

@blnicho
Copy link
Member

blnicho commented Jun 25, 2018

A couple comments and opinions:

  1. I think it's strange for the econ connector to automatically create missing components. Is this the default behavior for connectors or something new you added?
  2. I would avoid component names with '.' as much as possible following the discussion in Rethinking Pyomo component string representations #525 that @qtothec pointed out but also because it makes it more difficult to interactively interrogate the model. The equality constraints that are being added here are simple enough that I think a user should have easy access to them.
  3. Is there a reason why ConnectionExpander isn't implemented as a standard Pyomo transformation?
  4. I have a strong preference for creating indexed Constraints instead of ConstraintLists when indexed variables are added to a Connector. Indexed Constraints would be compatible with the DAE transformations when you have ContinuousSet indexed variables in a connector.

Here are some naming suggestions:
connect.exp --> connect_expanded or connect_connections or connect_constraints
flow.equality --> flow_equality or flow_connection or flow_con
pressure.equality --> pressure_equality or pressure_connection or pressure_con

Also, you should know about this utility function for guaranteeing unique component names which might be useful for ensuring a unique name for the Block you're adding to the model:

def unique_component_name(instance, name):

@gseastream
Copy link
Contributor Author

@blnicho thanks for the comments.

  1. Yes, this is default connector behavior. The expander can populate empty connectors based on what it is related to.
  2. I actually have a commit that already changed the periods to underscores. Before I push it though I am changing indexed connections to create indexed blocks to avoid names like connect[1]_exp and instead make connect_exp[1].
  3. I don't quite understand what you mean... I implemented the ConnectionExpander the same way as ConnectorExpander. Both are really just thin wrappers for calling the appropriate TransformationFactory plugin. I'm not sure why there exists such a thin wrapper like ConnectorExpander but I was just following what I saw.
  4. I also have already changed it to IndexedConstraints :) I will try to finish this last bit up so I can push.

And thanks for the naming tips.

@gseastream
Copy link
Contributor Author

A comment on the minor change I just made, I realized you don't need the bal constraint (sum of in == sum of out) since you already get this from the insum and outsum constraints, and actually having that extra constraint violates LICQ.

Also I forgot to change the test to reflect this so I'm about to push another commit for that...

@jsiirola
Copy link
Member

@gseastream: while I can see advantages of propagating domains to either auto or extensive variables (particularly for preprocessing transformations), I believe that I agree with @qtothec's implication that it doesn't matter if the variable domains match -- as long as the auto variable's domain is not more restrictive than the "other" variable.

Copy link
Member

@jsiirola jsiirola left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of comments, but nothing that would prevent merging.

logger = logging.getLogger('pyomo.core')


class _ConnectionData(ActiveComponentData):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following Pyomo convention, _*Data classes should be slotized.

# ___________________________________________________________________________

from pyomo.common.plugin import PluginGlobals
PluginGlobals.add_env("pyomo")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pyomo generally avoids importing/registering plugins in the global environment. That said, I am not always sure why that has become the standard. @whart222?

Upon further review, it doesn't look like you are registering plugins. Can the Globals push be omitted?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just copying the __init__.py files of other packages I saw. Other packages (like gdp, dae, pysp, repn) do this exact same thing. I have no comment on or idea of what it does. As for registering plugins, I'm not sure what exactly that means but I do make calls to alias and register_component.

"argument 'ports' must be list or tuple "
"containing exactly 2 Ports.")
for c in ports:
if type(c) not in port_types:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer checking the ctype and not the actual class type. That is: if c.type() is not Port.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only did it this way in case someone passed non-component types (without a .type() method), but I can put it in a try-except to check the ctype.

"containing exactly 2 Ports.")
for c in ports:
if type(c) not in port_types:
if type(c) is IndexedPort:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should test if c.is_indexed():

"must specify both 'source' and 'destination' "
"for directed Arc.")
if type(source) not in port_types:
if type(source) is IndexedPort:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests should be updated as mentioned above.

name == "splitfrac"):
raise ValueError(
"Extensive variable '%s' on Port '%s' may not end "
"with '_split' or '_equality'" % (name, self.name))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can still end up with name collisions. Consider the following (insanity):

m.p = Port()
m.p.add(m.x, 'foo_equality')  # rule=Port.Equality
m.p.add(m.foo, rule=Port.Extensive)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name collisions still won't occur in this case. Basically, every constraint that ends up on the expanded block ends in either _equality or _split. For adding new expanded/extensive variables to the expanded blocks, those new variables have the exact same name as the port member. So if we make sure they don't end in _equality or _split (and that they're not named splitfrac), we can ensure they will not conflict with any other names on the extensive block. Furthermore, as for your insane example, the equality constraint generated for your foo_equality member will be named foo_eqaulity_equality, and the expanded block will then contain a constraint of this name as well as (depending on if the connectivity requires it) a variable named foo and another named splitfrac and a constraint named foo_split.


def _iter_vars(self):
for var in itervalues(self.vars):
if not hasattr(var, 'is_indexed') or not var.is_indexed():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do er need the hasattr? Is this a Kernel thing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this git blame for connectors, you introduced using hasattr 2 years ago with the following commit message:

Removing requirement that Var, Connector support is_indexed()

This fixes what was almost certainly a bug in the original expression
API, where we wanted to be able to tell a user when they attempted to
use an indexed Var where they probably meant to use one of the contained
_VarData objects. To give a good error, we checked is_indexed() -
which suddenly made Var containers support the VarData API. This commit
removes that requirement.

Is the check no longer necessary?

((k, v) for k, v in iteritems(self._data)),
("Name", "Value"), _line_generator)

def Equality(port, name, index_set):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be declared a @classmethod?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just read up on the difference between class and static methods here, and it looks like I would prefer static methods. Class methods require that the first argument be a reference to a class object, but that would be unnecessary here since I don't need a handle on Port as an argument. Not only that, it would change the api of passable rule functions to have to be something that takes a class as its first argument which is weird because it's unnecessary, and then that affects users who may want to define their own custom expansion function and use that as the rule.

That being said, I do think it would look cleaner to use the decorators instead of the staticmethod function calls below, so I'll probably switch to using that.

EDIT: I read further into it and I see that while classmethods have the class as the first argument, it is implicit and does not need to be passed when calling the function. Still, I don't feel like I need the handle on Port as an argument, and as a staticfunction it will be clearer that this is just a plain old ordinary function that happens to be located inside Port.

for arc in port.arcs(active=True):
Port._add_equality_constraint(arc, name, index_set)

def Extensive(port, name, index_set, write_var_sum=True):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be declared a @classmethod?

"""
port_parent = port.parent_block()
out_vars = Port._Split(port, name, index_set, write_var_sum)
in_vars = Port._Combine(port, name, index_set)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why you don't call these methods as port._Split(name, index_set, write_var_sum)? That way they don't need to be declared as static methods below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't consider them methods of the instance, but rather transformation functions that can be called on a port, much in the same vein as Extensive. I wouldn't want to confuse instance methods for transformation functions that happen to be stored in the class definition. And theoretically, they can be used as the rule instead of Extensive if for whatever reason the user only wants to split things and never mix, so they follow the same api as Extensive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Development

Successfully merging this pull request may close these issues.

None yet

6 participants